Skip to main content

eggress_pproxy_compat/translate/
native.rs

1//! Native compilation: intermediates become `ConfigFile`/`RuntimeConfig` and
2//! native [`eggress_uri::ProxyChainSpec`] chains without a TOML round trip.
3//!
4//! Field-for-field agreement with the TOML renderer keeps the two paths
5//! equivalent (see `native_equivalence` tests).
6
7use 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
14/// Direct native compilation of a pproxy chain to a native [`eggress_uri::ProxyChainSpec`]
15/// without TOML serialization.
16///
17/// This is the canonical outbound path: semantic translation produces a typed
18/// native chain, while TOML rendering remains only for `--dump-config` /
19/// migration / debugging. Validation (backward roles, unsupported hops,
20/// plugins, local-bind, schemes) mirrors `translate_from_uris` remote handling
21/// so direct and TOML-render/reparse paths agree on warnings/unsupported and
22/// redaction.
23pub 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        // Native-capable single tokens delegate to the canonical
83        // `ProtocolSpec::parse_name` path; only compat-specific outbound
84        // tokens and the two historical combined `quic+http` forms stay
85        // explicit here. This preserves the exact accepted set while removing
86        // the independent native whitelist.
87        if hop.scheme == "ssh" {
88            if cfg!(feature = "ssh") {
89                // accepted below
90            } 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            // accepted below
109        } else if hop.scheme == "quic+http" || hop.scheme == "http+quic" {
110            // Historical combined upstream form; both tokens are native.
111            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                // native-capable single token
119            } else {
120                // Explicit compat-only outbound tokens (never added to native).
121                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/// Native translation result for service/listener mode.
149///
150/// Semantic translation produces this typed result; TOML rendering
151/// (`translate_from_uris` / `TranslationOutput.toml`) is a presentation of the
152/// same intermediates for `--dump-config` / migration / debugging.
153#[derive(Debug, Clone)]
154pub struct NativeTranslation {
155    /// Compiled runtime configuration ready for embed/runtime startup.
156    pub runtime: eggress_config::compile::RuntimeConfig,
157    /// Canonical typed findings, identical to the TOML-render path.
158    pub issues: Vec<crate::issues::CompatIssue>,
159}
160
161/// Direct native compilation for listener/service mode without TOML string.
162///
163/// Builds the same intermediates as `translate_from_uris` via the shared
164/// `build_intermediates` builder, converts them to `ConfigFile` via direct
165/// struct mapping (no `toml::to_string` / `toml::from_str` round trip), then
166/// validates and compiles to `RuntimeConfig`. Returns warnings/unsupported
167/// identical to the TOML path.
168impl NativeTranslation {
169    /// Whether translation has no blockers.
170    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    /// Legacy warning view: all `Warning`-severity issues, in order.
178    pub fn warnings(&self) -> Vec<crate::warnings::CompatWarning> {
179        self.issues.iter().filter_map(|i| i.to_warning()).collect()
180    }
181
182    /// Legacy unsupported view: all `Unsupported`-severity issues, in order.
183    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    // Same validation boundary as file-backed startup: structural validate
199    // then compile. Security warnings are not needed for internal consumers;
200    // translator warnings/unsupported are already in `output`.
201    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
218/// Convert shared intermediates to a native `ConfigFile` without TOML string.
219///
220/// Field-for-field mapping preserves the exact semantics of the TOML renderer
221/// (`generate_toml`): omitted TOML keys become `None`, empty upstream-group
222/// strings become `None`, `any = false` stays explicit, and match trees are
223/// rebuilt recursively. Any divergence surfaces as a validation/compile error
224/// here rather than silent behavior change.
225pub(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                // TOML renderer omits missing keys; model requires `key`.
266                // Translators only emit TLS when `--ssl` supplied cert+key, so
267                // empty here would fail compile exactly as TOML parse would.
268                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        // Keep field-for-field agreement with the TOML renderer: bare
434        // host:port from the first listener bind.
435        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}