1use super::intermediates::{
8 build_chain_config_uri, build_intermediates, validate_pproxy_plugins, TranslationIntermediates,
9};
10use super::model::MatchToml;
11use crate::args::PproxyArgs;
12use crate::uri::{PproxyChain, PproxyUri};
13
14pub fn compile_chain_to_native(
24 chain: &PproxyChain,
25) -> Result<eggress_uri::ProxyChainSpec, crate::error::CompatError> {
26 use crate::error::CompatError;
27
28 if chain.hops.iter().any(|hop| hop.is_backward()) {
29 return Err(CompatError::UnsupportedFeature {
30 feature: "backward-upstream",
31 detail: format!(
32 "pproxy chain '{}' uses a backward (+in) role which cannot execute outbound",
33 chain.redacted_display()
34 ),
35 });
36 }
37
38 let unsupported = crate::uri::validate_chain_hops(chain);
39 if !unsupported.is_empty() {
40 let roles = unsupported
41 .iter()
42 .map(|(_, scheme)| scheme.clone())
43 .collect::<Vec<_>>()
44 .join(", ");
45 return Err(CompatError::UnsupportedFeature {
46 feature: "chain-unsupported-hop",
47 detail: format!(
48 "pproxy chain '{}' contains unsupported hop role(s): {}",
49 chain.redacted_display(),
50 roles
51 ),
52 });
53 }
54
55 for hop in &chain.hops {
56 if let Some(bind) = hop.local_bind.as_deref() {
57 if hop.scheme == "unix" {
58 return Err(CompatError::UnsupportedFeature {
59 feature: "local-bind",
60 detail: format!(
61 "local bind '{bind}' cannot be applied to Unix upstream '{}'",
62 hop.redacted_display()
63 ),
64 });
65 }
66 if bind.parse::<std::net::IpAddr>().is_err() {
67 return Err(CompatError::UnsupportedFeature {
68 feature: "local-bind",
69 detail: format!(
70 "local bind '{bind}' must be an IP address for upstream '{}'",
71 hop.redacted_display()
72 ),
73 });
74 }
75 }
76 if let Err(error) = validate_pproxy_plugins(&hop.plugins) {
77 return Err(CompatError::UnsupportedFeature {
78 feature: "plugin",
79 detail: error,
80 });
81 }
82 if hop.scheme == "ssh" {
88 if cfg!(feature = "ssh") {
89 } else {
91 return Err(CompatError::UnsupportedFeature {
92 feature: "ssh-upstream",
93 detail: format!(
94 "SSH upstream '{}': SSH transport is not supported",
95 hop.redacted_display()
96 ),
97 });
98 }
99 } else if hop.scheme == "redir" {
100 return Err(CompatError::UnsupportedFeature {
101 feature: "redir-upstream",
102 detail: format!(
103 "Redir upstream '{}': transparent proxy redirect is not supported as upstream",
104 hop.redacted_display()
105 ),
106 });
107 } else if hop.scheme == "unix" {
108 } else if hop.scheme == "quic+http" || hop.scheme == "http+quic" {
110 debug_assert!(hop
112 .protocol_chain
113 .iter()
114 .all(|t| eggress_uri::ProtocolSpec::parse_name(t).is_some()));
115 } else if hop.protocol_chain.len() == 1 {
116 let token = hop.protocol_chain[0].as_str();
117 if eggress_uri::ProtocolSpec::parse_name(token).is_some() {
118 } else {
120 match token {
122 "https" | "direct" => {}
123 other => {
124 return Err(CompatError::UnsupportedFeature {
125 feature: "scheme",
126 detail: format!("unknown scheme '{other}' in upstream URI"),
127 });
128 }
129 }
130 }
131 } else {
132 return Err(CompatError::UnsupportedFeature {
133 feature: "scheme",
134 detail: format!("unknown scheme '{}' in upstream URI", hop.scheme),
135 });
136 }
137 }
138
139 let native_uri = build_chain_config_uri(chain);
140 eggress_uri::parse_proxy_chain(&native_uri).map_err(|error| CompatError::ConfigValidation {
141 message: format!(
142 "native chain parse failed for '{}': {error}",
143 chain.redacted_display()
144 ),
145 })
146}
147
148#[derive(Debug, Clone)]
154pub struct NativeTranslation {
155 pub runtime: eggress_config::compile::RuntimeConfig,
157 pub issues: Vec<crate::issues::CompatIssue>,
159}
160
161impl NativeTranslation {
169 pub fn has_unsupported(&self) -> bool {
171 use crate::issues::IssueSeverity;
172 self.issues
173 .iter()
174 .any(|i| i.severity == IssueSeverity::Unsupported)
175 }
176
177 pub fn warnings(&self) -> Vec<crate::warnings::CompatWarning> {
179 self.issues.iter().filter_map(|i| i.to_warning()).collect()
180 }
181
182 pub fn unsupported(&self) -> Vec<crate::warnings::UnsupportedFeature> {
184 self.issues
185 .iter()
186 .filter_map(|i| i.to_unsupported())
187 .collect()
188 }
189}
190
191pub fn translate_to_runtime_config(
192 args: &PproxyArgs,
193 local_uris: &[PproxyUri],
194 remote_chains: &[PproxyChain],
195) -> Result<NativeTranslation, crate::error::CompatError> {
196 let (intermediates, output) = build_intermediates(args, local_uris, remote_chains)?;
197 let config_file = intermediates_to_config_file(&intermediates);
198 eggress_config::validate::validate_config(&config_file).map_err(|errors| {
202 let messages: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
203 crate::error::CompatError::ConfigValidation {
204 message: messages.join("; "),
205 }
206 })?;
207 let runtime = eggress_config::compile::compile_config(&config_file).map_err(|error| {
208 crate::error::CompatError::ConfigValidation {
209 message: format!("native compile of translated config failed: {error}"),
210 }
211 })?;
212 Ok(NativeTranslation {
213 runtime,
214 issues: output.issues,
215 })
216}
217
218pub(crate) fn intermediates_to_config_file(
226 intermediates: &TranslationIntermediates,
227) -> eggress_config::model::ConfigFile {
228 use eggress_config::model;
229
230 let listeners = intermediates
231 .listeners
232 .iter()
233 .map(|listener| model::ListenerConfig {
234 name: listener.name.clone(),
235 bind: listener.bind.clone(),
236 protocols: listener.protocols.clone(),
237 reuse_port: listener.reuse_port,
238 connection_limit: None,
239 auth: listener.auth.as_ref().map(|auth| model::AuthConfig {
240 auth_type: auth.r#type.clone(),
241 username: auth.username.clone(),
242 password: auth.password.clone(),
243 password_env: None,
244 }),
245 udp_enabled: None,
246 udp: listener.udp.as_ref().map(|udp| model::ListenerUdpConfig {
247 enabled: None,
248 mode: udp.mode.clone(),
249 bind: udp.bind.clone(),
250 advertise: None,
251 idle_timeout: None,
252 target_idle_timeout: None,
253 max_associations: None,
254 max_targets_per_association: None,
255 max_datagram_size: None,
256 client_pin: None,
257 allow_private_egress: None,
258 max_associations_global: None,
259 fixed_target: udp.fixed_target.clone(),
260 upstream_connect_timeout: None,
261 upstream_udp_bind: None,
262 }),
263 tls: listener.tls.as_ref().map(|tls| model::ListenerTlsConfig {
264 cert: tls.cert.clone(),
265 key: tls.key.clone().unwrap_or_default(),
269 alpn: tls.alpn.clone(),
270 }),
271 shadowsocks: listener
272 .shadowsocks
273 .as_ref()
274 .map(|ss| model::ShadowsocksListenerConfig {
275 method: ss.method.clone(),
276 password: ss.password.clone(),
277 auth_prefix: None,
278 plugins: Vec::new(),
279 }),
280 ssr: listener.ssr.as_ref().map(|ssr| model::SsrListenerConfig {
281 auth_prefix: ssr.auth_prefix.clone(),
282 plugins: ssr.plugins.clone(),
283 }),
284 trojan: listener
285 .trojan
286 .as_ref()
287 .map(|trojan| model::ListenerTrojanConfig {
288 password: trojan.password.clone(),
289 fallback: None,
290 }),
291 transparent: listener.transparent.as_ref().map(|transparent| {
292 model::TransparentConfig {
293 enabled: Some(transparent.enabled),
294 protocol: Some(transparent.protocol.clone()),
295 }
296 }),
297 unix: listener
298 .unix
299 .as_ref()
300 .map(|unix| model::UnixListenerConfig {
301 path: unix.path.clone(),
302 unlink_existing: Some(unix.unlink_existing),
303 mode: None,
304 }),
305 fixed_target: listener.fixed_target.clone(),
306 local_bind: listener.local_bind.clone(),
307 })
308 .collect();
309
310 let upstreams = intermediates
311 .upstreams
312 .iter()
313 .map(|upstream| model::UpstreamConfig {
314 id: upstream.id.clone(),
315 uri: upstream.uri.clone(),
316 health: upstream
317 .health
318 .as_ref()
319 .map(|health| model::HealthConfigToml {
320 mode: None,
321 interval: Some(health.interval.clone()),
322 timeout: None,
323 failures_to_unhealthy: None,
324 successes_to_healthy: None,
325 initial_state: None,
326 }),
327 h2: None,
328 })
329 .collect();
330
331 let upstream_groups = intermediates
332 .upstream_groups
333 .iter()
334 .map(|group| model::UpstreamGroupConfig {
335 id: group.id.clone(),
336 scheduler: Some(group.scheduler.clone()),
337 members: group.members.clone(),
338 fallback: Some(group.fallback.clone()),
339 })
340 .collect();
341
342 fn convert_match(match_toml: &MatchToml) -> model::MatchExprConfig {
343 use eggress_config::model;
344 if !match_toml.any_of.is_empty() {
345 let any_of = match_toml.any_of.iter().map(convert_match).collect();
346 model::MatchExprConfig::Composite(model::CompositeMatcher {
347 all: None,
348 any_of: Some(any_of),
349 not: None,
350 })
351 } else {
352 model::MatchExprConfig::Leaf(Box::new(model::LeafMatcher {
353 host_exact: None,
354 host_suffix: None,
355 host_regex: match_toml.host_regex.clone(),
356 destination_port_regex: match_toml.destination_port_regex.clone(),
357 destination_port: None,
358 destination_port_range: None,
359 destination_port_set: None,
360 destination_cidr: None,
361 source_cidr: None,
362 source_port: None,
363 listener: None,
364 protocol: None,
365 identity: None,
366 transport: match_toml.transport.clone(),
367 reverse_listener: None,
368 }))
369 }
370 }
371
372 let rules = intermediates
373 .rules
374 .iter()
375 .map(|rule| model::RuleConfig {
376 id: rule.id.clone(),
377 host_exact: None,
378 host_suffix: None,
379 host_regex: rule.host_regex.clone(),
380 destination_port_regex: None,
381 destination_port: None,
382 any: Some(rule.any),
383 match_expr: rule.r#match.as_ref().map(convert_match),
384 direct: rule.direct,
385 upstream_group: if rule.upstream_group.is_empty() {
386 None
387 } else {
388 Some(rule.upstream_group.clone())
389 },
390 reject: rule.reject.clone(),
391 })
392 .collect();
393
394 let reverse_servers = intermediates
395 .reverse_servers
396 .iter()
397 .map(|server| model::ReverseServerConfig {
398 id: server.id.clone(),
399 control_bind: server.control_bind.clone(),
400 external_bind: server.external_bind.clone(),
401 auth_username: server.auth_username.clone(),
402 auth_password: server.auth_password.clone(),
403 auth_password_env: None,
404 max_streams: None,
405 heartbeat_interval: None,
406 pproxy_compat: server.pproxy_compat,
407 tls: None,
408 })
409 .collect();
410
411 let reverse_clients = intermediates
412 .reverse_clients
413 .iter()
414 .map(|client| model::ReverseClientConfig {
415 id: client.id.clone(),
416 server_addr: client.server_addr.clone(),
417 server_uri: client.server_uri.clone(),
418 auth_username: client.auth_username.clone(),
419 auth_password: client.auth_password.clone(),
420 auth_password_env: None,
421 reconnect_initial: None,
422 reconnect_max: None,
423 heartbeat_interval: None,
424 parallel_connections: client.parallel_connections,
425 default_target_host: None,
426 default_target_port: None,
427 pproxy_compat: client.pproxy_compat,
428 tls: None,
429 })
430 .collect();
431
432 let admin = if intermediates.pac_enabled || !intermediates.static_content.is_empty() {
433 let proxy = intermediates
436 .listeners
437 .first()
438 .map(|l| l.bind.clone())
439 .unwrap_or_else(|| "127.0.0.1:8080".to_string());
440 Some(model::AdminConfig {
441 bind: None,
442 enabled: None,
443 metrics: None,
444 auth: None,
445 pac: Some(model::PacConfigToml {
446 path: intermediates.pac_path.clone(),
447 proxy,
448 direct_fallback: Some(true),
449 direct_hosts: None,
450 direct_suffixes: None,
451 }),
452 static_content: Some(
453 intermediates
454 .static_content
455 .iter()
456 .map(|content| model::StaticContentToml {
457 path: content.path.clone(),
458 content_type: None,
459 body: Some(content.body.clone()),
460 })
461 .collect(),
462 ),
463 })
464 } else {
465 None
466 };
467
468 model::ConfigFile {
469 version: Some(1),
470 process: None,
471 timeouts: None,
472 listeners: Some(listeners),
473 upstreams: Some(upstreams),
474 upstream_groups: Some(upstream_groups),
475 rules: Some(rules),
476 rules_file: None,
477 routing: None,
478 admin,
479 reverse_servers: Some(reverse_servers),
480 reverse_clients: Some(reverse_clients),
481 }
482}