mirror of
https://github.com/getnora-io/nora.git
synced 2026-04-12 12:40:31 +00:00
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:
@@ -93,7 +93,7 @@ fn default_bucket() -> String {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MavenConfig {
|
||||
#[serde(default)]
|
||||
pub proxies: Vec<String>,
|
||||
pub proxies: Vec<MavenProxyEntry>,
|
||||
#[serde(default = "default_timeout")]
|
||||
pub proxy_timeout: u64,
|
||||
}
|
||||
@@ -102,6 +102,8 @@ pub struct MavenConfig {
|
||||
pub struct NpmConfig {
|
||||
#[serde(default)]
|
||||
pub proxy: Option<String>,
|
||||
#[serde(default)]
|
||||
pub proxy_auth: Option<String>, // "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<String>,
|
||||
#[serde(default)]
|
||||
pub proxy_auth: Option<String>, // "user:pass" for basic auth
|
||||
#[serde(default = "default_timeout")]
|
||||
pub proxy_timeout: u64,
|
||||
}
|
||||
@@ -131,6 +135,37 @@ pub struct DockerUpstream {
|
||||
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
|
||||
#[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() {
|
||||
|
||||
@@ -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<Vec<u8>, ()> {
|
||||
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<u8>, 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");
|
||||
|
||||
@@ -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<String> {
|
||||
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<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 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<reqwest::Response, ()> {
|
||||
// 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
|
||||
|
||||
@@ -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<Arc<AppState>>, Path(path): Path<String>)
|
||||
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<Vec<u8>, ()> {
|
||||
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(());
|
||||
|
||||
@@ -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<Arc<AppState>>, Path(path): Path<Str
|
||||
if let Some(proxy_url) = &state.config.npm.proxy {
|
||||
let url = format!("{}/{}", proxy_url.trim_end_matches('/'), path);
|
||||
|
||||
if let Ok(data) =
|
||||
fetch_from_proxy(&state.http_client, &url, state.config.npm.proxy_timeout).await
|
||||
if let Ok(data) = fetch_from_proxy(
|
||||
&state.http_client,
|
||||
&url,
|
||||
state.config.npm.proxy_timeout,
|
||||
state.config.npm.proxy_auth.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
if is_tarball {
|
||||
state.metrics.record_download("npm");
|
||||
@@ -98,13 +104,14 @@ async fn fetch_from_proxy(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
timeout_secs: u64,
|
||||
auth: Option<&str>,
|
||||
) -> Result<Vec<u8>, ()> {
|
||||
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(());
|
||||
|
||||
@@ -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<String, ()> {
|
||||
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<Vec<u8>, ()> {
|
||||
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<Vec<u8>, ()> {
|
||||
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(());
|
||||
|
||||
@@ -205,7 +205,12 @@ pub async fn api_dashboard(State(state): State<Arc<AppState>>) -> Json<Dashboard
|
||||
MountPoint {
|
||||
registry: "Maven".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 {
|
||||
registry: "npm".to_string(),
|
||||
|
||||
Reference in New Issue
Block a user