From ea030e0dcdc9f282b3e3c96623e5e323b2a8b106 Mon Sep 17 00:00:00 2001 From: Nikolay Govorov Date: Sat, 31 Jan 2026 07:33:13 +0000 Subject: Unifies backends --- src/backends/go.rs | 128 ++++++------------ src/backends/mod.rs | 12 ++ src/backends/zig.rs | 128 ++++++------------ src/config.rs | 16 +++ src/controller_backend.rs | 95 +++++++++++++ src/controller_web.rs | 278 ++++++++++++++++++++++++++++++++++++++ src/main.rs | 49 ++++--- src/web.rs | 278 -------------------------------------- 8 files changed, 516 insertions(+), 468 deletions(-) create mode 100644 src/controller_backend.rs create mode 100644 src/controller_web.rs delete mode 100644 src/web.rs diff --git a/src/backends/go.rs b/src/backends/go.rs index 9a241c5..df94df8 100644 --- a/src/backends/go.rs +++ b/src/backends/go.rs @@ -1,15 +1,46 @@ // SPDX-FileCopyrightText: 2026 Nikolay Govorov // SPDX-License-Identifier: AGPL-3.0-or-later -use std::sync::Arc; - -use axum::{Router, body, extract, http, response, routing}; +use serde::Deserialize; use thiserror::Error; -use tracing::error; -use crate::config; -use crate::proxy; -use crate::storage; +use super::Backend; + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct GoConfig { + pub enabled: bool, + pub upstream: String, +} + +impl Default for GoConfig { + fn default() -> Self { + Self { + enabled: true, + upstream: String::from("https://dl.google.com/go"), + } + } +} + +pub struct GoBackend { + config: GoConfig, + source: String, +} + +impl GoBackend { + pub fn new(config: GoConfig, source: String) -> Self { + Self { config, source } + } +} + +impl Backend for GoBackend { + const ID: &'static str = "go"; + + fn upstream_url(&self, filename: &str) -> Result { + let tarball = Tarball::parse(filename).map_err(|_| ())?; + Ok(tarball.upstream_url(&self.config.upstream, &self.source)) + } +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Archive { @@ -140,12 +171,8 @@ impl<'a> Tarball<'a> { } /// 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 - ) + pub fn upstream_url(&self, upstream: &str, source: &str) -> String { + format!("{}/{}?source={}", upstream, self.filename, source) } } @@ -168,77 +195,6 @@ fn parse_minor_with_release(s: &str) -> Result<(u32, ReleaseType), ParseError> { Ok((minor, ReleaseType::Stable)) } -pub struct GoController { - config: Arc, - storage: Arc, - upstream: Arc, -} - -impl GoController { - pub fn new( - config: Arc, - storage: Arc, - upstream: Arc, - ) -> Self { - Self { - config, - storage, - upstream, - } - } - - pub fn router(self: Arc) -> Router { - Router::new() - .route("/go/{filename}", routing::get(Self::handle)) - .with_state(self) - } - - async fn handle( - extract::State(controller): extract::State>, - extract::Path(filename): extract::Path, - ) -> Result { - 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::*; @@ -333,10 +289,10 @@ mod tests { #[test] fn test_upstream_url() { let t = Tarball::parse("go1.25.6.linux-amd64.tar.gz").unwrap(); - let url = t.upstream_url("zorian"); + let url = t.upstream_url("https://dl.google.com/go", "zorian:test"); assert_eq!( url, - "https://dl.google.com/go/go1.25.6.linux-amd64.tar.gz?source=zorian" + "https://dl.google.com/go/go1.25.6.linux-amd64.tar.gz?source=zorian:test" ); } diff --git a/src/backends/mod.rs b/src/backends/mod.rs index 910005c..97ac397 100644 --- a/src/backends/mod.rs +++ b/src/backends/mod.rs @@ -3,3 +3,15 @@ pub mod go; pub mod zig; + +pub use go::{GoBackend, GoConfig}; +pub use zig::{ZigBackend, ZigConfig}; + +/// Trait for backend-specific logic (parsing, URL building). +pub trait Backend: Send + Sync + 'static { + /// Fixed unique identifier for storage + const ID: &'static str; + + /// Validates filename and returns the upstream URL. + fn upstream_url(&self, filename: &str) -> Result; +} diff --git a/src/backends/zig.rs b/src/backends/zig.rs index 9131511..424fd34 100644 --- a/src/backends/zig.rs +++ b/src/backends/zig.rs @@ -1,16 +1,47 @@ // SPDX-FileCopyrightText: 2026 Nikolay Govorov // SPDX-License-Identifier: AGPL-3.0-or-later -use std::sync::Arc; - -use axum::{Router, body, extract, http, response, routing}; use semver::Version; +use serde::Deserialize; use thiserror::Error; -use tracing::error; -use crate::config; -use crate::proxy; -use crate::storage; +use super::Backend; + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct ZigConfig { + pub enabled: bool, + pub upstream: String, +} + +impl Default for ZigConfig { + fn default() -> Self { + Self { + enabled: true, + upstream: String::from("https://ziglang.org"), + } + } +} + +pub struct ZigBackend { + config: ZigConfig, + source: String, +} + +impl ZigBackend { + pub fn new(config: ZigConfig, source: String) -> Self { + Self { config, source } + } +} + +impl Backend for ZigBackend { + const ID: &'static str = "zig"; + + fn upstream_url(&self, filename: &str) -> Result { + let tarball = Tarball::parse(filename).map_err(|_| ())?; + Ok(tarball.upstream_url(&self.config.upstream, &self.source)) + } +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Archive { @@ -132,89 +163,14 @@ impl<'a> Tarball<'a> { } /// Builds the upstream URL for this tarball. - pub fn upstream_url(&self, source: &str) -> String { + pub fn upstream_url(&self, upstream: &str, source: &str) -> String { if self.development { - format!( - "https://ziglang.org/builds/{}?source={}", - self.filename, source - ) + format!("{}/builds/{}?source={}", upstream, self.filename, source) } else { format!( - "https://ziglang.org/download/{}/{}?source={}", - self.version, self.filename, source, + "{}/download/{}/{}?source={}", + upstream, self.version, self.filename, source, ) } } } - -pub struct ZigController { - config: Arc, - storage: Arc, - upstream: Arc, -} - -impl ZigController { - pub fn new( - config: Arc, - storage: Arc, - upstream: Arc, - ) -> Self { - Self { - config, - storage, - upstream, - } - } - - pub fn router(self: Arc) -> Router { - Router::new() - .route("/zig/{filename}", routing::get(Self::handle)) - .with_state(self) - } - - async fn handle( - extract::State(controller): extract::State>, - extract::Path(filename): extract::Path, - ) -> Result { - let tarball = Tarball::parse(&filename).map_err(|_| http::StatusCode::NOT_FOUND)?; - let url = tarball.upstream_url(controller.config.appname()); - - match controller.storage.get("zig", &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("zig", &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 { - // TODO: add etag - 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() - } -} diff --git a/src/config.rs b/src/config.rs index 1e684bb..0fce8be 100644 --- a/src/config.rs +++ b/src/config.rs @@ -11,6 +11,8 @@ use bytesize::ByteSize; use serde::Deserialize; use thiserror::Error; +use crate::backends::{GoConfig, ZigConfig}; + fn deserialize_duration_secs<'de, D>(deserializer: D) -> Result where D: serde::Deserializer<'de>, @@ -241,6 +243,13 @@ pub struct TelemetryConfig { pub otelcol: Option, } +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(default)] +pub struct BackendsConfig { + pub go: GoConfig, + pub zig: ZigConfig, +} + #[derive(Debug, Deserialize)] #[serde(default)] pub struct ConfigService { @@ -249,6 +258,7 @@ pub struct ConfigService { listen: Vec, server: ServerConfig, telemetry: TelemetryConfig, + backends: BackendsConfig, } impl Default for ConfigService { fn default() -> Self { @@ -258,6 +268,7 @@ impl Default for ConfigService { server: ServerConfig::default(), listen: vec![ListenerConfig::default()], telemetry: TelemetryConfig::default(), + backends: BackendsConfig::default(), } } } @@ -334,6 +345,10 @@ impl ConfigService { pub fn telemetry(&self) -> &TelemetryConfig { &self.telemetry } + + pub fn backends(&self) -> &BackendsConfig { + &self.backends + } } #[cfg(test)] @@ -350,6 +365,7 @@ impl ConfigService { tls_key: None, }], telemetry: TelemetryConfig::default(), + backends: BackendsConfig::default(), } } } diff --git a/src/controller_backend.rs b/src/controller_backend.rs new file mode 100644 index 0000000..ace9cdc --- /dev/null +++ b/src/controller_backend.rs @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::sync::Arc; + +use axum::{Router, body, extract, http, response, routing}; +use tracing::error; + +use crate::backends::Backend; +use crate::proxy; +use crate::storage; + +/// Generic controller for backend HTTP handling. +pub struct BackendController { + backend: B, + storage: Arc, + upstream: Arc, +} + +impl BackendController { + pub fn new( + backend: B, + storage: Arc, + upstream: Arc, + ) -> Self { + Self { + backend, + storage, + upstream, + } + } + + pub fn router(self: Arc) -> Router { + Router::new() + .route("/{filename}", routing::get(Self::handle)) + .with_state(self) + } + + async fn handle( + extract::State(controller): extract::State>, + extract::Path(filename): extract::Path, + ) -> Result { + let url = match controller.backend.upstream_url(&filename) { + Ok(url) => url, + Err(()) => { + error!(backend = B::ID, filename, "invalid filename"); + return Err(http::StatusCode::NOT_FOUND); + } + }; + + match controller.storage.get(B::ID, &filename).await { + Ok(Some(entry)) => { + return Ok(Self::build_response( + http::StatusCode::OK, + entry.file_bytes.0, + )); + } + Ok(None) => {} + Err(err) => { + error!( + backend = B::ID, + filename, "failed to 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(B::ID, &filename, &entry.bytes).await { + Ok(()) => {} + Err(err) => { + error!( + backend = B::ID, + filename, "failed to put file to storage: {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() + } +} diff --git a/src/controller_web.rs b/src/controller_web.rs new file mode 100644 index 0000000..4866c15 --- /dev/null +++ b/src/controller_web.rs @@ -0,0 +1,278 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; + +use axum::body::Body; +use axum::extract; +use axum::http::{HeaderValue, Method, Request, Response, StatusCode, header}; +use axum::response; +use chrono::{DateTime, Utc}; +use sqlx::types::chrono; +use tower::{Layer, Service}; +use tracing::error; + +const CSP: &str = "default-src 'self'; base-uri 'none'; img-src 'self'; font-src 'self'; style-src 'self'; script-src 'self'; object-src 'none'; frame-ancestors 'none'"; + +/// Handles html pages rendering and static files +pub struct WebController { + jinja: minijinja::Environment<'static>, +} + +impl Default for WebController { + fn default() -> Self { + let mut jinja = minijinja::Environment::new(); + jinja.add_template("index", HTML).unwrap(); + + Self { jinja } + } +} + +impl WebController { + pub fn router(self: Arc) -> axum::Router { + axum::Router::new() + .route("/index.css", axum::routing::get(Self::styles)) + .route("/", axum::routing::get(Self::index)) + .layer(LastModifiedLayer {}) + .layer(tower_http::set_header::SetResponseHeaderLayer::overriding( + header::CACHE_CONTROL, + HeaderValue::from_static("no-cache"), + )) + .layer(tower_http::set_header::SetResponseHeaderLayer::overriding( + header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static(CSP), + )) + .with_state(self) + } + + async fn index( + extract::State(controller): extract::State>, + ) -> Result, StatusCode> { + let tmpl = controller.jinja.get_template("index"); + + tmpl.and_then(|template| template.render(minijinja::context! {})) + .map(response::Html) + .map_err(|err| { + error!("html rendering failed: {err}"); + StatusCode::INTERNAL_SERVER_ERROR + }) + } + + async fn styles() -> axum_extra::response::Css<&'static str> { + axum_extra::response::Css(CSS) + } +} + +#[derive(Clone)] +pub struct LastModifiedLayer {} +impl Layer for LastModifiedLayer { + type Service = LastModifiedService; + fn layer(&self, inner: S) -> Self::Service { + LastModifiedService { + inner, + + // Since the static file is packaged in a binary, we cache it across restarts. + // In the future, we can use the build time. + last_moidified: Utc::now(), + } + } +} + +#[derive(Clone)] +pub struct LastModifiedService { + inner: S, + last_moidified: DateTime, +} +impl Service> for LastModifiedService +where + S: Service, Response = Response> + Clone + Send + 'static, + S::Future: Send + 'static, + S::Error: Send + 'static, +{ + type Response = Response; + type Error = S::Error; + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, req: Request) -> Self::Future { + let method = req.method().clone(); + let headers = req.headers().clone(); + let last_modified = self.last_moidified; + + let mut inner = self.inner.clone(); + Box::pin(async move { + let raw = last_modified + .format("%a, %d %b %Y %H:%M:%S GMT") + .to_string(); + let lmh = HeaderValue::from_str(raw.as_str()).unwrap(); + + if (method == Method::GET || method == Method::HEAD) + && headers + .get(header::IF_MODIFIED_SINCE) + .and_then(|v| v.to_str().ok()) + .and_then(parse_http_date) + .is_some_and(|ims| last_modified <= ims) + { + match Response::builder() + .status(StatusCode::NOT_MODIFIED) + .header(header::LAST_MODIFIED, lmh.clone()) + .body(Body::empty()) + { + Ok(resp) => { + return Ok(resp); + } + Err(err) => { + error!("build response: {err}") + } + } + } + + let mut resp = inner.call(req).await?; + resp.headers_mut().insert(header::LAST_MODIFIED, lmh); + Ok(resp) + }) + } +} + +/// Parse HTTP-date (IMF-fixdate per RFC 7231) or RFC 2822 +fn parse_http_date(value: &str) -> Option> { + // IMF-fixdate: "Tue, 14 Jan 2026 12:34:56 GMT" + DateTime::parse_from_rfc2822(value.trim()) + .map(|d| d.with_timezone(&Utc)) + .ok() +} + +const HTML: &str = r##" + + + + + + + + Earth PKG — tiny & opinionated packages mirror + + + + +

