Skip to content

Commit 6c88c30

Browse files
authored
feat: add proxy-config and activate https/http proxy for pixi commands (#3320)
1 parent 7eda83f commit 6c88c30

File tree

12 files changed

+239
-29
lines changed

12 files changed

+239
-29
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/pixi_build_frontend/src/tool/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ impl Tool {
9898
}
9999

100100
/// Construct a new command that enables invocation of the tool.
101+
/// TODO: whether to inject proxy config
101102
pub fn command(&self) -> std::process::Command {
102103
match self {
103104
Tool::Isolated(tool) => {

crates/pixi_config/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ rattler = { workspace = true }
2121
rattler_conda_types = { workspace = true }
2222
rattler_networking = { workspace = true, features = ["s3"] }
2323
rattler_repodata_gateway = { workspace = true, features = ["gateway"] }
24+
reqwest = { workspace = true }
2425
serde = { workspace = true }
2526
serde_ignored = { workspace = true }
2627
serde_json = { workspace = true }

crates/pixi_config/src/lib.rs

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use std::{
33
path::{Path, PathBuf},
44
process::{Command, Stdio},
55
str::FromStr,
6+
sync::LazyLock,
67
};
78

89
use clap::{ArgAction, Parser};
@@ -15,6 +16,7 @@ use rattler_conda_types::{
1516
};
1617
use rattler_networking::s3_middleware;
1718
use rattler_repodata_gateway::{Gateway, GatewayBuilder, SourceConfig};
19+
use reqwest::{NoProxy, Proxy};
1820
use serde::{de::IntoDeserializer, Deserialize, Serialize};
1921
use url::Url;
2022

@@ -55,6 +57,25 @@ pub fn get_default_author() -> Option<(String, String)> {
5557
Some((name?, email.unwrap_or_else(|| "".into())))
5658
}
5759

60+
// detect proxy env vars like curl: https://curl.se/docs/manpage.html
61+
static ENV_HTTP_PROXY: LazyLock<Option<String>> = LazyLock::new(|| {
62+
["http_proxy", "all_proxy", "ALL_PROXY"]
63+
.iter()
64+
.find_map(|&k| std::env::var(k).ok().filter(|v| !v.is_empty()))
65+
});
66+
static ENV_HTTPS_PROXY: LazyLock<Option<String>> = LazyLock::new(|| {
67+
["https_proxy", "HTTPS_PROXY", "all_proxy", "ALL_PROXY"]
68+
.iter()
69+
.find_map(|&k| std::env::var(k).ok().filter(|v| !v.is_empty()))
70+
});
71+
static ENV_NO_PROXY: LazyLock<Option<String>> = LazyLock::new(|| {
72+
["no_proxy", "NO_PROXY"]
73+
.iter()
74+
.find_map(|&k| std::env::var(k).ok().filter(|v| !v.is_empty()))
75+
});
76+
static USE_PROXY_FROM_ENV: LazyLock<bool> =
77+
LazyLock::new(|| (*ENV_HTTPS_PROXY).is_some() || (*ENV_HTTP_PROXY).is_some());
78+
5879
/// Get pixi home directory, default to `$HOME/.pixi`
5980
///
6081
/// It may be overridden by the `PIXI_HOME` environment variable.
@@ -651,6 +672,11 @@ pub struct Config {
651672
#[serde(skip_serializing_if = "ConcurrencyConfig::is_default")]
652673
pub concurrency: ConcurrencyConfig,
653674

675+
/// Https/Http proxy configuration for pixi
676+
#[serde(default)]
677+
#[serde(skip_serializing_if = "ProxyConfig::is_default")]
678+
pub proxy_config: ProxyConfig,
679+
654680
//////////////////////
655681
// Deprecated fields //
656682
//////////////////////
@@ -681,6 +707,7 @@ impl Default for Config {
681707
shell: ShellConfig::default(),
682708
experimental: ExperimentalConfig::default(),
683709
concurrency: ConcurrencyConfig::default(),
710+
proxy_config: ProxyConfig::default(),
684711

685712
// Deprecated fields
686713
change_ps1: None,
@@ -783,6 +810,40 @@ impl ShellConfig {
783810
}
784811
}
785812

813+
#[derive(Clone, Debug, Deserialize, Serialize, Default, PartialEq, Eq)]
814+
#[serde(rename_all = "kebab-case")]
815+
pub struct ProxyConfig {
816+
/// https proxy.
817+
#[serde(default)]
818+
#[serde(skip_serializing_if = "Option::is_none")]
819+
pub https: Option<Url>,
820+
/// http proxy.
821+
#[serde(default)]
822+
#[serde(skip_serializing_if = "Option::is_none")]
823+
pub http: Option<Url>,
824+
/// A list of no proxy pattern
825+
#[serde(default)]
826+
#[serde(skip_serializing_if = "Vec::is_empty")]
827+
pub non_proxy_hosts: Vec<String>,
828+
}
829+
830+
impl ProxyConfig {
831+
pub fn is_default(&self) -> bool {
832+
self.https.is_none() && self.https.is_none() && self.non_proxy_hosts.is_empty()
833+
}
834+
pub fn merge(&self, other: Self) -> Self {
835+
Self {
836+
https: other.https.as_ref().or(self.https.as_ref()).cloned(),
837+
http: other.http.as_ref().or(self.http.as_ref()).cloned(),
838+
non_proxy_hosts: if other.is_default() {
839+
self.non_proxy_hosts.clone()
840+
} else {
841+
other.non_proxy_hosts.clone()
842+
},
843+
}
844+
}
845+
}
846+
786847
#[derive(thiserror::Error, Debug)]
787848
pub enum ConfigError {
788849
#[error("no file was found at {0}")]
@@ -909,6 +970,23 @@ impl Config {
909970
.validate()
910971
.map_err(|e| ConfigError::ValidationError(e, path.to_path_buf()))?;
911972

973+
// check proxy config
974+
if config.proxy_config.https.is_none() && config.proxy_config.http.is_none() {
975+
if !config.proxy_config.non_proxy_hosts.is_empty() {
976+
tracing::warn!("proxy-config.non-proxy-hosts is not empty but will be ignored, as no https or http config is set.")
977+
}
978+
} else if *USE_PROXY_FROM_ENV {
979+
let config_no_proxy = Some(config.proxy_config.non_proxy_hosts.iter().join(","))
980+
.filter(|v| !v.is_empty());
981+
if (*ENV_HTTPS_PROXY).as_deref() != config.proxy_config.https.as_ref().map(Url::as_str)
982+
|| (*ENV_HTTP_PROXY).as_deref()
983+
!= config.proxy_config.http.as_ref().map(Url::as_str)
984+
|| *ENV_NO_PROXY != config_no_proxy
985+
{
986+
tracing::info!("proxy configs are overridden by proxy environment vars.")
987+
}
988+
}
989+
912990
Ok(config)
913991
}
914992

@@ -1047,6 +1125,10 @@ impl Config {
10471125
"s3-options.<bucket>.region",
10481126
"s3-options.<bucket>.force-path-style",
10491127
"experimental.use-environment-activation-cache",
1128+
"proxy-config",
1129+
"proxy-config.https",
1130+
"proxy-config.http",
1131+
"proxy-config.non-proxy-hosts",
10501132
]
10511133
}
10521134

@@ -1087,6 +1169,8 @@ impl Config {
10871169
// Make other take precedence over self to allow for setting the value through the CLI
10881170
concurrency: self.concurrency.merge(other.concurrency),
10891171

1172+
proxy_config: self.proxy_config.merge(other.proxy_config),
1173+
10901174
// Deprecated fields that we can ignore as we handle them inside `shell.` field
10911175
change_ps1: None,
10921176
force_activate: None,
@@ -1162,6 +1246,43 @@ impl Config {
11621246
self.concurrency.downloads
11631247
}
11641248

1249+
pub fn get_proxies(&self) -> reqwest::Result<Vec<Proxy>> {
1250+
if (self.proxy_config.https.is_none() && self.proxy_config.http.is_none())
1251+
|| *USE_PROXY_FROM_ENV
1252+
{
1253+
return Ok(vec![]);
1254+
}
1255+
1256+
let config_no_proxy =
1257+
Some(self.proxy_config.non_proxy_hosts.iter().join(",")).filter(|v| !v.is_empty());
1258+
1259+
let mut result: Vec<Proxy> = Vec::new();
1260+
let config_no_proxy: Option<NoProxy> =
1261+
config_no_proxy.as_deref().and_then(NoProxy::from_string);
1262+
1263+
if self.proxy_config.https == self.proxy_config.http {
1264+
result.push(
1265+
Proxy::all(
1266+
self.proxy_config
1267+
.https
1268+
.as_ref()
1269+
.expect("must be some")
1270+
.as_str(),
1271+
)?
1272+
.no_proxy(config_no_proxy),
1273+
);
1274+
} else {
1275+
if let Some(url) = &self.proxy_config.http {
1276+
result.push(Proxy::http(url.as_str())?.no_proxy(config_no_proxy.clone()));
1277+
}
1278+
if let Some(url) = &self.proxy_config.https {
1279+
result.push(Proxy::https(url.as_str())?.no_proxy(config_no_proxy));
1280+
}
1281+
}
1282+
1283+
Ok(result)
1284+
}
1285+
11651286
/// Modify this config with the given key and value
11661287
///
11671288
/// # Note
@@ -1426,6 +1547,42 @@ impl Config {
14261547
_ => return Err(err),
14271548
}
14281549
}
1550+
key if key.starts_with("proxy-config") => {
1551+
if key == "proxy-config" {
1552+
if let Some(value) = value {
1553+
self.proxy_config = serde_json::de::from_str(&value).into_diagnostic()?;
1554+
} else {
1555+
self.proxy_config = ProxyConfig::default();
1556+
}
1557+
return Ok(());
1558+
} else if !key.starts_with("proxy-config.") {
1559+
return Err(err);
1560+
}
1561+
1562+
let subkey = key.strip_prefix("proxy-config.").unwrap();
1563+
match subkey {
1564+
"https" => {
1565+
self.proxy_config.https = value
1566+
.map(|v| Url::parse(&v))
1567+
.transpose()
1568+
.into_diagnostic()?;
1569+
}
1570+
"http" => {
1571+
self.proxy_config.http = value
1572+
.map(|v| Url::parse(&v))
1573+
.transpose()
1574+
.into_diagnostic()?;
1575+
}
1576+
"non-proxy-hosts" => {
1577+
self.proxy_config.non_proxy_hosts = value
1578+
.map(|v| serde_json::de::from_str(&v))
1579+
.transpose()
1580+
.into_diagnostic()?
1581+
.unwrap_or_default();
1582+
}
1583+
_ => return Err(err),
1584+
}
1585+
}
14291586
_ => return Err(err),
14301587
}
14311588

@@ -1728,6 +1885,7 @@ UNUSED = "unused"
17281885
RepodataChannelConfig::default(),
17291886
)]),
17301887
},
1888+
proxy_config: ProxyConfig::default(),
17311889
// Deprecated keys
17321890
change_ps1: None,
17331891
force_activate: None,
@@ -2148,4 +2306,25 @@ UNUSED = "unused"
21482306
assert_eq!(anaconda_config.disable_zstd, Some(false));
21492307
assert_eq!(anaconda_config.disable_sharded, None);
21502308
}
2309+
2310+
#[test]
2311+
fn test_proxy_config_parse() {
2312+
let toml = r#"
2313+
[proxy-config]
2314+
https = "http://proxy-for-https"
2315+
http = "http://proxy-for-http"
2316+
non-proxy-hosts = [ "a.com" ]
2317+
"#;
2318+
let (config, _) = Config::from_toml(toml, None).unwrap();
2319+
assert_eq!(
2320+
config.proxy_config.https,
2321+
Some(Url::parse("http://proxy-for-https").unwrap())
2322+
);
2323+
assert_eq!(
2324+
config.proxy_config.http,
2325+
Some(Url::parse("http://proxy-for-http").unwrap())
2326+
);
2327+
assert_eq!(config.proxy_config.non_proxy_hosts.len(), 1);
2328+
assert_eq!(config.proxy_config.non_proxy_hosts[0], "a.com");
2329+
}
21512330
}

