diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2bff7a2..30514a3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -173,16 +173,8 @@ jobs: with: path: release-assets merge-multiple: true - - name: Stage installer scripts - shell: bash - run: | - mkdir -p release-package - cp release-assets/* release-package/ - cp agent/agent.sh release-package/install-agent.sh - cp agent/install.ps1 release-package/install-agent.ps1 - cp agent/install-macos.sh release-package/install-agent-macos.sh - (cd release-package && sha256sum * > SHA256SUMS) - uses: softprops/action-gh-release@fe965f7af51af5f2602596916f38a38df2e33de0 # v3.0.2 with: body_path: CHANGELOG.md - files: release-package/** + files: release-assets/* + fail_on_unmatched_files: true diff --git a/agent/agent.sh b/agent/agent.sh index 8d24c78..9018da3 100644 --- a/agent/agent.sh +++ b/agent/agent.sh @@ -1,7 +1,7 @@ #!/bin/sh set -eu -SERVICE_NAME="nodeflare-agent" +SERVICE_NAME="nodeflare" INSTALL_DIR="/opt/nodeflare" AGENT_FILE="$INSTALL_DIR/agent" CONFIG_FILE="$INSTALL_DIR/agent.env" @@ -53,12 +53,34 @@ install_agent() { temporary="$INSTALL_DIR/.agent.$$.download" trap 'rm -f "$temporary"' EXIT HUP INT TERM artifact="agent-linux-$arch" + release_api="https://api.github.com/repos/imengying/NodeFlare/releases/latest" + release_json=$(curl --fail --location --silent --show-error --max-time 30 \ + -H 'Accept: application/vnd.github+json' \ + -H 'User-Agent: nodeflare-installer' \ + "$release_api") + expected=$(printf '%s\n' "$release_json" | tr '{' '\n' | awk -v name="$artifact" ' + { + compact = $0 + gsub(/[[:space:]]/, "", compact) + if (index(compact, "\"name\":\"" name "\"") > 0) selected = 1 + else if (index(compact, "\"name\":") > 0) selected = 0 + } + selected { + marker = "\"digest\":\"sha256:" + position = index(compact, marker) + if (position == 0) next + digest = substr(compact, position + length(marker), 64) + if (length(digest) == 64 && digest !~ /[^0-9a-fA-F]/) { + print tolower(digest) + exit + } + } + ') + [ -n "$expected" ] || { echo "GitHub release does not contain a SHA-256 digest for $artifact" >&2; exit 1; } release_base="https://github.com/imengying/NodeFlare/releases/latest/download" curl --fail --location --silent --show-error --max-time 120 \ "$release_base/$artifact" \ -o "$temporary" - checksums=$(curl --fail --location --silent --show-error --max-time 30 "$release_base/SHA256SUMS") - expected=$(printf '%s\n' "$checksums" | awk -v name="$artifact" '$2 == name { print $1; exit }') actual=$(sha256sum "$temporary" | awk '{ print $1 }') [ -n "$expected" ] && [ "$actual" = "$expected" ] || { echo "Agent checksum verification failed" >&2; exit 1; } chmod 755 "$temporary" diff --git a/agent/install-macos.sh b/agent/install-macos.sh index bcdb301..a615315 100644 --- a/agent/install-macos.sh +++ b/agent/install-macos.sh @@ -60,12 +60,34 @@ mkdir -p "$INSTALL_DIR" temporary="$INSTALL_DIR/.agent.$$.download" trap 'rm -f "$temporary"' EXIT HUP INT TERM artifact="agent-macos-aarch64" +release_api="https://api.github.com/repos/imengying/NodeFlare/releases/latest" +release_json=$(curl --fail --location --silent --show-error --max-time 30 \ + -H 'Accept: application/vnd.github+json' \ + -H 'User-Agent: nodeflare-installer' \ + "$release_api") +expected=$(printf '%s\n' "$release_json" | tr '{' '\n' | awk -v name="$artifact" ' + { + compact = $0 + gsub(/[[:space:]]/, "", compact) + if (index(compact, "\"name\":\"" name "\"") > 0) selected = 1 + else if (index(compact, "\"name\":") > 0) selected = 0 + } + selected { + marker = "\"digest\":\"sha256:" + position = index(compact, marker) + if (position == 0) next + digest = substr(compact, position + length(marker), 64) + if (length(digest) == 64 && digest !~ /[^0-9a-fA-F]/) { + print tolower(digest) + exit + } + } +') +[ -n "$expected" ] || { echo "GitHub release does not contain a SHA-256 digest for $artifact" >&2; exit 1; } release_base="https://github.com/imengying/NodeFlare/releases/latest/download" curl --fail --location --silent --show-error --max-time 120 \ "$release_base/$artifact" \ -o "$temporary" -checksums=$(curl --fail --location --silent --show-error --max-time 30 "$release_base/SHA256SUMS") -expected=$(printf '%s\n' "$checksums" | awk -v name="$artifact" '$2 == name { print $1; exit }') actual=$(shasum -a 256 "$temporary" | awk '{ print $1 }') [ -n "$expected" ] && [ "$actual" = "$expected" ] || { echo "Agent checksum verification failed" >&2; exit 1; } chmod 755 "$temporary" diff --git a/agent/install.ps1 b/agent/install.ps1 index 466d6e4..b7e4ca8 100644 --- a/agent/install.ps1 +++ b/agent/install.ps1 @@ -74,17 +74,17 @@ $CollectInterval = [Math]::Min($CollectInterval, $Interval) New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null & icacls.exe $InstallDir /inheritance:r /grant:r 'SYSTEM:(OI)(CI)F' 'Administrators:(OI)(CI)F' | Out-Null $Temporary = "$AgentFile.$PID.download.exe" -$ReleaseBase = "https://github.com/imengying/NodeFlare/releases/latest/download" +$ReleaseApi = "https://api.github.com/repos/imengying/NodeFlare/releases/latest" $Artifact = "agent-windows-x86_64.exe" -$ReleaseDownload = "$ReleaseBase/$Artifact" try { - Invoke-WebRequest -Uri $ReleaseDownload -OutFile $Temporary -TimeoutSec 120 - $Checksums = (Invoke-WebRequest -Uri "$ReleaseBase/SHA256SUMS" -TimeoutSec 30).Content - $ChecksumMatch = [regex]::Match($Checksums, "(?m)^([0-9a-fA-F]{64})\s+\*?agent-windows-x86_64\.exe\s*$") - if (-not $ChecksumMatch.Success) { - throw "SHA256SUMS does not contain $Artifact" + $Release = Invoke-RestMethod -Uri $ReleaseApi -Headers @{ Accept = "application/vnd.github+json"; "User-Agent" = "nodeflare-installer" } -TimeoutSec 30 + $ReleaseAsset = $Release.assets | Where-Object { $_.name -eq $Artifact } | Select-Object -First 1 + $DigestMatch = [regex]::Match([string]$ReleaseAsset.digest, '^sha256:([0-9a-fA-F]{64})$') + if ($null -eq $ReleaseAsset -or -not $DigestMatch.Success) { + throw "GitHub release does not contain a SHA-256 digest for $Artifact" } - $ExpectedChecksum = $ChecksumMatch.Groups[1].Value + $ExpectedChecksum = $DigestMatch.Groups[1].Value + Invoke-WebRequest -Uri $ReleaseAsset.browser_download_url -OutFile $Temporary -TimeoutSec 120 $ActualChecksum = (Get-FileHash -LiteralPath $Temporary -Algorithm SHA256).Hash if ($ActualChecksum -ne $ExpectedChecksum) { throw "Agent checksum verification failed" diff --git a/agent/src/main.rs b/agent/src/main.rs index e8744db..96c12a0 100644 --- a/agent/src/main.rs +++ b/agent/src/main.rs @@ -90,6 +90,7 @@ struct GithubRelease { struct GithubReleaseAsset { name: String, browser_download_url: String, + digest: Option, } #[derive(Debug, Deserialize)] @@ -1115,30 +1116,13 @@ fn sha256_file(path: &Path) -> Result { .collect()) } -fn parse_checksum(contents: &str, artifact: &str) -> Option { - contents.lines().find_map(|line| { - let mut fields = line.split_whitespace(); - let hash = fields.next()?; - let name = fields.next()?.trim_start_matches('*'); - (name == artifact - && fields.next().is_none() - && hash.len() == 64 - && hash.chars().all(|character| character.is_ascii_hexdigit())) - .then(|| hash.to_ascii_lowercase()) - }) -} - -fn release_checksum(agent: &ureq::Agent, url: &str, artifact: &str) -> Result { - let mut contents = String::new(); - agent - .get(url) - .call()? - .into_body() - .into_reader() - .take(1024 * 1024) - .read_to_string(&mut contents)?; - parse_checksum(&contents, artifact) - .ok_or_else(|| format!("SHA256SUMS does not contain a valid entry for {artifact}").into()) +fn release_asset_sha256(asset: &GithubReleaseAsset) -> Option { + let digest = asset.digest.as_deref()?.strip_prefix("sha256:")?; + (digest.len() == 64 + && digest + .chars() + .all(|character| character.is_ascii_hexdigit())) + .then(|| digest.to_ascii_lowercase()) } fn download_agent( @@ -1209,26 +1193,17 @@ fn update(agent: &ureq::Agent) -> Result { "" } )); - let Some(download_url) = release - .assets - .iter() - .find(|asset| asset.name == artifact) - .map(|asset| asset.browser_download_url.as_str()) - else { + let Some(release_asset) = release.assets.iter().find(|asset| asset.name == artifact) else { return Err(format!("latest release does not contain {artifact}").into()); }; - let Some(checksum_url) = release - .assets - .iter() - .find(|asset| asset.name == "SHA256SUMS") - .map(|asset| asset.browser_download_url.as_str()) - else { - return Err("latest release does not contain SHA256SUMS".into()); + let Some(expected_sha256) = release_asset_sha256(release_asset) else { + return Err( + format!("latest release does not contain a SHA-256 digest for {artifact}").into(), + ); }; - let expected_sha256 = release_checksum(agent, checksum_url, artifact)?; if !download_agent( agent, - download_url, + &release_asset.browser_download_url, &temporary, &release.tag_name, &expected_sha256, @@ -1409,9 +1384,10 @@ mod tests { use super::{ agent_artifact_name, executable_format_valid, is_public_probe_ip, median, - normalized_version, parse_checksum, parse_probe_target, ping_latency, prune_report_samples, - report_retry_delay, sanitize_latency_tasks, selected_interface, tcp_latency_probe_address, - valid_worker_url, version_triplet, LatencyTask, Report, MAX_LATENCY_TASKS, PROBE_ATTEMPTS, + normalized_version, parse_probe_target, ping_latency, prune_report_samples, + release_asset_sha256, report_retry_delay, sanitize_latency_tasks, selected_interface, + tcp_latency_probe_address, valid_worker_url, version_triplet, GithubReleaseAsset, + LatencyTask, Report, MAX_LATENCY_TASKS, PROBE_ATTEMPTS, }; #[cfg(any(target_os = "windows", target_os = "macos"))] @@ -1445,14 +1421,22 @@ mod tests { } #[test] - fn parses_release_checksums() { - let hash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; - let checksums = format!("{hash} agent-linux-x86_64\ninvalid other\n"); + fn validates_release_asset_digests() { + let hash = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"; + let mut asset = GithubReleaseAsset { + name: "agent-linux-x86_64".to_string(), + browser_download_url: "https://example.com/agent".to_string(), + digest: Some(format!("sha256:{hash}")), + }; + let expected = hash.to_ascii_lowercase(); assert_eq!( - parse_checksum(&checksums, "agent-linux-x86_64").as_deref(), - Some(hash) + release_asset_sha256(&asset).as_deref(), + Some(expected.as_str()) ); - assert_eq!(parse_checksum(&checksums, "other"), None); + asset.digest = Some("sha512:0123".to_string()); + assert_eq!(release_asset_sha256(&asset), None); + asset.digest = None; + assert_eq!(release_asset_sha256(&asset), None); } #[test] diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index c76f28a..179bd92 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -432,22 +432,22 @@ export function AdminPanel({ const commands = useMemo(() => install.map((server) => { const origin = window.location.origin; - const release = "https://github.com/imengying/NodeFlare/releases/latest/download"; + const installer = "https://raw.githubusercontent.com/imengying/NodeFlare/main/agent"; if (installPlatform === "windows") { return { ...server, - command: `Invoke-WebRequest -Uri "${release}/install-agent.ps1" -OutFile "$env:TEMP\\nodeflare-install.ps1"\n& "$env:TEMP\\nodeflare-install.ps1" install -ServerId ${powershellLiteral(server.id)} -Token ${powershellLiteral(server.agent_token)} -Url ${powershellLiteral(origin)}`, + command: `Invoke-WebRequest -Uri "${installer}/install.ps1" -OutFile "$env:TEMP\\nodeflare-install.ps1"\n& "$env:TEMP\\nodeflare-install.ps1" install -ServerId ${powershellLiteral(server.id)} -Token ${powershellLiteral(server.agent_token)} -Url ${powershellLiteral(origin)}`, }; } if (installPlatform === "macos") { return { ...server, - command: `curl -fsSL ${release}/install-agent-macos.sh | sudo sh -s -- install --server-id ${shellLiteral(server.id)} --token ${shellLiteral(server.agent_token)} --url ${shellLiteral(origin)}`, + command: `curl -fsSL ${installer}/install-macos.sh | sudo sh -s -- install --server-id ${shellLiteral(server.id)} --token ${shellLiteral(server.agent_token)} --url ${shellLiteral(origin)}`, }; } return { ...server, - command: `curl -fsSL ${release}/install-agent.sh | sudo sh -s -- install --server-id ${shellLiteral(server.id)} --token ${shellLiteral(server.agent_token)} --url ${shellLiteral(origin)}`, + command: `curl -fsSL ${installer}/agent.sh | sudo sh -s -- install --server-id ${shellLiteral(server.id)} --token ${shellLiteral(server.agent_token)} --url ${shellLiteral(origin)}`, }; }), [install, installPlatform]);