From 6a9491a38e9895a0be5e136ca8d1f9481f0d95c3 Mon Sep 17 00:00:00 2001 From: Nikolay Govorov Date: Sat, 17 Jan 2026 00:25:07 +0000 Subject: Allows to listen to multiple ports, adds https support --- Cargo.lock | 1 + Cargo.toml | 1 + deny.toml | 1 + pkg/zorian.service | 1 + pkg/zorian.toml | 12 +- src/config.rs | 78 ++++++- src/main.rs | 495 +++++++++++++++++++++++---------------------- src/web.rs | 154 ++++++++++++++ 8 files changed, 499 insertions(+), 244 deletions(-) create mode 100644 src/web.rs diff --git a/Cargo.lock b/Cargo.lock index 466689b..a0b66b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5298,6 +5298,7 @@ dependencies = [ "tempfile", "thiserror 2.0.17", "tokio", + "tokio-rustls", "toml", "tower", "tower-http", diff --git a/Cargo.toml b/Cargo.toml index f157487..b082481 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,7 @@ sqlx = { version = "0.8", features = [ thiserror = "2.0" tokio = { version = "1.49", features = ["full"] } toml = "0.9" +tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] } tower = { version = "0.5", features = ["full"] } tower-http = { version = "0.6", features = ["full"] } tracing = "0.1" diff --git a/deny.toml b/deny.toml index 28938d0..03a665b 100644 --- a/deny.toml +++ b/deny.toml @@ -5,6 +5,7 @@ allow = [ # Dilution licenses available without restrictions "MIT", + "ISC", "Apache-2.0", "BSD-3-Clause", diff --git a/pkg/zorian.service b/pkg/zorian.service index 2f229ce..ac99e25 100644 --- a/pkg/zorian.service +++ b/pkg/zorian.service @@ -17,6 +17,7 @@ ExecPaths=/usr/local/bin/zorian /usr/lib ExecStart=/usr/local/bin/zorian --config=/etc/zorian.toml LimitCORE=infinity LimitNOFILE=500000 +AmbientCapabilities=CAP_NET_BIND_SERVICE # %p is resolved to the systemd unit name LogsDirectory=%p diff --git a/pkg/zorian.toml b/pkg/zorian.toml index ceb94a6..e290f13 100644 --- a/pkg/zorian.toml +++ b/pkg/zorian.toml @@ -5,9 +5,15 @@ # Specify your name if you want your instance to be identified. appname="zorian" -# The address to which the server will respond via HTTP. -# Use a reverse proxy if you want to add TLS. -listen="0.0.0.0:3000" +# Listeners configuration. Each listener has an address and optional hostnames. +# Empty hostnames means accept all requests (catch-all). +[[listen]] +addr = "0.0.0.0:3000" +hostnames = ["localhost", "127.0.0.1"] + +# For HTTPS, set tls_cert and tls_key to PEM file paths. +# tls_crt = "/etc/zorian/cert.pem" +# tls_key = "/etc/zorian/key.pem" # Path to the directory for storing state (indexes, caches, statistics). # Must be a writable directory. diff --git a/src/config.rs b/src/config.rs index 4be4026..e8c6fcb 100644 --- a/src/config.rs +++ b/src/config.rs @@ -26,12 +26,50 @@ pub enum ConfigError { #[error("dirname '{0}' is not writable: {1}")] NotWritable(PathBuf, std::io::Error), + + #[error("listener '{0}': tls_crt is set but tls_key is missing")] + TlsKeyMissing(String), + + #[error("listener '{0}': tls_key is set but tls_crt is missing")] + TlsCrtMissing(String), + + #[error("listener '{0}': TLS crtificate file not found: {1}")] + TlsCrtNotFound(String, PathBuf), + + #[error("listener '{0}': TLS key file not found: {1}")] + TlsKeyNotFound(String, PathBuf), +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ListenerConfig { + pub addr: String, + + /// Hostnames to accept for this listener. Empty means accept all. + #[serde(default)] + pub hostnames: Vec, + + /// Path to TLS certificate file (PEM format). If set, tls_key must also be set. + pub tls_crt: Option, + + /// Path to TLS private key file (PEM format). If set, tls_crt must also be set. + pub tls_key: Option, +} + +impl Default for ListenerConfig { + fn default() -> Self { + Self { + addr: "0.0.0.0:3000".to_string(), + hostnames: Vec::new(), + tls_crt: None, + tls_key: None, + } + } } #[derive(Debug, Deserialize)] #[serde(default)] pub struct ConfigService { - listen: String, + listen: Vec, appname: String, dirname: PathBuf, } @@ -39,7 +77,7 @@ pub struct ConfigService { impl Default for ConfigService { fn default() -> Self { Self { - listen: "0.0.0.0:3000".to_string(), + listen: vec![ListenerConfig::default()], appname: "zorian".to_string(), dirname: PathBuf::from("./.zorian-state"), } @@ -76,6 +114,33 @@ impl ConfigService { .map_err(|e| ConfigError::NotWritable(self.dirname.clone(), e))?; fs::remove_file(&testfile)?; + // Validate TLS configuration for each listener + for listener in &self.listen { + match (&listener.tls_crt, &listener.tls_key) { + (Some(_crt), None) => { + return Err(ConfigError::TlsKeyMissing(listener.addr.clone())); + } + (None, Some(_)) => { + return Err(ConfigError::TlsCrtMissing(listener.addr.clone())); + } + (Some(crt), Some(key)) => { + if !crt.exists() { + return Err(ConfigError::TlsCrtNotFound( + listener.addr.clone(), + crt.clone(), + )); + } + if !key.exists() { + return Err(ConfigError::TlsKeyNotFound( + listener.addr.clone(), + key.clone(), + )); + } + } + (None, None) => {} + } + } + Ok(()) } @@ -83,7 +148,7 @@ impl ConfigService { &self.appname } - pub fn listen(&self) -> &str { + pub fn listeners(&self) -> &[ListenerConfig] { &self.listen } @@ -96,7 +161,12 @@ impl ConfigService { impl ConfigService { pub fn for_test(dirname: PathBuf) -> Self { Self { - listen: "127.0.0.1:0".to_string(), + listen: vec![ListenerConfig { + addr: "127.0.0.1:0".to_string(), + hostnames: Vec::new(), + tls_crt: None, + tls_key: None, + }], appname: "test".to_string(), dirname, } diff --git a/src/main.rs b/src/main.rs index 1aa8951..34d89a9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,31 +5,26 @@ mod backends; mod config; mod proxy; mod storage; +mod web; use std::future::Future; -use std::path::PathBuf; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; use std::pin::Pin; use std::sync::Arc; use std::task::{Context, Poll}; use axum::{ - Router, body::Body, - extract, + extract::{self, connect_info::Connected}, http::{self, Request, Response}, - response::{self, IntoResponse}, - routing, -}; -use tower_http::{ - request_id, - trace::{DefaultOnFailure, TraceLayer}, }; +use tokio_rustls::TlsAcceptor; use tracing::{error, info}; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +use tracing_subscriber::{layer::SubscriberExt, registry::LookupSpan, util::SubscriberInitExt}; use crate::backends::zig::ZigController; - -static REQUEST_ID_HEADER: http::HeaderName = http::HeaderName::from_static("x-request-id"); +use crate::web::WebController; const VERSION: &str = env!("CARGO_PKG_VERSION"); const HELP: &str = "\ @@ -41,6 +36,37 @@ Options: --version Show version "; +/// Contains metainfo about one server interface +#[derive(Clone)] +struct ListenerInfo { + addr: SocketAddr, + hosts: Vec, +} + +/// Contains metainfo about one client connection +#[derive(Clone, Copy, Debug)] +struct ClientInfo(SocketAddr); +impl Connected> for ClientInfo { + fn connect_info(target: axum::serve::IncomingStream<'_, TlsListener>) -> Self { + ClientInfo(*target.remote_addr()) + } +} +impl Connected> for ClientInfo { + fn connect_info(target: axum::serve::IncomingStream<'_, tokio::net::TcpListener>) -> Self { + ClientInfo(*target.remote_addr()) + } +} + +/// Request info stored in span extensions for logging +#[derive(Clone)] +struct RequestInfo { + method: http::Method, + version: http::Version, + path: http::Uri, + host: Option, + user_agent: Option, +} + #[tokio::main] async fn main() { let mut config_path = None; @@ -102,58 +128,225 @@ async fn main() { upstream.clone(), )); - let trace_layer = TraceLayer::new_for_http() - .make_span_with(|req: &http::Request<_>| { + const REQUEST_ID_HEADER: http::HeaderName = http::HeaderName::from_static("x-request-id"); + let trace_layer = tower_http::trace::TraceLayer::new_for_http() + .make_span_with(|req: &http::Request| { let request_id = req .headers() .get(&REQUEST_ID_HEADER) .and_then(|v| v.to_str().ok()) .unwrap_or(""); - - tracing::info_span!("http_request", request_id = %request_id) + let local_addr = req.extensions().get::().map(|a| a.addr); + let remote_addr = req + .extensions() + .get::>() + .map(|ci| ci.0.0.ip()); + + tracing::info_span!( + "http_request", + request_id = %request_id, + local_addr = ?local_addr, + remote_addr = ?remote_addr, + ) }) - .on_request(()) - .on_response(()) - .on_failure(DefaultOnFailure::new().level(tracing::Level::ERROR)); - - let app = Router::new() + .on_request(|req: &Request, span: &tracing::Span| { + let info = RequestInfo { + method: req.method().clone(), + path: req.uri().clone(), + version: req.version(), + host: req + .headers() + .get(http::header::HOST) + .and_then(|v| v.to_str().ok()) + .map(String::from), + user_agent: req + .headers() + .get(http::header::USER_AGENT) + .and_then(|v| v.to_str().ok()) + .map(String::from), + }; + + span.with_subscriber(|(id, dispatch)| { + if let Some(reg) = dispatch.downcast_ref::() + && let Some(span_ref) = reg.span(id) + { + span_ref.extensions_mut().insert(info); + } + }); + }) + .on_response( + |res: &Response, latency: std::time::Duration, span: &tracing::Span| { + use axum::body::HttpBody as _; + + let status = res.status().as_u16(); + let content_length = res.body().size_hint().exact(); + let content_type = res + .headers() + .get(http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()); + + let req_info = span.with_subscriber(|(id, dispatch)| { + dispatch + .downcast_ref::() + .and_then(|reg| reg.span(id)) + .and_then(|span_ref| span_ref.extensions().get::().cloned()) + }); + + if let Some(Some(req_info)) = req_info { + info!( + method = %req_info.method, + version = ?req_info.version, + path = %req_info.path, + host = req_info.host, + user_agent = req_info.user_agent, + status, + latency = latency.as_nanos() as u64, + content_type, + content_length, + "on_response", + ); + } else { + info!( + status, + latency = latency.as_nanos() as u64, + content_type, + content_length, + "on_response", + ); + } + }, + ) + .on_failure(tower_http::trace::DefaultOnFailure::new().level(tracing::Level::ERROR)); + + let app = axum::Router::new() .merge(web_controller.router()) .merge(zig_controller.router()) - .layer(LoggingLayer) + .layer(HostValidationLayer) .layer(trace_layer) - .layer(request_id::PropagateRequestIdLayer::new( + .layer(tower_http::request_id::PropagateRequestIdLayer::new( REQUEST_ID_HEADER.clone(), )) - .layer(request_id::SetRequestIdLayer::new( + .layer(tower_http::request_id::SetRequestIdLayer::new( REQUEST_ID_HEADER.clone(), - request_id::MakeRequestUuid, + tower_http::request_id::MakeRequestUuid, )); - let listener = tokio::net::TcpListener::bind(config.listen()) - .await - .unwrap(); + let mut tasks = tokio::task::JoinSet::new(); + + for listener_config in config.listeners() { + let tcp_listener = tokio::net::TcpListener::bind(&listener_config.addr) + .await + .unwrap(); + + let tls_enabled = listener_config.tls_crt.is_some(); + info!( + "listening {} on {} (hostnames: {})", + if tls_enabled { "HTTPS" } else { "HTTP" }, + tcp_listener.local_addr().unwrap(), + if listener_config.hostnames.is_empty() { + "*".to_string() + } else { + listener_config.hostnames.join(", ") + }, + ); + + let local_addr = tcp_listener.local_addr().unwrap(); + let app = app + .clone() + .layer(axum::Extension(ListenerInfo { + addr: local_addr, + hosts: listener_config.hostnames.clone(), + })) + .into_make_service_with_connect_info::(); + + if let (Some(crt_path), Some(key_path)) = + (&listener_config.tls_crt, &listener_config.tls_key) + { + let tls_listener = TlsListener::new(tcp_listener, crt_path, key_path); + tasks.spawn(async move { + axum::serve(tls_listener, app).await.unwrap(); + }); + } else { + tasks.spawn(async move { + axum::serve(tcp_listener, app).await.unwrap(); + }); + } + } - info!("listening on {}", listener.local_addr().unwrap()); - axum::serve(listener, app).await.unwrap(); + if let Some(result) = tasks.join_next().await { + result.unwrap(); + } } -#[derive(Clone)] -pub struct LoggingLayer; +/// A TLS listener that wraps a TCP listener and performs TLS handshakes. +struct TlsListener { + inner: tokio::net::TcpListener, + acceptor: TlsAcceptor, +} +impl TlsListener { + fn new(inner: tokio::net::TcpListener, crt_path: &Path, key_path: &Path) -> Self { + use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject}; + + let certs: Vec> = CertificateDer::pem_file_iter(crt_path) + .expect("failed to open certificate file") + .collect::>() + .expect("failed to parse certificates"); + + let key = PrivateKeyDer::from_pem_file(key_path).expect("failed to read private key"); -impl tower::Layer for LoggingLayer { - type Service = LoggingService; + let config = tokio_rustls::rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certs, key) + .expect("failed to build TLS config"); + + let acceptor = TlsAcceptor::from(Arc::new(config)); + Self { inner, acceptor } + } +} +impl axum::serve::Listener for TlsListener { + type Io = tokio_rustls::server::TlsStream; + type Addr = std::net::SocketAddr; + + fn local_addr(&self) -> std::io::Result { + self.inner.local_addr() + } + + async fn accept(&mut self) -> (Self::Io, Self::Addr) { + loop { + let (stream, addr) = match self.inner.accept().await { + Ok(conn) => conn, + Err(e) => { + error!("failed to accept TCP connection: {}", e); + continue; + } + }; + match self.acceptor.accept(stream).await { + Ok(tls_stream) => return (tls_stream, addr), + Err(e) => { + error!("TLS handshake failed from {}: {}", addr, e); + continue; + } + } + } + } +} + +/// Layer that validates the Host header against configured hostnames. +#[derive(Clone)] +struct HostValidationLayer; +impl tower::Layer for HostValidationLayer { + type Service = HostValidationService; fn layer(&self, inner: S) -> Self::Service { - LoggingService { inner } + HostValidationService { inner } } } #[derive(Clone)] -pub struct LoggingService { +struct HostValidationService { inner: S, } - -impl tower::Service> for LoggingService +impl tower::Service> for HostValidationService where S: tower::Service, Response = Response> + Clone + Send + 'static, S::Future: Send, @@ -167,206 +360,34 @@ where } fn call(&mut self, req: Request) -> Self::Future { - let clone = self.inner.clone(); + let interface = req.extensions().get::().cloned(); - // take the service that was ready - let inner = std::mem::replace(&mut self.inner, clone); - Box::pin(async move { log_request(inner, req).await }) - } -} - -async fn log_request(mut inner: S, req: Request) -> Result, S::Error> -where - S: tower::Service, Response = Response>, -{ - let start = std::time::Instant::now(); - - let method = req.method().clone(); - let uri = req.uri().clone(); - - let headers = req.headers(); - let client_ip = headers - .get("x-forwarded-for") - .or_else(|| headers.get("x-real-ip")) - .and_then(|v| v.to_str().ok()) - .map(String::from); - let user_agent = headers - .get(http::header::USER_AGENT) - .and_then(|v| v.to_str().ok()) - .map(String::from); - let referer = headers - .get(http::header::REFERER) - .and_then(|v| v.to_str().ok()) - .map(String::from); - - let res = inner.call(req).await?; - - let latency = start.elapsed(); - let status = res.status(); - let headers = res.headers(); - let content_length = headers - .get(http::header::CONTENT_LENGTH) - .and_then(|v| v.to_str().ok()); - let content_type = headers - .get(http::header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()); - - info!( - method = %method, - uri = %uri, - status = %status, - latency = ?latency, - client_ip = client_ip.as_deref(), - user_agent = user_agent.as_deref(), - referer = referer.as_deref(), - content_length, - content_type, - "request" - ); - - Ok(res) -} - -/// 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) -> Router { - Router::new() - .route("/index.css", routing::get(Self::styles)) - .route("/", routing::get(Self::index)) - .with_state(self) - } - - async fn index( - extract::State(controller): extract::State>, - ) -> Result { - let template = controller.jinja.get_template("index").unwrap(); - let rendered = template.render(minijinja::context! {}).unwrap(); - - let mut response = response::Html(rendered).into_response(); - response.headers_mut().insert( - http::header::CONTENT_SECURITY_POLICY, - http::HeaderValue::from_static( - "default-src 'self'; base-uri 'none'; img-src 'self'; font-src 'self'; style-src 'self'; script-src 'self'; object-src 'none'; frame-ancestors 'none'", - ), - ); - - Ok(response) - } + // Check if hostname validation is needed + if let Some(iface) = interface + && !iface.hosts.is_empty() + { + let host = req + .headers() + .get(http::header::HOST) + .and_then(|v| v.to_str().ok()) + .map(|h| h.split(':').next().unwrap_or(h)); // Strip port if present + + let is_valid = host + .map(|h| iface.hosts.iter().any(|allowed| allowed == h)) + .unwrap_or(false); + + if !is_valid { + return Box::pin(async move { + Ok(Response::builder() + .status(http::StatusCode::MISDIRECTED_REQUEST) + .body(Body::empty()) + .unwrap()) + }); + } + } - async fn styles() -> Result, http::StatusCode> { - Ok(axum_extra::response::Css(CSS)) + let clone = self.inner.clone(); + let mut inner = std::mem::replace(&mut self.inner, clone); + Box::pin(async move { inner.call(req).await }) } } - -const HTML: &str = r#" - - - - - - - - Earth PKG — tiny & opinionated packages mirror - - - - -

Earth PKG — tiny & opinionated packages mirror.

- -
-

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.

- -

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

- -

Direct usage:

- -

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 minisign 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 minisign public key in download page. -

- -

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 = " -: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; -} -"; diff --git a/src/web.rs b/src/web.rs new file mode 100644 index 0000000..d286d35 --- /dev/null +++ b/src/web.rs @@ -0,0 +1,154 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::sync::Arc; + +use axum::{ + extract::{self}, + http::{self}, + response::{self, IntoResponse}, +}; + +/// 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)) + .with_state(self) + } + + async fn index( + extract::State(controller): extract::State>, + ) -> Result { + let template = controller.jinja.get_template("index").unwrap(); + let rendered = template.render(minijinja::context! {}).unwrap(); + + let mut response = response::Html(rendered).into_response(); + response.headers_mut().insert( + http::header::CONTENT_SECURITY_POLICY, + http::HeaderValue::from_static( + "default-src 'self'; base-uri 'none'; img-src 'self'; font-src 'self'; style-src 'self'; script-src 'self'; object-src 'none'; frame-ancestors 'none'", + ), + ); + + Ok(response) + } + + async fn styles() -> Result, http::StatusCode> { + Ok(axum_extra::response::Css(CSS)) + } +} + +const HTML: &str = r#" + + + + + + + + Earth PKG — tiny & opinionated packages mirror + + + + +

Earth PKG — tiny & opinionated packages mirror.

+ +
+

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.

+ +

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

+ +

Direct usage:

+ +

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 minisign 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 minisign public key in download page. +

+ +

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 = " +: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; +} +"; -- Gilti