crates/pixi_config/src/snapshots/pixi_config__tests__config_merge_multiple.snap

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,11 @@ Config {
133133
solves: 1,
134134
downloads: 50,
135135
},
136+
proxy_config: ProxyConfig {
137+
https: None,
138+
http: None,
139+
non_proxy_hosts: [],
140+
},
136141
change_ps1: None,
137142
force_activate: None,
138143
}

crates/pixi_utils/src/reqwest.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,14 +117,18 @@ pub fn build_reqwest_clients(
117117
}
118118

119119
let timeout = 5 * 60;
120-
let client = Client::builder()
120+
let mut builder = Client::builder()
121121
.pool_max_idle_per_host(20)
122122
.user_agent(app_user_agent)
123123
.danger_accept_invalid_certs(config.tls_no_verify())
124124
.read_timeout(Duration::from_secs(timeout))
125-
.use_rustls_tls()
126-
.build()
127-
.expect("failed to create reqwest Client");
125+
.use_rustls_tls();
126+
127+
for p in config.get_proxies().into_diagnostic()? {
128+
builder = builder.proxy(p);
129+
}
130+
131+
let client = builder.build().expect("failed to create reqwest Client");
128132

129133
let mut client_builder = ClientBuilder::new(client.clone());
130134

pixi_docs/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/cli/self_update.rs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use flate2::read::GzDecoder;
44
use tar::Archive;
55

