feat: upstream auth for all protocols (Docker, Maven, npm, PyPI)

Wire up basic auth credentials for upstream registry proxying:
- Docker: pass configured auth to Bearer token requests
- Maven: support url|auth format in NORA_MAVEN_PROXIES env var
- npm: add NORA_NPM_PROXY_AUTH env var
- PyPI: add NORA_PYPI_PROXY_AUTH env var
- Mask credentials in logs (never log plaintext passwords)

Config examples:
  NORA_DOCKER_UPSTREAMS="https://registry.corp.com|user:pass"
  NORA_MAVEN_PROXIES="https://nexus.corp.com/maven2|user:pass"
  NORA_NPM_PROXY_AUTH="user:pass"
  NORA_PYPI_PROXY_AUTH="user:pass"
This commit is contained in:
2026-03-15 21:29:20 +00:00
parent a1da4fff1e
commit fa962b2d6e
7 changed files with 186 additions and 59 deletions

View File

@@ -93,7 +93,7 @@ fn default_bucket() -> String {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MavenConfig { pub struct MavenConfig {
#[serde(default)] #[serde(default)]
pub proxies: Vec<String>, pub proxies: Vec<MavenProxyEntry>,
#[serde(default = "default_timeout")] #[serde(default = "default_timeout")]
pub proxy_timeout: u64, pub proxy_timeout: u64,
} }
@@ -102,6 +102,8 @@ pub struct MavenConfig {
pub struct NpmConfig { pub struct NpmConfig {
#[serde(default)] #[serde(default)]
pub proxy: Option<String>, pub proxy: Option<String>,
#[serde(default)]
pub proxy_auth: Option<String>, // "user:pass" for basic auth
#[serde(default = "default_timeout")] #[serde(default = "default_timeout")]
pub proxy_timeout: u64, pub proxy_timeout: u64,
} }
@@ -110,6 +112,8 @@ pub struct NpmConfig {
pub struct PypiConfig { pub struct PypiConfig {
#[serde(default)] #[serde(default)]
pub proxy: Option<String>, pub proxy: Option<String>,
#[serde(default)]
pub proxy_auth: Option<String>, // "user:pass" for basic auth
#[serde(default = "default_timeout")] #[serde(default = "default_timeout")]
pub proxy_timeout: u64, pub proxy_timeout: u64,
} }
@@ -131,6 +135,37 @@ pub struct DockerUpstream {
pub auth: Option<String>, // "user:pass" for basic auth pub auth: Option<String>, // "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<String>, // "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 /// Raw repository configuration for simple file storage
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RawConfig { pub struct RawConfig {
@@ -177,7 +212,9 @@ fn default_timeout() -> u64 {
impl Default for MavenConfig { impl Default for MavenConfig {
fn default() -> Self { fn default() -> Self {
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, proxy_timeout: 30,
} }
} }
@@ -187,6 +224,7 @@ impl Default for NpmConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
proxy: Some("https://registry.npmjs.org".to_string()), proxy: Some("https://registry.npmjs.org".to_string()),
proxy_auth: None,
proxy_timeout: 30, proxy_timeout: 30,
} }
} }
@@ -196,6 +234,7 @@ impl Default for PypiConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
proxy: Some("https://pypi.org/simple/".to_string()), proxy: Some("https://pypi.org/simple/".to_string()),
proxy_auth: None,
proxy_timeout: 30, proxy_timeout: 30,
} }
} }
@@ -377,9 +416,23 @@ impl Config {
self.auth.htpasswd_file = val; 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") { 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(val) = env::var("NORA_MAVEN_PROXY_TIMEOUT") {
if let Ok(timeout) = val.parse() { 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 // PyPI config
if let Ok(val) = env::var("NORA_PYPI_PROXY") { if let Ok(val) = env::var("NORA_PYPI_PROXY") {
self.pypi.proxy = if val.is_empty() { None } else { Some(val) }; 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 // Docker config
if let Ok(val) = env::var("NORA_DOCKER_PROXY_TIMEOUT") { if let Ok(val) = env::var("NORA_DOCKER_PROXY_TIMEOUT") {
if let Ok(timeout) = val.parse() { if let Ok(timeout) = val.parse() {

View File

@@ -15,6 +15,7 @@ use axum::{
routing::{delete, get, head, patch, put}, routing::{delete, get, head, patch, put},
Json, Router, Json, Router,
}; };
use base64::{engine::general_purpose::STANDARD, Engine};
use parking_lot::RwLock; use parking_lot::RwLock;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
@@ -181,6 +182,7 @@ async fn download_blob(
&digest, &digest,
&state.docker_auth, &state.docker_auth,
state.config.docker.proxy_timeout, state.config.docker.proxy_timeout,
upstream.auth.as_deref(),
) )
.await .await
{ {
@@ -392,6 +394,7 @@ async fn get_manifest(
&reference, &reference,
&state.docker_auth, &state.docker_auth,
state.config.docker.proxy_timeout, state.config.docker.proxy_timeout,
upstream.auth.as_deref(),
) )
.await .await
{ {
@@ -733,6 +736,7 @@ async fn fetch_blob_from_upstream(
digest: &str, digest: &str,
docker_auth: &DockerAuth, docker_auth: &DockerAuth,
timeout: u64, timeout: u64,
basic_auth: Option<&str>,
) -> Result<Vec<u8>, ()> { ) -> Result<Vec<u8>, ()> {
let url = format!( let url = format!(
"{}/v2/{}/blobs/{}", "{}/v2/{}/blobs/{}",
@@ -741,13 +745,13 @@ async fn fetch_blob_from_upstream(
digest digest
); );
// First try without auth // First try with basic auth if configured
let response = client let mut request = client.get(&url).timeout(Duration::from_secs(timeout));
.get(&url) if let Some(credentials) = basic_auth {
.timeout(Duration::from_secs(timeout)) let encoded = STANDARD.encode(credentials);
.send() request = request.header("Authorization", format!("Basic {}", encoded));
.await }
.map_err(|_| ())?; let response = request.send().await.map_err(|_| ())?;
let response = if response.status() == reqwest::StatusCode::UNAUTHORIZED { let response = if response.status() == reqwest::StatusCode::UNAUTHORIZED {
// Get Www-Authenticate header and fetch token // Get Www-Authenticate header and fetch token
@@ -758,7 +762,7 @@ async fn fetch_blob_from_upstream(
.map(String::from); .map(String::from);
if let Some(token) = docker_auth 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 .await
{ {
client client
@@ -790,6 +794,7 @@ async fn fetch_manifest_from_upstream(
reference: &str, reference: &str,
docker_auth: &DockerAuth, docker_auth: &DockerAuth,
timeout: u64, timeout: u64,
basic_auth: Option<&str>,
) -> Result<(Vec<u8>, String), ()> { ) -> Result<(Vec<u8>, String), ()> {
let url = format!( let url = format!(
"{}/v2/{}/manifests/{}", "{}/v2/{}/manifests/{}",
@@ -806,14 +811,16 @@ async fn fetch_manifest_from_upstream(
application/vnd.oci.image.manifest.v1+json, \ application/vnd.oci.image.manifest.v1+json, \
application/vnd.oci.image.index.v1+json"; application/vnd.oci.image.index.v1+json";
// First try without auth // First try with basic auth if configured
let response = client let mut request = client
.get(&url) .get(&url)
.timeout(Duration::from_secs(timeout)) .timeout(Duration::from_secs(timeout))
.header("Accept", accept_header) .header("Accept", accept_header);
.send() if let Some(credentials) = basic_auth {
.await let encoded = STANDARD.encode(credentials);
.map_err(|e| { 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::error!(error = %e, url = %url, "Failed to send request to upstream");
})?; })?;
@@ -830,7 +837,7 @@ async fn fetch_manifest_from_upstream(
tracing::debug!(www_auth = ?www_auth, "Got 401, fetching token"); tracing::debug!(www_auth = ?www_auth, "Got 401, fetching token");
if let Some(token) = docker_auth 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 .await
{ {
tracing::debug!("Token acquired, retrying with auth"); tracing::debug!("Token acquired, retrying with auth");

View File

@@ -1,6 +1,7 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay // Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
use base64::{engine::general_purpose::STANDARD, Engine};
use parking_lot::RwLock; use parking_lot::RwLock;
use std::collections::HashMap; use std::collections::HashMap;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -36,6 +37,7 @@ impl DockerAuth {
registry_url: &str, registry_url: &str,
name: &str, name: &str,
www_authenticate: Option<&str>, www_authenticate: Option<&str>,
basic_auth: Option<&str>,
) -> Option<String> { ) -> Option<String> {
let cache_key = format!("{}:{}", registry_url, name); let cache_key = format!("{}:{}", registry_url, name);
@@ -51,7 +53,7 @@ impl DockerAuth {
// Need to fetch a new token // Need to fetch a new token
let www_auth = www_authenticate?; 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) // Cache the token (default 5 minute expiry)
{ {
@@ -70,7 +72,12 @@ impl DockerAuth {
/// Parse Www-Authenticate header and fetch token from auth server /// 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" /// 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<String> { async fn fetch_token(
&self,
www_authenticate: &str,
name: &str,
basic_auth: Option<&str>,
) -> Option<String> {
let params = parse_www_authenticate(www_authenticate)?; let params = parse_www_authenticate(www_authenticate)?;
let realm = params.get("realm")?; let realm = params.get("realm")?;
@@ -82,7 +89,14 @@ impl DockerAuth {
tracing::debug!(url = %url, "Fetching auth token"); 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() { if !response.status().is_success() {
tracing::warn!(status = %response.status(), "Token request failed"); tracing::warn!(status = %response.status(), "Token request failed");
@@ -104,9 +118,15 @@ impl DockerAuth {
url: &str, url: &str,
registry_url: &str, registry_url: &str,
name: &str, name: &str,
basic_auth: Option<&str>,
) -> Result<reqwest::Response, ()> { ) -> Result<reqwest::Response, ()> {
// First try without auth // First try with basic auth if configured, otherwise anonymous
let response = self.client.get(url).send().await.map_err(|_| ())?; 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 { if response.status() == reqwest::StatusCode::UNAUTHORIZED {
// Extract Www-Authenticate header // Extract Www-Authenticate header
@@ -118,7 +138,7 @@ impl DockerAuth {
// Get token and retry // Get token and retry
if let Some(token) = self 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 .await
{ {
return self return self

View File

@@ -12,6 +12,7 @@ use axum::{
routing::{get, put}, routing::{get, put},
Router, Router,
}; };
use base64::{engine::general_purpose::STANDARD, Engine};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@@ -49,10 +50,17 @@ async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>)
return with_content_type(&path, data).into_response(); return with_content_type(&path, data).into_response();
} }
for proxy_url in &state.config.maven.proxies { for proxy in &state.config.maven.proxies {
let url = format!("{}/{}", proxy_url.trim_end_matches('/'), path); 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) => { Ok(data) => {
state.metrics.record_download("maven"); state.metrics.record_download("maven");
state.metrics.record_cache_miss(); state.metrics.record_cache_miss();
@@ -124,13 +132,14 @@ async fn fetch_from_proxy(
client: &reqwest::Client, client: &reqwest::Client,
url: &str, url: &str,
timeout_secs: u64, timeout_secs: u64,
auth: Option<&str>,
) -> Result<Vec<u8>, ()> { ) -> Result<Vec<u8>, ()> {
let response = client let mut request = client.get(url).timeout(Duration::from_secs(timeout_secs));
.get(url) if let Some(credentials) = auth {
.timeout(Duration::from_secs(timeout_secs)) let encoded = STANDARD.encode(credentials);
.send() request = request.header("Authorization", format!("Basic {}", encoded));
.await }
.map_err(|_| ())?; let response = request.send().await.map_err(|_| ())?;
if !response.status().is_success() { if !response.status().is_success() {
return Err(()); return Err(());

View File

@@ -12,6 +12,7 @@ use axum::{
routing::get, routing::get,
Router, Router,
}; };
use base64::{engine::general_purpose::STANDARD, Engine};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@@ -59,8 +60,13 @@ async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<Str
if let Some(proxy_url) = &state.config.npm.proxy { if let Some(proxy_url) = &state.config.npm.proxy {
let url = format!("{}/{}", proxy_url.trim_end_matches('/'), path); let url = format!("{}/{}", proxy_url.trim_end_matches('/'), path);
if let Ok(data) = if let Ok(data) = fetch_from_proxy(
fetch_from_proxy(&state.http_client, &url, state.config.npm.proxy_timeout).await &state.http_client,
&url,
state.config.npm.proxy_timeout,
state.config.npm.proxy_auth.as_deref(),
)
.await
{ {
if is_tarball { if is_tarball {
state.metrics.record_download("npm"); state.metrics.record_download("npm");
@@ -98,13 +104,14 @@ async fn fetch_from_proxy(
client: &reqwest::Client, client: &reqwest::Client,
url: &str, url: &str,
timeout_secs: u64, timeout_secs: u64,
auth: Option<&str>,
) -> Result<Vec<u8>, ()> { ) -> Result<Vec<u8>, ()> {
let response = client let mut request = client.get(url).timeout(Duration::from_secs(timeout_secs));
.get(url) if let Some(credentials) = auth {
.timeout(Duration::from_secs(timeout_secs)) let encoded = STANDARD.encode(credentials);
.send() request = request.header("Authorization", format!("Basic {}", encoded));
.await }
.map_err(|_| ())?; let response = request.send().await.map_err(|_| ())?;
if !response.status().is_success() { if !response.status().is_success() {
return Err(()); return Err(());

View File

@@ -11,6 +11,7 @@ use axum::{
routing::get, routing::get,
Router, Router,
}; };
use base64::{engine::general_purpose::STANDARD, Engine};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@@ -86,8 +87,13 @@ async fn package_versions(
if let Some(proxy_url) = &state.config.pypi.proxy { if let Some(proxy_url) = &state.config.pypi.proxy {
let url = format!("{}/{}/", proxy_url.trim_end_matches('/'), normalized); let url = format!("{}/{}/", proxy_url.trim_end_matches('/'), normalized);
if let Ok(html) = if let Ok(html) = fetch_package_page(
fetch_package_page(&state.http_client, &url, state.config.pypi.proxy_timeout).await &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 // Rewrite URLs in the HTML to point to our registry
let rewritten = rewrite_pypi_links(&html, &normalized); let rewritten = rewrite_pypi_links(&html, &normalized);
@@ -140,6 +146,7 @@ async fn download_file(
&state.http_client, &state.http_client,
&page_url, &page_url,
state.config.pypi.proxy_timeout, state.config.pypi.proxy_timeout,
state.config.pypi.proxy_auth.as_deref(),
) )
.await .await
{ {
@@ -149,6 +156,7 @@ async fn download_file(
&state.http_client, &state.http_client,
&file_url, &file_url,
state.config.pypi.proxy_timeout, state.config.pypi.proxy_timeout,
state.config.pypi.proxy_auth.as_deref(),
) )
.await .await
{ {
@@ -202,14 +210,17 @@ async fn fetch_package_page(
client: &reqwest::Client, client: &reqwest::Client,
url: &str, url: &str,
timeout_secs: u64, timeout_secs: u64,
auth: Option<&str>,
) -> Result<String, ()> { ) -> Result<String, ()> {
let response = client let mut request = client
.get(url) .get(url)
.timeout(Duration::from_secs(timeout_secs)) .timeout(Duration::from_secs(timeout_secs))
.header("Accept", "text/html") .header("Accept", "text/html");
.send() if let Some(credentials) = auth {
.await let encoded = STANDARD.encode(credentials);
.map_err(|_| ())?; request = request.header("Authorization", format!("Basic {}", encoded));
}
let response = request.send().await.map_err(|_| ())?;
if !response.status().is_success() { if !response.status().is_success() {
return Err(()); return Err(());
@@ -219,13 +230,18 @@ async fn fetch_package_page(
} }
/// Fetch file from upstream /// Fetch file from upstream
async fn fetch_file(client: &reqwest::Client, url: &str, timeout_secs: u64) -> Result<Vec<u8>, ()> { async fn fetch_file(
let response = client client: &reqwest::Client,
.get(url) url: &str,
.timeout(Duration::from_secs(timeout_secs)) timeout_secs: u64,
.send() auth: Option<&str>,
.await ) -> Result<Vec<u8>, ()> {
.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() { if !response.status().is_success() {
return Err(()); return Err(());

View File

@@ -205,7 +205,12 @@ pub async fn api_dashboard(State(state): State<Arc<AppState>>) -> Json<Dashboard
MountPoint { MountPoint {
registry: "Maven".to_string(), registry: "Maven".to_string(),
mount_path: "/maven2/".to_string(), mount_path: "/maven2/".to_string(),
proxy_upstream: state.config.maven.proxies.first().cloned(), proxy_upstream: state
.config
.maven
.proxies
.first()
.map(|p| p.url().to_string()),
}, },
MountPoint { MountPoint {
registry: "npm".to_string(), registry: "npm".to_string(),