mirror of
https://github.com/getnora-io/nora.git
synced 2026-04-12 13:50:31 +00:00
feat: add Go module proxy (GOPROXY protocol) (#59)
* feat: add Go module proxy (GOPROXY protocol) (#47) Implements caching proxy for Go modules with 5 standard endpoints: - GET /go/{module}/@v/list — list versions - GET /go/{module}/@v/{version}.info — version metadata - GET /go/{module}/@v/{version}.mod — go.mod file - GET /go/{module}/@v/{version}.zip — module zip - GET /go/{module}/@latest — latest version info Features: - Module path encoding/decoding per Go spec (!x → X) - Immutable caching (.info/.mod/.zip never overwritten) - Mutable endpoints (@v/list, @latest) refreshed from upstream - Configurable upstream (default: proxy.golang.org) - Separate timeout for .zip downloads (default: 120s) - Size limit for zips (default: 100MB) - Path traversal protection - Dashboard integration (stats, mount points, index) - 25 unit tests (encoding, path splitting, safety, content-type) Closes #47 * style: cargo fmt * feat(ui): add Go pages, compact cards, fix icons - Go in sidebar + list/detail pages with go get command - Dashboard: fix fallback icon (was Docker whale for Go) - Compact registry cards: lg:grid-cols-6, all 6 in one row - Cargo icon: crate boxes instead of truck - Go icon: stylized Go text (sidebar + dashboard) * fix(go): URL-decode path + send encoded paths to upstream Go client sends %21 for ! in module paths. Axum wildcard does not auto-decode, so we percent-decode manually. Upstream proxy.golang.org expects encoded paths (with !), not decoded uppercase. Tested: full Pusk build (22 modules, 135MB cached) including SherClockHolmes/webpush-go with triple uppercase encoding. * style: cargo fmt
This commit is contained in:
522
nora-registry/src/registry/go.rs
Normal file
522
nora-registry/src/registry/go.rs
Normal file
@@ -0,0 +1,522 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//! Go module proxy (GOPROXY protocol).
|
||||
//!
|
||||
//! Implements the 5 required endpoints:
|
||||
//! GET /go/{module}/@v/list — list known versions
|
||||
//! GET /go/{module}/@v/{ver}.info — version metadata (JSON)
|
||||
//! GET /go/{module}/@v/{ver}.mod — go.mod file
|
||||
//! GET /go/{module}/@v/{ver}.zip — module zip archive
|
||||
//! GET /go/{module}/@latest — latest version info
|
||||
|
||||
use crate::activity_log::{ActionType, ActivityEntry};
|
||||
use crate::audit::AuditEntry;
|
||||
use crate::registry::{proxy_fetch, proxy_fetch_text, ProxyError};
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{header, HeaderValue, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use percent_encoding::percent_decode;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new().route("/go/{*path}", get(handle))
|
||||
}
|
||||
|
||||
/// Main handler — parses the wildcard path and dispatches to the right logic.
|
||||
async fn handle(State(state): State<Arc<AppState>>, Path(path): Path<String>) -> Response {
|
||||
// URL-decode the path: Go client sends %21 for !, Axum wildcard may not decode it
|
||||
let path = percent_decode(path.as_bytes())
|
||||
.decode_utf8()
|
||||
.map(|s| s.into_owned())
|
||||
.unwrap_or(path);
|
||||
|
||||
tracing::debug!(path = %path, "Go proxy request");
|
||||
|
||||
// Validate path: no traversal, no null bytes
|
||||
if !is_safe_path(&path) {
|
||||
tracing::debug!(path = %path, "Go proxy: unsafe path");
|
||||
return StatusCode::BAD_REQUEST.into_response();
|
||||
}
|
||||
|
||||
// Split: "github.com/!azure/sdk/@v/v1.0.0.info" → module + file
|
||||
let (module_encoded, file) = match split_go_path(&path) {
|
||||
Some(parts) => parts,
|
||||
None => {
|
||||
tracing::debug!(path = %path, "Go proxy: cannot split path");
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let storage_key = format!("go/{}", path);
|
||||
let content_type = content_type_for(&file);
|
||||
|
||||
// Mutable endpoints: @v/list and @latest can be refreshed from upstream
|
||||
let is_mutable = file == "@v/list" || file == "@latest";
|
||||
// Immutable: .info, .mod, .zip — once cached, never overwrite
|
||||
let is_immutable = !is_mutable;
|
||||
|
||||
// 1. Try local cache (for immutable files, this is authoritative)
|
||||
if let Ok(data) = state.storage.get(&storage_key).await {
|
||||
state.metrics.record_download("go");
|
||||
state.metrics.record_cache_hit();
|
||||
state.activity.push(ActivityEntry::new(
|
||||
ActionType::CacheHit,
|
||||
format_artifact(&module_encoded, &file),
|
||||
"go",
|
||||
"CACHE",
|
||||
));
|
||||
return with_content_type(data.to_vec(), content_type);
|
||||
}
|
||||
|
||||
// 2. Try upstream proxy
|
||||
let proxy_url = match &state.config.go.proxy {
|
||||
Some(url) => url.clone(),
|
||||
None => return StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
|
||||
// Validate module path encoding (but keep encoded for upstream — proxy.golang.org expects ! encoding)
|
||||
if decode_module_path(&module_encoded).is_err() {
|
||||
return StatusCode::BAD_REQUEST.into_response();
|
||||
}
|
||||
|
||||
let upstream_url = format!(
|
||||
"{}/{}",
|
||||
proxy_url.trim_end_matches('/'),
|
||||
format_upstream_path(&module_encoded, &file)
|
||||
);
|
||||
|
||||
// Use longer timeout for .zip files
|
||||
let timeout = if file.ends_with(".zip") {
|
||||
state.config.go.proxy_timeout_zip
|
||||
} else {
|
||||
state.config.go.proxy_timeout
|
||||
};
|
||||
|
||||
// Fetch: binary for .zip, text for everything else
|
||||
let data = if file.ends_with(".zip") {
|
||||
proxy_fetch(
|
||||
&state.http_client,
|
||||
&upstream_url,
|
||||
timeout,
|
||||
state.config.go.proxy_auth.as_deref(),
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
proxy_fetch_text(
|
||||
&state.http_client,
|
||||
&upstream_url,
|
||||
timeout,
|
||||
state.config.go.proxy_auth.as_deref(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map(|s| s.into_bytes())
|
||||
};
|
||||
|
||||
match data {
|
||||
Ok(bytes) => {
|
||||
// Enforce size limit for .zip
|
||||
if file.ends_with(".zip") && bytes.len() as u64 > state.config.go.max_zip_size {
|
||||
tracing::warn!(
|
||||
module = module_encoded,
|
||||
size = bytes.len(),
|
||||
limit = state.config.go.max_zip_size,
|
||||
"Go module zip exceeds size limit"
|
||||
);
|
||||
return StatusCode::PAYLOAD_TOO_LARGE.into_response();
|
||||
}
|
||||
|
||||
state.metrics.record_download("go");
|
||||
state.metrics.record_cache_miss();
|
||||
state.activity.push(ActivityEntry::new(
|
||||
ActionType::ProxyFetch,
|
||||
format_artifact(&module_encoded, &file),
|
||||
"go",
|
||||
"PROXY",
|
||||
));
|
||||
state
|
||||
.audit
|
||||
.log(AuditEntry::new("proxy_fetch", "api", "", "go", ""));
|
||||
|
||||
// Background cache: immutable = put_if_absent, mutable = always overwrite
|
||||
let storage = state.storage.clone();
|
||||
let key = storage_key.clone();
|
||||
let data_clone = bytes.clone();
|
||||
tokio::spawn(async move {
|
||||
if is_immutable {
|
||||
// Only write if not already cached (immutability guarantee)
|
||||
if storage.stat(&key).await.is_none() {
|
||||
let _ = storage.put(&key, &data_clone).await;
|
||||
}
|
||||
} else {
|
||||
let _ = storage.put(&key, &data_clone).await;
|
||||
}
|
||||
});
|
||||
|
||||
state.repo_index.invalidate("go");
|
||||
with_content_type(bytes, content_type)
|
||||
}
|
||||
Err(ProxyError::NotFound) => StatusCode::NOT_FOUND.into_response(),
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
module = module_encoded,
|
||||
file = file,
|
||||
error = ?e,
|
||||
"Go upstream proxy error"
|
||||
);
|
||||
StatusCode::BAD_GATEWAY.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Module path encoding/decoding
|
||||
// ============================================================================
|
||||
|
||||
/// Decode Go module path: `!x` → `X`
|
||||
///
|
||||
/// Go module proxy spec requires uppercase letters to be encoded as `!`
|
||||
/// followed by the lowercase letter. Raw uppercase in encoded path is invalid.
|
||||
fn decode_module_path(encoded: &str) -> Result<String, ()> {
|
||||
let mut result = String::with_capacity(encoded.len());
|
||||
let mut chars = encoded.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '!' {
|
||||
match chars.next() {
|
||||
Some(next) if next.is_ascii_lowercase() => {
|
||||
result.push(next.to_ascii_uppercase());
|
||||
}
|
||||
_ => return Err(()),
|
||||
}
|
||||
} else if c.is_ascii_uppercase() {
|
||||
// Raw uppercase in encoded path is invalid per spec
|
||||
return Err(());
|
||||
} else {
|
||||
result.push(c);
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Encode Go module path: `X` → `!x`
|
||||
#[cfg(test)]
|
||||
fn encode_module_path(path: &str) -> String {
|
||||
let mut result = String::with_capacity(path.len() + 8);
|
||||
for c in path.chars() {
|
||||
if c.is_ascii_uppercase() {
|
||||
result.push('!');
|
||||
result.push(c.to_ascii_lowercase());
|
||||
} else {
|
||||
result.push(c);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Path parsing helpers
|
||||
// ============================================================================
|
||||
|
||||
/// Split Go path into (encoded_module, file).
|
||||
///
|
||||
/// Examples:
|
||||
/// "github.com/user/repo/@v/v1.0.0.info" → ("github.com/user/repo", "@v/v1.0.0.info")
|
||||
/// "github.com/user/repo/v2/@v/list" → ("github.com/user/repo/v2", "@v/list")
|
||||
/// "github.com/user/repo/@latest" → ("github.com/user/repo", "@latest")
|
||||
fn split_go_path(path: &str) -> Option<(String, String)> {
|
||||
// Try @latest first (it's simpler)
|
||||
if let Some(pos) = path.rfind("/@latest") {
|
||||
let module = &path[..pos];
|
||||
if !module.is_empty() {
|
||||
return Some((module.to_string(), "@latest".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// Try @v/ — find the last occurrence (handles /v2/@v/ correctly)
|
||||
if let Some(pos) = path.rfind("/@v/") {
|
||||
let module = &path[..pos];
|
||||
let file = &path[pos + 1..]; // "@v/..."
|
||||
if !module.is_empty() && !file.is_empty() {
|
||||
return Some((module.to_string(), file.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Path validation: no traversal attacks
|
||||
fn is_safe_path(path: &str) -> bool {
|
||||
!path.contains("..")
|
||||
&& !path.starts_with('/')
|
||||
&& !path.contains("//")
|
||||
&& !path.contains('\0')
|
||||
&& !path.is_empty()
|
||||
}
|
||||
|
||||
/// Content-Type for Go proxy responses
|
||||
fn content_type_for(file: &str) -> &'static str {
|
||||
if file.ends_with(".info") || file == "@latest" {
|
||||
"application/json"
|
||||
} else if file.ends_with(".zip") {
|
||||
"application/zip"
|
||||
} else {
|
||||
// .mod, @v/list
|
||||
"text/plain; charset=utf-8"
|
||||
}
|
||||
}
|
||||
|
||||
/// Build upstream URL path (uses decoded module path)
|
||||
fn format_upstream_path(module_decoded: &str, file: &str) -> String {
|
||||
format!("{}/{}", module_decoded, file)
|
||||
}
|
||||
|
||||
/// Human-readable artifact name for activity log
|
||||
fn format_artifact(module: &str, file: &str) -> String {
|
||||
if file == "@v/list" || file == "@latest" {
|
||||
format!("{} {}", module, file)
|
||||
} else if let Some(version_file) = file.strip_prefix("@v/") {
|
||||
// "v1.0.0.info" → "module@v1.0.0"
|
||||
let version = version_file
|
||||
.rsplit_once('.')
|
||||
.map(|(v, _ext)| v)
|
||||
.unwrap_or(version_file);
|
||||
format!("{}@{}", module, version)
|
||||
} else {
|
||||
format!("{}/{}", module, file)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build response with Content-Type header
|
||||
fn with_content_type(data: Vec<u8>, content_type: &'static str) -> Response {
|
||||
(
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, HeaderValue::from_static(content_type))],
|
||||
data,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── Encoding/decoding ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_decode_azure() {
|
||||
assert_eq!(
|
||||
decode_module_path("github.com/!azure/sdk").unwrap(),
|
||||
"github.com/Azure/sdk"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_multiple_uppercase() {
|
||||
assert_eq!(
|
||||
decode_module_path("!google!cloud!platform/foo").unwrap(),
|
||||
"GoogleCloudPlatform/foo"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_no_uppercase() {
|
||||
assert_eq!(
|
||||
decode_module_path("github.com/user/repo").unwrap(),
|
||||
"github.com/user/repo"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_invalid_bang_at_end() {
|
||||
assert!(decode_module_path("foo!").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_invalid_bang_followed_by_uppercase() {
|
||||
assert!(decode_module_path("foo!A").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_raw_uppercase_is_invalid() {
|
||||
assert!(decode_module_path("github.com/Azure/sdk").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encode_roundtrip() {
|
||||
let original = "github.com/Azure/azure-sdk-for-go";
|
||||
let encoded = encode_module_path(original);
|
||||
assert_eq!(encoded, "github.com/!azure/azure-sdk-for-go");
|
||||
assert_eq!(decode_module_path(&encoded).unwrap(), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encode_no_change() {
|
||||
assert_eq!(
|
||||
encode_module_path("github.com/user/repo"),
|
||||
"github.com/user/repo"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Path splitting ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_split_version_info() {
|
||||
let (module, file) = split_go_path("github.com/user/repo/@v/v1.0.0.info").unwrap();
|
||||
assert_eq!(module, "github.com/user/repo");
|
||||
assert_eq!(file, "@v/v1.0.0.info");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_version_list() {
|
||||
let (module, file) = split_go_path("github.com/user/repo/@v/list").unwrap();
|
||||
assert_eq!(module, "github.com/user/repo");
|
||||
assert_eq!(file, "@v/list");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_latest() {
|
||||
let (module, file) = split_go_path("github.com/user/repo/@latest").unwrap();
|
||||
assert_eq!(module, "github.com/user/repo");
|
||||
assert_eq!(file, "@latest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_major_version_suffix() {
|
||||
let (module, file) = split_go_path("github.com/user/repo/v2/@v/list").unwrap();
|
||||
assert_eq!(module, "github.com/user/repo/v2");
|
||||
assert_eq!(file, "@v/list");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_incompatible_version() {
|
||||
let (module, file) =
|
||||
split_go_path("github.com/user/repo/@v/v4.1.2+incompatible.info").unwrap();
|
||||
assert_eq!(module, "github.com/user/repo");
|
||||
assert_eq!(file, "@v/v4.1.2+incompatible.info");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_pseudo_version() {
|
||||
let (module, file) =
|
||||
split_go_path("github.com/user/repo/@v/v0.0.0-20210101000000-abcdef123456.info")
|
||||
.unwrap();
|
||||
assert_eq!(module, "github.com/user/repo");
|
||||
assert_eq!(file, "@v/v0.0.0-20210101000000-abcdef123456.info");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_no_at() {
|
||||
assert!(split_go_path("github.com/user/repo/v1.0.0").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_empty_module() {
|
||||
assert!(split_go_path("/@v/list").is_none());
|
||||
}
|
||||
|
||||
// ── Path safety ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_safe_path_normal() {
|
||||
assert!(is_safe_path("github.com/user/repo/@v/list"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_traversal() {
|
||||
assert!(!is_safe_path("../../etc/passwd"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_absolute() {
|
||||
assert!(!is_safe_path("/etc/passwd"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_double_slash() {
|
||||
assert!(!is_safe_path("github.com//evil/@v/list"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_null() {
|
||||
assert!(!is_safe_path("github.com/\0evil/@v/list"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_empty() {
|
||||
assert!(!is_safe_path(""));
|
||||
}
|
||||
|
||||
// ── Content-Type ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_content_type_info() {
|
||||
assert_eq!(content_type_for("@v/v1.0.0.info"), "application/json");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_type_latest() {
|
||||
assert_eq!(content_type_for("@latest"), "application/json");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_type_zip() {
|
||||
assert_eq!(content_type_for("@v/v1.0.0.zip"), "application/zip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_type_mod() {
|
||||
assert_eq!(
|
||||
content_type_for("@v/v1.0.0.mod"),
|
||||
"text/plain; charset=utf-8"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_type_list() {
|
||||
assert_eq!(content_type_for("@v/list"), "text/plain; charset=utf-8");
|
||||
}
|
||||
|
||||
// ── Artifact formatting ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_format_artifact_version() {
|
||||
assert_eq!(
|
||||
format_artifact("github.com/user/repo", "@v/v1.0.0.info"),
|
||||
"github.com/user/repo@v1.0.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_artifact_list() {
|
||||
assert_eq!(
|
||||
format_artifact("github.com/user/repo", "@v/list"),
|
||||
"github.com/user/repo @v/list"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_artifact_latest() {
|
||||
assert_eq!(
|
||||
format_artifact("github.com/user/repo", "@latest"),
|
||||
"github.com/user/repo @latest"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_artifact_zip() {
|
||||
assert_eq!(
|
||||
format_artifact("github.com/user/repo", "@v/v1.0.0.zip"),
|
||||
"github.com/user/repo@v1.0.0"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
mod cargo_registry;
|
||||
pub mod docker;
|
||||
pub mod docker_auth;
|
||||
mod go;
|
||||
mod maven;
|
||||
mod npm;
|
||||
mod pypi;
|
||||
@@ -12,6 +13,7 @@ mod raw;
|
||||
pub use cargo_registry::routes as cargo_routes;
|
||||
pub use docker::routes as docker_routes;
|
||||
pub use docker_auth::DockerAuth;
|
||||
pub use go::routes as go_routes;
|
||||
pub use maven::routes as maven_routes;
|
||||
pub use npm::routes as npm_routes;
|
||||
pub use pypi::routes as pypi_routes;
|
||||
|
||||
Reference in New Issue
Block a user