diff options
| author | Nikolay Govorov <me@govorov.online> | 2026-01-31 05:59:43 +0000 |
|---|---|---|
| committer | Nikolay Govorov <me@govorov.online> | 2026-01-31 05:59:43 +0000 |
| commit | 9ac2a1efd7cc7c5201a9be4a387d415b06f3bc4a (patch) | |
| tree | 1307f34157122c9d78af96c0bb8b11e52591e966 | |
| parent | ac40af04288b870641fe1a2ac23157b228ee13f6 (diff) | |
| download | tar tar.gz tar.bz2 tar.lz tar.xz tar.zst zip | |
Support Go downloads
Diffstat
| -rw-r--r-- | src/backends/go.rs | 352 | +352 −0 |
| -rw-r--r-- | src/backends/mod.rs | 1 | +1 −0 |
| -rw-r--r-- | src/main.rs | 7 | +7 −0 |
| -rw-r--r-- | src/web.rs | 55 | +42 −13 |
4 files changed, 402 insertions, 13 deletions
diff --git a/src/backends/go.rs b/src/backends/go.rs new file mode 100644 --- /dev/null +++ b/src/backends/go.rs @@ -0,0 +1,352 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov <me@govorov.online> +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::sync::Arc; + +use axum::{Router, body, extract, http, response, routing}; +use thiserror::Error; +use tracing::error; + +use crate::config; +use crate::proxy; +use crate::storage; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Archive { + TarGz, + Zip, + Msi, + Pkg, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ReleaseType { + Stable, + ReleaseCandidate(u32), + Beta(u32), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum TarballType<'a> { + Source, + Binary { os: &'a str, arch: &'a str }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[error("invalid tarball filename")] +struct ParseError; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct GoVersion { + major: u32, + minor: u32, + patch: Option<u32>, + release_type: ReleaseType, +} + +/// Describes a single file stored at `dl.google.com/go/`. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Tarball<'a> { + filename: &'a str, + version: GoVersion, + tarball_type: TarballType<'a>, + archive: Archive, + sha256: bool, +} + +impl<'a> Tarball<'a> { + pub fn parse(filename: &'a str) -> Result<Self, ParseError> { + let mut buffer = filename; + let mut sha256 = false; + let archive; + + // Strip "go" prefix + buffer = buffer.strip_prefix("go").ok_or(ParseError)?; + + // Check for .sha256 suffix + if let Some(it) = buffer.strip_suffix(".sha256") { + buffer = it; + sha256 = true; + } + + // Determine archive type + if let Some(it) = buffer.strip_suffix(".tar.gz") { + buffer = it; + archive = Archive::TarGz; + } else if let Some(it) = buffer.strip_suffix(".zip") { + buffer = it; + archive = Archive::Zip; + } else if let Some(it) = buffer.strip_suffix(".msi") { + buffer = it; + archive = Archive::Msi; + } else if let Some(it) = buffer.strip_suffix(".pkg") { + buffer = it; + archive = Archive::Pkg; + } else { + return Err(ParseError); + } + + if buffer.is_empty() { + return Err(ParseError); + } + + // Split by dots: "1.25.6.linux-amd64" -> ["1", "25", "6", "linux-amd64"] + // or "1.26rc2.linux-amd64" -> ["1", "26rc2", "linux-amd64"] + let parts: Vec<&str> = buffer.split('.').collect(); + if parts.len() < 3 { + return Err(ParseError); + } + + // Parse major version + let major = parts[0].parse::<u32>().map_err(|_| ParseError)?; + + // Parse minor version with possible release type (rc/beta) + let (minor, release_type) = parse_minor_with_release(parts[1])?; + + // Determine patch and remainder + let (patch, remainder) = if parts.len() >= 4 { + // Try to parse parts[2] as patch number + if let Ok(p) = parts[2].parse::<u32>() { + (Some(p), parts[3]) + } else { + (None, parts[2]) + } + } else { + // parts.len() == 3, no patch + (None, parts[2]) + }; + + // Determine tarball type + let tarball_type = if remainder == "src" { + TarballType::Source + } else if let Some((os, arch)) = remainder.split_once('-') { + TarballType::Binary { os, arch } + } else { + return Err(ParseError); + }; + + Ok(Tarball { + filename, + version: GoVersion { + major, + minor, + patch, + release_type, + }, + tarball_type, + archive, + sha256, + }) + } + + /// Builds the upstream URL for this tarball. + pub fn upstream_url(&self, source: &str) -> String { + // Use direct URL (go.dev/dl/ redirects to dl.google.com/go/) + format!( + "https://dl.google.com/go/{}?source={}", + self.filename, source + ) + } +} + +/// Parses minor version with possible release type suffix. +/// "25" -> (25, Stable) +/// "26rc2" -> (26, ReleaseCandidate(2)) +/// "26beta1" -> (26, Beta(1)) +fn parse_minor_with_release(s: &str) -> Result<(u32, ReleaseType), ParseError> { + if let Some(idx) = s.find("rc") { + let minor = s[..idx].parse::<u32>().map_err(|_| ParseError)?; + let rc_num = s[idx + 2..].parse::<u32>().map_err(|_| ParseError)?; + return Ok((minor, ReleaseType::ReleaseCandidate(rc_num))); + } + if let Some(idx) = s.find("beta") { + let minor = s[..idx].parse::<u32>().map_err(|_| ParseError)?; + let beta_num = s[idx + 4..].parse::<u32>().map_err(|_| ParseError)?; + return Ok((minor, ReleaseType::Beta(beta_num))); + } + let minor = s.parse::<u32>().map_err(|_| ParseError)?; + Ok((minor, ReleaseType::Stable)) +} + +pub struct GoController { + config: Arc<config::ConfigService>, + storage: Arc<storage::StorageService>, + upstream: Arc<proxy::ProxyService>, +} + +impl GoController { + pub fn new( + config: Arc<config::ConfigService>, + storage: Arc<storage::StorageService>, + upstream: Arc<proxy::ProxyService>, + ) -> Self { + Self { + config, + storage, + upstream, + } + } + + pub fn router(self: Arc<Self>) -> Router { + Router::new() + .route("/go/{filename}", routing::get(Self::handle)) + .with_state(self) + } + + async fn handle( + extract::State(controller): extract::State<Arc<Self>>, + extract::Path(filename): extract::Path<String>, + ) -> Result<response::Response, http::StatusCode> { + let tarball = Tarball::parse(&filename).map_err(|_| http::StatusCode::NOT_FOUND)?; + let url = tarball.upstream_url(controller.config.appname()); + + match controller.storage.get("go", &filename).await { + Ok(Some(entry)) => { + return Ok(Self::build_response( + http::StatusCode::OK, + entry.file_bytes.0, + )); + } + Ok(None) => {} + Err(err) => { + error!("failed get file from storage: {err}"); + return Err(http::StatusCode::INTERNAL_SERVER_ERROR); + } + } + + let entry = controller + .upstream + .fetch(proxy::DownloadRequest { url }) + .await?; + + match controller.storage.put("go", &filename, &entry.bytes).await { + Ok(()) => {} + Err(_) => { + return Err(http::StatusCode::INTERNAL_SERVER_ERROR); + } + } + + Ok(Self::build_response(http::StatusCode::OK, entry.bytes)) + } + + fn build_response(status: http::StatusCode, bytes: bytes::Bytes) -> response::Response { + response::Response::builder() + .status(status) + .header(http::header::CONTENT_TYPE, "application/octet-stream") + .header(http::header::CONTENT_LENGTH, bytes.len()) + .body(body::Body::from(bytes)) + .unwrap() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_stable_binary() { + let t = Tarball::parse("go1.25.6.linux-amd64.tar.gz").unwrap(); + assert_eq!(t.version.major, 1); + assert_eq!(t.version.minor, 25); + assert_eq!(t.version.patch, Some(6)); + assert!(matches!(t.version.release_type, ReleaseType::Stable)); + assert!(matches!( + t.tarball_type, + TarballType::Binary { + os: "linux", + arch: "amd64" + } + )); + assert_eq!(t.archive, Archive::TarGz); + assert!(!t.sha256); + } + + #[test] + fn test_parse_first_minor_release() { + let t = Tarball::parse("go1.25.linux-amd64.tar.gz").unwrap(); + assert_eq!(t.version.major, 1); + assert_eq!(t.version.minor, 25); + assert_eq!(t.version.patch, None); + assert!(matches!(t.version.release_type, ReleaseType::Stable)); + } + + #[test] + fn test_parse_rc() { + let t = Tarball::parse("go1.26rc2.linux-amd64.tar.gz").unwrap(); + assert_eq!(t.version.major, 1); + assert_eq!(t.version.minor, 26); + assert_eq!(t.version.patch, None); + assert!(matches!( + t.version.release_type, + ReleaseType::ReleaseCandidate(2) + )); + } + + #[test] + fn test_parse_beta() { + let t = Tarball::parse("go1.26beta1.darwin-arm64.tar.gz").unwrap(); + assert_eq!(t.version.major, 1); + assert_eq!(t.version.minor, 26); + assert!(matches!(t.version.release_type, ReleaseType::Beta(1))); + } + + #[test] + fn test_parse_source() { + let t = Tarball::parse("go1.25.6.src.tar.gz").unwrap(); + assert!(matches!(t.tarball_type, TarballType::Source)); + assert_eq!(t.version.patch, Some(6)); + } + + #[test] + fn test_parse_windows_zip() { + let t = Tarball::parse("go1.25.6.windows-amd64.zip").unwrap(); + assert_eq!(t.archive, Archive::Zip); + assert!(matches!( + t.tarball_type, + TarballType::Binary { + os: "windows", + arch: "amd64" + } + )); + } + + #[test] + fn test_parse_msi() { + let t = Tarball::parse("go1.25.6.windows-amd64.msi").unwrap(); + assert_eq!(t.archive, Archive::Msi); + } + + #[test] + fn test_parse_pkg() { + let t = Tarball::parse("go1.25.6.darwin-arm64.pkg").unwrap(); + assert_eq!(t.archive, Archive::Pkg); + } + + #[test] + fn test_parse_sha256() { + let t = Tarball::parse("go1.25.6.linux-amd64.tar.gz.sha256").unwrap(); + assert!(t.sha256); + assert_eq!(t.archive, Archive::TarGz); + assert_eq!(t.version.patch, Some(6)); + } + + #[test] + fn test_upstream_url() { + let t = Tarball::parse("go1.25.6.linux-amd64.tar.gz").unwrap(); + let url = t.upstream_url("zorian"); + assert_eq!( + url, + "https://dl.google.com/go/go1.25.6.linux-amd64.tar.gz?source=zorian" + ); + } + + #[test] + fn test_invalid_prefix() { + assert!(Tarball::parse("rust1.25.6.linux-amd64.tar.gz").is_err()); + } + + #[test] + fn test_invalid_extension() { + assert!(Tarball::parse("go1.25.6.linux-amd64.tar.bz2").is_err()); + } +} diff --git a/src/backends/mod.rs b/src/backends/mod.rs index 1fb3a4b..910005c 100644 --- a/src/backends/mod.rs +++ b/src/backends/mod.rs @@ -1,4 +1,5 @@ // SPDX-FileCopyrightText: 2026 Nikolay Govorov <me@govorov.online> // SPDX-License-Identifier: AGPL-3.0-or-later +pub mod go; pub mod zig; diff --git a/src/main.rs b/src/main.rs index 7dd6c1f..54b7246 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,6 +28,7 @@ use tokio::signal; use tracing::{error, info, trace}; use tracing_subscriber::registry::LookupSpan; +use crate::backends::go::GoController; use crate::backends::zig::ZigController; use crate::web::WebController; @@ -124,6 +125,11 @@ async fn main() { storage.clone(), upstream.clone(), )); + let go_controller = Arc::new(GoController::new( + config.clone(), + storage.clone(), + upstream.clone(), + )); const REQUEST_ID_HEADER: http::HeaderName = http::HeaderName::from_static("x-request-id"); @@ -222,6 +228,7 @@ async fn main() { let app = axum::Router::new() .merge(web_controller.router()) .merge(zig_controller.router()) + .merge(go_controller.router()) // Opt-in layers .layer(tower_http::compression::CompressionLayer::new()) // request limits diff --git a/src/web.rs b/src/web.rs index 838cbc1..4866c15 100644 --- a/src/web.rs +++ b/src/web.rs @@ -148,7 +148,7 @@ fn parse_http_date(value: &str) -> Option<DateTime<Utc>> { .ok() } -const HTML: &str = r#" +const HTML: &str = r##" <!DOCTYPE html> <html lang="en"> <head> @@ -161,19 +161,26 @@ const HTML: &str = r#" </head> <body> - <h1>Earth PKG — tiny & opinionated packages mirror.</h1> + <h1>Zorian — tiny & opinionated packages mirror.</h1> <main> - <p>This site provides a proxy for downloading zig installation files and dependencies. - On the one hand, this reduces the load on the original project's site - and makes your infrastructure more reliable by adding redundancy.</p> + <p>This site provides a caching proxy for downloading Zig and Go installation files. + It reduces load on upstream servers and makes your infrastructure more reliable by adding redundancy.</p> + + <p>Zorian is open source software licensed under <a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a>. + Source code is available on <a href="https://github.com/mrdimidium/zorian">GitHub</a>.</p> + + <h2>Usage</h2> + + <p>Replace official download URLs with <code>https://pkg.earth/{tool}/{filename}</code>. + Files are cached automatically after the first download.</p> + + <h3 id="zig"><a href="#zig">Zig</a></h3> <p>Read more about community mirrors in the <a href="https://ziglang.org/download/community-mirrors/">blog post</a>. - Information on how to deploy your own mirror is available + Information on how to deploy your own mirror is available <a href="https://github.com/ziglang/www.ziglang.org/blob/main/MIRRORS.md">in the documentation</a>.</p> - <h2>Direct usage:</h2> - <p>For simplicity, you can use tools like <a href="https://github.com/prantlf/zigup">prantlf/zigup</a> and <a href="https://github.com/mlugg/setup-zig">mlugg/setup-zig</a>.</p> @@ -181,13 +188,26 @@ const HTML: &str = r#" To install manually: <ol> <li>download zig dist file:<br><code>wget https://pkg.earth/zig/zig-x86_64-linux-0.15.1.tar.xz</code>;</li> - <li>download zig minisign file:<br><code>wget https://pkg.earth/zig/zig-x86_64-linux-0.15.1.tar.xz.minisig</code>;</li> + <li>download zig minisig file:<br><code>wget https://pkg.earth/zig/zig-x86_64-linux-0.15.1.tar.xz.minisig</code>;</li> <li>check archive integrity:<br><code>minisign -Vm zig-x86_64-linux-0.15.1.tar.xz -P RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U</code>;</li> <li>unpack archive:<br><code>tar -xf "zig-x86_64-linux-0.15.1.tar.xz"</code>;</li> <li>check installed zig:<br><code>./zig-x86_64-linux-0.15.1/zig --version</code>.</li> </ol> + You can take actual minisig public key at <a href="https://ziglang.org/download/">ziglang.org/download</a>. + </p> - You can take actual minisign public key in <a href="https://ziglang.org/download/">download page</a>. + <h3 id="go"><a href="#go">Go</a></h3> + + <p> + To install manually: + <ol> + <li>download go dist file:<br><code>wget https://pkg.earth/go/go1.23.0.linux-amd64.tar.gz</code>;</li> + <li>download go sha256 file:<br><code>wget https://pkg.earth/go/go1.23.0.linux-amd64.tar.gz.sha256</code>;</li> + <li>check archive integrity:<br><code>sha256sum -c go1.23.0.linux-amd64.tar.gz.sha256</code>;</li> + <li>unpack archive:<br><code>tar -xzf go1.23.0.linux-amd64.tar.gz</code>;</li> + <li>check installed go:<br><code>./go/bin/go version</code>.</li> + </ol> + You can find available versions at <a href="https://go.dev/dl/">go.dev/dl</a>. </p> <h2>Privacy policy</h2> @@ -202,9 +222,9 @@ const HTML: &str = r#" </main> </body> </html> -"#; +"##; -const CSS: &str = " +const CSS: &str = r##" :root { font-size: 1.125rem; line-height: 1.4; @@ -246,4 +266,13 @@ h1:first-child { th { text-align: start; } -"; + +h3 a { + color: inherit; + text-decoration: none; +} + +h3 a:hover { + text-decoration: underline; +} +"##; |