Zorian — tiny & opinionated packages mirror.

+ +
+

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.

+ +

Zorian is open source software licensed under AGPL-3.0. + Source code is available on GitHub.

+ +

Usage

+ +

Replace official download URLs with https://pkg.earth/{tool}/{filename}. + Files are cached automatically after the first download.

+ +

Zig

+ +

Read more about community mirrors in the blog post. + Information on how to deploy your own mirror is available + in the documentation.

+ +

For simplicity, you can use tools like prantlf/zigup and + mlugg/setup-zig.

+ +

+ To install manually: +

    +
  1. download zig dist file:
    wget https://pkg.earth/zig/zig-x86_64-linux-0.15.1.tar.xz;
  2. +
  3. download zig minisig file:
    wget https://pkg.earth/zig/zig-x86_64-linux-0.15.1.tar.xz.minisig;
  4. +
  5. check archive integrity:
    minisign -Vm zig-x86_64-linux-0.15.1.tar.xz -P RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U;
  6. +
  7. unpack archive:
    tar -xf "zig-x86_64-linux-0.15.1.tar.xz";
  8. +
  9. check installed zig:
    ./zig-x86_64-linux-0.15.1/zig --version.
  10. +
+ You can take actual minisig public key at ziglang.org/download. +

+ +

Go