66
use miette::IntoDiagnostic;
7+
use pixi_config::Config;
78
use pixi_consts::consts;
89
use reqwest::redirect::Policy;
910
use reqwest::Client;
@@ -59,10 +60,11 @@ async fn latest_version() -> miette::Result<Version> {
5960
let url = format!("{}/latest", consts::RELEASES_URL);
6061

6162
// Create a client with a redirect policy
62-
let no_redirect_client = Client::builder()
63-
.redirect(Policy::none()) // Prevent automatic redirects
64-
.build()
65-
.into_diagnostic()?;
63+
let mut no_redirect_client_builder = Client::builder().redirect(Policy::none()); // Prevent automatic redirects
64+
for p in Config::load_global().get_proxies().into_diagnostic()? {
65+
no_redirect_client_builder = no_redirect_client_builder.proxy(p);
66+
}
67+
let no_redirect_client = no_redirect_client_builder.build().into_diagnostic()?;
6668

6769
let version: String = match no_redirect_client
6870
.head(&url)
@@ -170,6 +172,7 @@ pub async fn execute(args: Args) -> miette::Result<()> {
170172
// Create a temp file to download the archive
171173
let mut archived_tempfile = tempfile::NamedTempFile::new().into_diagnostic()?;
172174

175+
// TODO proxy inject in https://github.com/prefix-dev/pixi/pull/3346
173176
let client = Client::new();
174177
let mut res = client
175178
.get(&download_url)

src/cli/upload.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ use thiserror::Error;
1515
use tokio::fs::File;
1616
use tokio_util::io::ReaderStream;
1717

18+
use pixi_config::Config;
1819
use pixi_progress;
1920

2021
#[allow(rustdoc::bare_urls)]
@@ -53,7 +54,12 @@ pub async fn execute(args: Args) -> miette::Result<()> {
5354
HumanBytes(filesize)
5455
);
5556

56-
let client = reqwest_middleware::ClientBuilder::new(reqwest::Client::new())
57+
let mut raw_builder = reqwest::Client::builder();
58+
for p in Config::load_global().get_proxies().into_diagnostic()? {
59+
raw_builder = raw_builder.proxy(p);
60+
}
61+
62+
let client = reqwest_middleware::ClientBuilder::new(raw_builder.build().into_diagnostic()?)
5763
.with_arc(Arc::new(
5864
AuthenticationMiddleware::from_env_and_defaults().into_diagnostic()?,
5965
))

0 commit comments

Comments
 (0)