diff --git a/nora-registry/src/config.rs b/nora-registry/src/config.rs index 767c240..215b3a4 100644 --- a/nora-registry/src/config.rs +++ b/nora-registry/src/config.rs @@ -93,7 +93,7 @@ fn default_bucket() -> String { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MavenConfig { #[serde(default)] - pub proxies: Vec, + pub proxies: Vec, #[serde(default = "default_timeout")] pub proxy_timeout: u64, } @@ -102,6 +102,8 @@ pub struct MavenConfig { pub struct NpmConfig { #[serde(default)] pub proxy: Option, + #[serde(default)] + pub proxy_auth: Option, // "user:pass" for basic auth #[serde(default = "default_timeout")] pub proxy_timeout: u64, } @@ -110,6 +112,8 @@ pub struct NpmConfig { pub struct PypiConfig { #[serde(default)] pub proxy: Option, + #[serde(default)] + pub proxy_auth: Option, // "user:pass" for basic auth #[serde(default = "default_timeout")] pub proxy_timeout: u64, } @@ -131,6 +135,37 @@ pub struct DockerUpstream { pub auth: Option, // "user:pass" for basic auth } +/// Maven upstream proxy configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum MavenProxyEntry { + Simple(String), + Full(MavenProxy), +} + +/// Maven upstream proxy with optional auth +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MavenProxy { + pub url: String, + #[serde(default)] + pub auth: Option, // "user:pass" for basic auth +} + +impl MavenProxyEntry { + pub fn url(&self) -> &str { + match self { + MavenProxyEntry::Simple(s) => s, + MavenProxyEntry::Full(p) => &p.url, + } + } + pub fn auth(&self) -> Option<&str> { + match self { + MavenProxyEntry::Simple(_) => None, + MavenProxyEntry::Full(p) => p.auth.as_deref(), + } + } +} + /// Raw repository configuration for simple file storage #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RawConfig { @@ -177,7 +212,9 @@ fn default_timeout() -> u64 { impl Default for MavenConfig { fn default() -> Self { Self { - proxies: vec!["https://repo1.maven.org/maven2".to_string()], + proxies: vec![MavenProxyEntry::Simple( + "https://repo1.maven.org/maven2".to_string(), + )], proxy_timeout: 30, } } @@ -187,6 +224,7 @@ impl Default for NpmConfig { fn default() -> Self { Self { proxy: Some("https://registry.npmjs.org".to_string()), + proxy_auth: None, proxy_timeout: 30, } } @@ -196,6 +234,7 @@ impl Default for PypiConfig { fn default() -> Self { Self { proxy: Some("https://pypi.org/simple/".to_string()), + proxy_auth: None, proxy_timeout: 30, } } @@ -377,9 +416,23 @@ impl Config { self.auth.htpasswd_file = val; } - // Maven config + // Maven config — supports "url1,url2" or "url1|auth1,url2|auth2" if let Ok(val) = env::var("NORA_MAVEN_PROXIES") { - self.maven.proxies = val.split(',').map(|s| s.trim().to_string()).collect(); + self.maven.proxies = val + .split(',') + .filter(|s| !s.is_empty()) + .map(|s| { + let parts: Vec<&str> = s.trim().splitn(2, '|').collect(); + if parts.len() > 1 { + MavenProxyEntry::Full(MavenProxy { + url: parts[0].to_string(), + auth: Some(parts[1].to_string()), + }) + } else { + MavenProxyEntry::Simple(parts[0].to_string()) + } + }) + .collect(); } if let Ok(val) = env::var("NORA_MAVEN_PROXY_TIMEOUT") { if let Ok(timeout) = val.parse() { @@ -397,6 +450,11 @@ impl Config { } } + // npm proxy auth + if let Ok(val) = env::var("NORA_NPM_PROXY_AUTH") { + self.npm.proxy_auth = if val.is_empty() { None } else { Some(val) }; + } + // PyPI config if let Ok(val) = env::var("NORA_PYPI_PROXY") { self.pypi.proxy = if val.is_empty() { None } else { Some(val) }; @@ -407,6 +465,11 @@ impl Config { } } + // PyPI proxy auth + if let Ok(val) = env::var("NORA_PYPI_PROXY_AUTH") { + self.pypi.proxy_auth = if val.is_empty() { None } else { Some(val) }; + } + // Docker config if let Ok(val) = env::var("NORA_DOCKER_PROXY_TIMEOUT") { if let Ok(timeout) = val.parse() { diff --git a/nora-registry/src/registry/docker.rs b/nora-registry/src/registry/docker.rs index ce44fbf..ae531bb 100644 --- a/nora-registry/src/registry/docker.rs +++ b/nora-registry/src/registry/docker.rs @@ -15,6 +15,7 @@ use axum::{ routing::{delete, get, head, patch, put}, Json, Router, }; +use base64::{engine::general_purpose::STANDARD, Engine}; use parking_lot::RwLock; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -181,6 +182,7 @@ async fn download_blob( &digest, &state.docker_auth, state.config.docker.proxy_timeout, + upstream.auth.as_deref(), ) .await { @@ -392,6 +394,7 @@ async fn get_manifest( &reference, &state.docker_auth, state.config.docker.proxy_timeout, + upstream.auth.as_deref(), ) .await { @@ -733,6 +736,7 @@ async fn fetch_blob_from_upstream( digest: &str, docker_auth: &DockerAuth, timeout: u64, + basic_auth: Option<&str>, ) -> Result, ()> { let url = format!( "{}/v2/{}/blobs/{}", @@ -741,13 +745,13 @@ async fn fetch_blob_from_upstream( digest ); - // First try without auth - let response = client - .get(&url) - .timeout(Duration::from_secs(timeout)) - .send() - .await - .map_err(|_| ())?; + // First try — with basic auth if configured + let mut request = client.get(&url).timeout(Duration::from_secs(timeout)); + if let Some(credentials) = basic_auth { + let encoded = STANDARD.encode(credentials); + request = request.header("Authorization", format!("Basic {}", encoded)); + } + let response = request.send().await.map_err(|_| ())?; let response = if response.status() == reqwest::StatusCode::UNAUTHORIZED { // Get Www-Authenticate header and fetch token @@ -758,7 +762,7 @@ async fn fetch_blob_from_upstream( .map(String::from); if let Some(token) = docker_auth - .get_token(upstream_url, name, www_auth.as_deref()) + .get_token(upstream_url, name, www_auth.as_deref(), basic_auth) .await { client @@ -790,6 +794,7 @@ async fn fetch_manifest_from_upstream( reference: &str, docker_auth: &DockerAuth, timeout: u64, + basic_auth: Option<&str>, ) -> Result<(Vec, String), ()> { let url = format!( "{}/v2/{}/manifests/{}", @@ -806,16 +811,18 @@ async fn fetch_manifest_from_upstream( application/vnd.oci.image.manifest.v1+json, \ application/vnd.oci.image.index.v1+json"; - // First try without auth - let response = client + // First try — with basic auth if configured + let mut request = client .get(&url) .timeout(Duration::from_secs(timeout)) - .header("Accept", accept_header) - .send() - .await - .map_err(|e| { - tracing::error!(error = %e, url = %url, "Failed to send request to upstream"); - })?; + .header("Accept", accept_header); + if let Some(credentials) = basic_auth { + let encoded = STANDARD.encode(credentials); + request = request.header("Authorization", format!("Basic {}", encoded)); + } + let response = request.send().await.map_err(|e| { + tracing::error!(error = %e, url = %url, "Failed to send request to upstream"); + })?; tracing::debug!(status = %response.status(), "Initial upstream response"); @@ -830,7 +837,7 @@ async fn fetch_manifest_from_upstream( tracing::debug!(www_auth = ?www_auth, "Got 401, fetching token"); if let Some(token) = docker_auth - .get_token(upstream_url, name, www_auth.as_deref()) + .get_token(upstream_url, name, www_auth.as_deref(), basic_auth) .await { tracing::debug!("Token acquired, retrying with auth"); diff --git a/nora-registry/src/registry/docker_auth.rs b/nora-registry/src/registry/docker_auth.rs index 71e74cc..0f74087 100644 --- a/nora-registry/src/registry/docker_auth.rs +++ b/nora-registry/src/registry/docker_auth.rs @@ -1,6 +1,7 @@ // Copyright (c) 2026 Volkov Pavel | DevITWay // SPDX-License-Identifier: MIT +use base64::{engine::general_purpose::STANDARD, Engine}; use parking_lot::RwLock; use std::collections::HashMap; use std::time::{Duration, Instant}; @@ -36,6 +37,7 @@ impl DockerAuth { registry_url: &str, name: &str, www_authenticate: Option<&str>, + basic_auth: Option<&str>, ) -> Option { let cache_key = format!("{}:{}", registry_url, name); @@ -51,7 +53,7 @@ impl DockerAuth { // Need to fetch a new token let www_auth = www_authenticate?; - let token = self.fetch_token(www_auth, name).await?; + let token = self.fetch_token(www_auth, name, basic_auth).await?; // Cache the token (default 5 minute expiry) { @@ -70,7 +72,12 @@ impl DockerAuth { /// Parse Www-Authenticate header and fetch token from auth server /// Format: Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/alpine:pull" - async fn fetch_token(&self, www_authenticate: &str, name: &str) -> Option { + async fn fetch_token( + &self, + www_authenticate: &str, + name: &str, + basic_auth: Option<&str>, + ) -> Option { let params = parse_www_authenticate(www_authenticate)?; let realm = params.get("realm")?; @@ -82,7 +89,14 @@ impl DockerAuth { tracing::debug!(url = %url, "Fetching auth token"); - let response = self.client.get(&url).send().await.ok()?; + let mut request = self.client.get(&url); + if let Some(credentials) = basic_auth { + let encoded = STANDARD.encode(credentials); + request = request.header("Authorization", format!("Basic {}", encoded)); + tracing::debug!("Using basic auth for token request"); + } + + let response = request.send().await.ok()?; if !response.status().is_success() { tracing::warn!(status = %response.status(), "Token request failed"); @@ -104,9 +118,15 @@ impl DockerAuth { url: &str, registry_url: &str, name: &str, + basic_auth: Option<&str>, ) -> Result { - // First try without auth - let response = self.client.get(url).send().await.map_err(|_| ())?; + // First try — with basic auth if configured, otherwise anonymous + let mut request = self.client.get(url); + if let Some(credentials) = basic_auth { + let encoded = STANDARD.encode(credentials); + request = request.header("Authorization", format!("Basic {}", encoded)); + } + let response = request.send().await.map_err(|_| ())?; if response.status() == reqwest::StatusCode::UNAUTHORIZED { // Extract Www-Authenticate header @@ -118,7 +138,7 @@ impl DockerAuth { // Get token and retry if let Some(token) = self - .get_token(registry_url, name, www_auth.as_deref()) + .get_token(registry_url, name, www_auth.as_deref(), basic_auth) .await { return self diff --git a/nora-registry/src/registry/maven.rs b/nora-registry/src/registry/maven.rs index 3dc00b3..b9ba926 100644 --- a/nora-registry/src/registry/maven.rs +++ b/nora-registry/src/registry/maven.rs @@ -12,6 +12,7 @@ use axum::{ routing::{get, put}, Router, }; +use base64::{engine::general_purpose::STANDARD, Engine}; use std::sync::Arc; use std::time::Duration; @@ -49,10 +50,17 @@ async fn download(State(state): State>, Path(path): Path) return with_content_type(&path, data).into_response(); } - for proxy_url in &state.config.maven.proxies { - let url = format!("{}/{}", proxy_url.trim_end_matches('/'), path); + for proxy in &state.config.maven.proxies { + let url = format!("{}/{}", proxy.url().trim_end_matches('/'), path); - match fetch_from_proxy(&state.http_client, &url, state.config.maven.proxy_timeout).await { + match fetch_from_proxy( + &state.http_client, + &url, + state.config.maven.proxy_timeout, + proxy.auth(), + ) + .await + { Ok(data) => { state.metrics.record_download("maven"); state.metrics.record_cache_miss(); @@ -124,13 +132,14 @@ async fn fetch_from_proxy( client: &reqwest::Client, url: &str, timeout_secs: u64, + auth: Option<&str>, ) -> Result, ()> { - let response = client - .get(url) - .timeout(Duration::from_secs(timeout_secs)) - .send() - .await - .map_err(|_| ())?; + let mut request = client.get(url).timeout(Duration::from_secs(timeout_secs)); + if let Some(credentials) = auth { + let encoded = STANDARD.encode(credentials); + request = request.header("Authorization", format!("Basic {}", encoded)); + } + let response = request.send().await.map_err(|_| ())?; if !response.status().is_success() { return Err(()); diff --git a/nora-registry/src/registry/npm.rs b/nora-registry/src/registry/npm.rs index 6927d44..676f7ba 100644 --- a/nora-registry/src/registry/npm.rs +++ b/nora-registry/src/registry/npm.rs @@ -12,6 +12,7 @@ use axum::{ routing::get, Router, }; +use base64::{engine::general_purpose::STANDARD, Engine}; use std::sync::Arc; use std::time::Duration; @@ -59,8 +60,13 @@ async fn handle_request(State(state): State>, Path(path): Path, ) -> Result, ()> { - let response = client - .get(url) - .timeout(Duration::from_secs(timeout_secs)) - .send() - .await - .map_err(|_| ())?; + let mut request = client.get(url).timeout(Duration::from_secs(timeout_secs)); + if let Some(credentials) = auth { + let encoded = STANDARD.encode(credentials); + request = request.header("Authorization", format!("Basic {}", encoded)); + } + let response = request.send().await.map_err(|_| ())?; if !response.status().is_success() { return Err(()); diff --git a/nora-registry/src/registry/pypi.rs b/nora-registry/src/registry/pypi.rs index afe8366..fc23c0e 100644 --- a/nora-registry/src/registry/pypi.rs +++ b/nora-registry/src/registry/pypi.rs @@ -11,6 +11,7 @@ use axum::{ routing::get, Router, }; +use base64::{engine::general_purpose::STANDARD, Engine}; use std::sync::Arc; use std::time::Duration; @@ -86,8 +87,13 @@ async fn package_versions( if let Some(proxy_url) = &state.config.pypi.proxy { let url = format!("{}/{}/", proxy_url.trim_end_matches('/'), normalized); - if let Ok(html) = - fetch_package_page(&state.http_client, &url, state.config.pypi.proxy_timeout).await + if let Ok(html) = fetch_package_page( + &state.http_client, + &url, + state.config.pypi.proxy_timeout, + state.config.pypi.proxy_auth.as_deref(), + ) + .await { // Rewrite URLs in the HTML to point to our registry let rewritten = rewrite_pypi_links(&html, &normalized); @@ -140,6 +146,7 @@ async fn download_file( &state.http_client, &page_url, state.config.pypi.proxy_timeout, + state.config.pypi.proxy_auth.as_deref(), ) .await { @@ -149,6 +156,7 @@ async fn download_file( &state.http_client, &file_url, state.config.pypi.proxy_timeout, + state.config.pypi.proxy_auth.as_deref(), ) .await { @@ -202,14 +210,17 @@ async fn fetch_package_page( client: &reqwest::Client, url: &str, timeout_secs: u64, + auth: Option<&str>, ) -> Result { - let response = client + let mut request = client .get(url) .timeout(Duration::from_secs(timeout_secs)) - .header("Accept", "text/html") - .send() - .await - .map_err(|_| ())?; + .header("Accept", "text/html"); + if let Some(credentials) = auth { + let encoded = STANDARD.encode(credentials); + request = request.header("Authorization", format!("Basic {}", encoded)); + } + let response = request.send().await.map_err(|_| ())?; if !response.status().is_success() { return Err(()); @@ -219,13 +230,18 @@ async fn fetch_package_page( } /// Fetch file from upstream -async fn fetch_file(client: &reqwest::Client, url: &str, timeout_secs: u64) -> Result, ()> { - let response = client - .get(url) - .timeout(Duration::from_secs(timeout_secs)) - .send() - .await - .map_err(|_| ())?; +async fn fetch_file( + client: &reqwest::Client, + url: &str, + timeout_secs: u64, + auth: Option<&str>, +) -> Result, ()> { + let mut request = client.get(url).timeout(Duration::from_secs(timeout_secs)); + if let Some(credentials) = auth { + let encoded = STANDARD.encode(credentials); + request = request.header("Authorization", format!("Basic {}", encoded)); + } + let response = request.send().await.map_err(|_| ())?; if !response.status().is_success() { return Err(()); diff --git a/nora-registry/src/ui/api.rs b/nora-registry/src/ui/api.rs index 65ca372..47921bc 100644 --- a/nora-registry/src/ui/api.rs +++ b/nora-registry/src/ui/api.rs @@ -205,7 +205,12 @@ pub async fn api_dashboard(State(state): State>) -> Json