+ +

+ To install manually: +

    +
  1. download go dist file:
    wget https://pkg.earth/go/go1.23.0.linux-amd64.tar.gz;
  2. +
  3. download go sha256 file:
    wget https://pkg.earth/go/go1.23.0.linux-amd64.tar.gz.sha256;
  4. +
  5. check archive integrity:
    sha256sum -c go1.23.0.linux-amd64.tar.gz.sha256;
  6. +
  7. unpack archive:
    tar -xzf go1.23.0.linux-amd64.tar.gz;
  8. +
  9. check installed go:
    ./go/bin/go version.
  10. +
+ You can find available versions at go.dev/dl. +

+ +

Privacy policy

+ +

This mirror is a non-profit project available on a voluntary basis. The author has no plans to fund it.

+ +

Since the mirror is hosted on hardware, we collect access logs to combat bots and brute-force attacks. + The logs are used for security purposes and load planning, are not shared with third parties, + and are deleted after 30 days.

+ +

Third-party analytics systems are not used, same as client-side trackers.

+
+ + +"##; + +const CSS: &str = r##" +:root { + font-size: 1.125rem; + line-height: 1.4; + font-family: + 'Alegreya Sans', -apple-system, BlinkMacSystemFont, + 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, + 'Open Sans', 'Helvetica Neue', sans-serif; +} + +html { + margin: 0; + padding: 0; +} + +body { + margin: 0 auto; + padding: 1.5em 2em; + max-width: 680px; +} + +code { + font-size: .75rem; +} + +h1, h2, h3, h4, h5, h6 { + font-weight: 700; + line-height: 1.2; + margin: 0; +} + +h1 { + font-size: 2.75rem; +} + +h1:first-child { + margin-top: 0; +} + +th { + text-align: start; +} + +h3 a { + color: inherit; + text-decoration: none; +} + +h3 a:hover { + text-decoration: underline; +} +"##; diff --git a/src/main.rs b/src/main.rs index 54b7246..955c4f4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,7 +6,9 @@ mod config; mod proxy; mod storage; mod telemetry; -mod web; + +mod controller_backend; +mod controller_web; use std::future::Future; use std::net::{SocketAddr, TcpListener}; @@ -28,9 +30,9 @@ 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; +use crate::backends::{GoBackend, ZigBackend}; +use crate::controller_backend::BackendController; +use crate::controller_web::WebController; const VERSION: &str = env!("CARGO_PKG_VERSION"); const HELP: &str = "\ @@ -120,16 +122,8 @@ async fn main() { let upstream = Arc::new(proxy::ProxyService::new()); let web_controller = Arc::new(WebController::default()); - let zig_controller = Arc::new(ZigController::new( - config.clone(), - storage.clone(), - upstream.clone(), - )); - let go_controller = Arc::new(GoController::new( - config.clone(), - storage.clone(), - upstream.clone(), - )); + let source = format!("zorian:{}", config.appname()); + let backends = config.backends(); const REQUEST_ID_HEADER: http::HeaderName = http::HeaderName::from_static("x-request-id"); @@ -225,10 +219,29 @@ async fn main() { .finish() .unwrap(); - let app = axum::Router::new() - .merge(web_controller.router()) - .merge(zig_controller.router()) - .merge(go_controller.router()) + let mut app = axum::Router::new().merge(web_controller.router()); + + if backends.zig.enabled { + let backend = ZigBackend::new(backends.zig.clone(), source.clone()); + let ctrl = Arc::new(BackendController::new( + backend, + storage.clone(), + upstream.clone(), + )); + app = app.nest("/zig", ctrl.router()); + } + + if backends.go.enabled { + let backend = GoBackend::new(backends.go.clone(), source.clone()); + let ctrl = Arc::new(BackendController::new( + backend, + storage.clone(), + upstream.clone(), + )); + app = app.nest("/go", ctrl.router()); + } + + let app = app // Opt-in layers .layer(tower_http::compression::CompressionLayer::new()) // request limits diff --git a/src/web.rs b/src/web.rs deleted file mode 100644 index 4866c15..0000000 --- a/src/web.rs +++ /dev/null @@ -1,278 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::future::Future; -use std::pin::Pin; -use std::sync::Arc; -use std::task::{Context, Poll}; - -use axum::body::Body; -use axum::extract; -use axum::http::{HeaderValue, Method, Request, Response, StatusCode, header}; -use axum::response; -use chrono::{DateTime, Utc}; -use sqlx::types::chrono; -use tower::{Layer, Service}; -use tracing::error; - -const CSP: &str = "default-src 'self'; base-uri 'none'; img-src 'self'; font-src 'self'; style-src 'self'; script-src 'self'; object-src 'none'; frame-ancestors 'none'"; - -/// Handles html pages rendering and static files -pub struct WebController { - jinja: minijinja::Environment<'static>, -} - -impl Default for WebController { - fn default() -> Self { - let mut jinja = minijinja::Environment::new(); - jinja.add_template("index", HTML).unwrap(); - - Self { jinja } - } -} - -impl WebController { - pub fn router(self: Arc) -> axum::Router { - axum::Router::new() - .route("/index.css", axum::routing::get(Self::styles)) - .route("/", axum::routing::get(Self::index)) - .layer(LastModifiedLayer {}) - .layer(tower_http::set_header::SetResponseHeaderLayer::overriding( - header::CACHE_CONTROL, - HeaderValue::from_static("no-cache"), - )) - .layer(tower_http::set_header::SetResponseHeaderLayer::overriding( - header::CONTENT_SECURITY_POLICY, - HeaderValue::from_static(CSP), - )) - .with_state(self) - } - - async fn index( - extract::State(controller): extract::State>, - ) -> Result, StatusCode> { - let tmpl = controller.jinja.get_template("index"); - - tmpl.and_then(|template| template.render(minijinja::context! {})) - .map(response::Html) - .map_err(|err| { - error!("html rendering failed: {err}"); - StatusCode::INTERNAL_SERVER_ERROR - }) - } - - async fn styles() -> axum_extra::response::Css<&'static str> { - axum_extra::response::Css(CSS) - } -} - -#[derive(Clone)] -pub struct LastModifiedLayer {} -impl Layer for LastModifiedLayer { - type Service = LastModifiedService; - fn layer(&self, inner: S) -> Self::Service { - LastModifiedService { - inner, - - // Since the static file is packaged in a binary, we cache it across restarts. - // In the future, we can use the build time. - last_moidified: Utc::now(), - } - } -} - -#[derive(Clone)] -pub struct LastModifiedService { - inner: S, - last_moidified: DateTime, -} -impl Service> for LastModifiedService -where - S: Service, Response = Response> + Clone + Send + 'static, - S::Future: Send + 'static, - S::Error: Send + 'static, -{ - type Response = Response; - type Error = S::Error; - type Future = Pin> + Send>>; - - fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { - self.inner.poll_ready(cx) - } - - fn call(&mut self, req: Request) -> Self::Future { - let method = req.method().clone(); - let headers = req.headers().clone(); - let last_modified = self.last_moidified; - - let mut inner = self.inner.clone(); - Box::pin(async move { - let raw = last_modified - .format("%a, %d %b %Y %H:%M:%S GMT") - .to_string(); - let lmh = HeaderValue::from_str(raw.as_str()).unwrap(); - - if (method == Method::GET || method == Method::HEAD) - && headers - .get(header::IF_MODIFIED_SINCE) - .and_then(|v| v.to_str().ok()) - .and_then(parse_http_date) - .is_some_and(|ims| last_modified <= ims) - { - match Response::builder() - .status(StatusCode::NOT_MODIFIED) - .header(header::LAST_MODIFIED, lmh.clone()) - .body(Body::empty()) - { - Ok(resp) => { - return Ok(resp); - } - Err(err) => { - error!("build response: {err}") - } - } - } - - let mut resp = inner.call(req).await?; - resp.headers_mut().insert(header::LAST_MODIFIED, lmh); - Ok(resp) - }) - } -} - -/// Parse HTTP-date (IMF-fixdate per RFC 7231) or RFC 2822 -fn parse_http_date(value: &str) -> Option> { - // IMF-fixdate: "Tue, 14 Jan 2026 12:34:56 GMT" - DateTime::parse_from_rfc2822(value.trim()) - .map(|d| d.with_timezone(&Utc)) - .ok() -} - -const HTML: &str = r##" - - - - - - - - Earth PKG — tiny & opinionated packages mirror - - - - -

Zorian — tiny & opinionated packages mirror.

- -
-

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.

- -

Zorian is open source software licensed under AGPL-3.0. - Source code is available on GitHub.

- -

Usage

- -

Replace official download URLs with https://pkg.earth/{tool}/{filename}. - Files are cached automatically after the first download.

- -

Zig

- -

Read more about community mirrors in the blog post. - Information on how to deploy your own mirror is available - in the documentation.

- -

For simplicity, you can use tools like prantlf/zigup and - mlugg/setup-zig.

- -

- To install manually: -

    -
  1. download zig dist file:
    wget https://pkg.earth/zig/zig-x86_64-linux-0.15.1.tar.xz;
  2. -
  3. download zig minisig file:
    wget https://pkg.earth/zig/zig-x86_64-linux-0.15.1.tar.xz.minisig;
  4. -
  5. check archive integrity:
    minisign -Vm zig-x86_64-linux-0.15.1.tar.xz -P RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U;
  6. -
  7. unpack archive:
    tar -xf "zig-x86_64-linux-0.15.1.tar.xz";
  8. -
  9. check installed zig:
    ./zig-x86_64-linux-0.15.1/zig --version.
  10. -
- You can take actual minisig public key at ziglang.org/download. -

- -

Go

- -

- To install manually: -

    -
  1. download go dist file:
    wget https://pkg.earth/go/go1.23.0.linux-amd64.tar.gz;
  2. -
  3. download go sha256 file:
    wget https://pkg.earth/go/go1.23.0.linux-amd64.tar.gz.sha256;
  4. -
  5. check archive integrity:
    sha256sum -c go1.23.0.linux-amd64.tar.gz.sha256;
  6. -
  7. unpack archive:
    tar -xzf go1.23.0.linux-amd64.tar.gz;
  8. -
  9. check installed go:
    ./go/bin/go version.
  10. -
- You can find available versions at go.dev/dl. -

- -

Privacy policy

- -

This mirror is a non-profit project available on a voluntary basis. The author has no plans to fund it.

- -

Since the mirror is hosted on hardware, we collect access logs to combat bots and brute-force attacks. - The logs are used for security purposes and load planning, are not shared with third parties, - and are deleted after 30 days.

- -

Third-party analytics systems are not used, same as client-side trackers.

-
- - -"##; - -const CSS: &str = r##" -:root { - font-size: 1.125rem; - line-height: 1.4; - font-family: - 'Alegreya Sans', -apple-system, BlinkMacSystemFont, - 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, - 'Open Sans', 'Helvetica Neue', sans-serif; -} - -html { - margin: 0; - padding: 0; -} - -body { - margin: 0 auto; - padding: 1.5em 2em; - max-width: 680px; -} - -code { - font-size: .75rem; -} - -h1, h2, h3, h4, h5, h6 { - font-weight: 700; - line-height: 1.2; - margin: 0; -} - -h1 { - font-size: 2.75rem; -} - -h1:first-child { - margin-top: 0; -} - -th { - text-align: start; -} - -h3 a { - color: inherit; - text-decoration: none; -} - -h3 a:hover { - text-decoration: underline; -} -"##; -- Gilti