mirror of
https://github.com/telemt/telemt.git
synced 2026-10-29 03:57:15 +03:00
bc1c130f38
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
205 lines
7.5 KiB
Rust
205 lines
7.5 KiB
Rust
use std::future::Future;
|
|
use std::time::Duration;
|
|
|
|
use super::*;
|
|
|
|
/// Rejects mode-specific keys even when unknown-key handling is permissive.
|
|
pub(super) fn validate_mode_keys(document: &toml::Value) -> Result<()> {
|
|
if let Some(vhosts) = document
|
|
.get("web")
|
|
.and_then(|web| web.get("vhosts"))
|
|
.and_then(toml::Value::as_array)
|
|
{
|
|
for (idx, vhost) in vhosts.iter().enumerate() {
|
|
if let Some(decoy) = vhost.get("decoy")
|
|
&& decoy.get("resolve").is_some()
|
|
&& decoy.get("mode").and_then(toml::Value::as_str) != Some("http_upstream")
|
|
{
|
|
return Err(ProxyError::Config(format!(
|
|
"web.vhosts[{idx}].decoy.resolve is only valid for mode=http_upstream"
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Parses one origin and preserves the existing literal-address containment policy.
|
|
pub(super) fn parse_origin(
|
|
idx: usize,
|
|
upstream: &str,
|
|
resolve: WebDecoyResolve,
|
|
) -> Result<url::Url> {
|
|
let parsed = url::Url::parse(upstream).map_err(|error| {
|
|
ProxyError::Config(format!(
|
|
"web.vhosts[{idx}].decoy.upstream is invalid: {error}"
|
|
))
|
|
})?;
|
|
if parsed.scheme() != "http"
|
|
|| parsed.host_str().is_none()
|
|
|| !parsed.username().is_empty()
|
|
|| parsed.password().is_some()
|
|
|| parsed.query().is_some()
|
|
|| parsed.fragment().is_some()
|
|
|| parsed.path() != "/"
|
|
|| parsed.port() == Some(0)
|
|
{
|
|
return Err(ProxyError::Config(format!(
|
|
"web.vhosts[{idx}].decoy.upstream must be an http origin without credentials, path, query, or fragment"
|
|
)));
|
|
}
|
|
match parsed.host() {
|
|
Some(url::Host::Ipv4(ip)) => validate_address(idx, IpAddr::V4(ip))?,
|
|
Some(url::Host::Ipv6(ip)) => validate_address(idx, IpAddr::V6(ip))?,
|
|
Some(url::Host::Domain(_)) if resolve == WebDecoyResolve::Startup => {}
|
|
_ => {
|
|
return Err(ProxyError::Config(format!(
|
|
"web.vhosts[{idx}].decoy.upstream host must be a loopback or private IP literal unless resolve = \"startup\""
|
|
)));
|
|
}
|
|
}
|
|
Ok(parsed)
|
|
}
|
|
|
|
fn validate_address(idx: usize, ip: IpAddr) -> Result<()> {
|
|
let private = match ip {
|
|
IpAddr::V4(ip) => ip.is_loopback() || ip.is_private() || ip.is_link_local(),
|
|
IpAddr::V6(ip) => ip.is_loopback() || ip.is_unique_local() || ip.is_unicast_link_local(),
|
|
};
|
|
if !private {
|
|
return Err(ProxyError::Config(format!(
|
|
"web.vhosts[{idx}].decoy.upstream must remain inside loopback or a private network"
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Returns all captured answers so non-selected addresses cannot hide a listener loop.
|
|
pub(super) fn addresses(
|
|
config: &ProxyConfig,
|
|
idx: usize,
|
|
parsed: &url::Url,
|
|
require_resolved: bool,
|
|
) -> Result<Vec<SocketAddr>> {
|
|
let port = parsed
|
|
.port_or_known_default()
|
|
.ok_or_else(|| ProxyError::Config("WEB decoy port cannot be resolved".to_string()))?;
|
|
let answers = match parsed.host() {
|
|
Some(url::Host::Ipv4(ip)) => vec![SocketAddr::new(IpAddr::V4(ip), port)],
|
|
Some(url::Host::Ipv6(ip)) => vec![SocketAddr::new(IpAddr::V6(ip), port)],
|
|
Some(url::Host::Domain(host)) => {
|
|
let key = (host.to_string(), port);
|
|
match config.web.decoy_dns.origins.get(&key) {
|
|
Some(answers) if !answers.is_empty() => answers.clone(),
|
|
_ if !require_resolved => return Ok(Vec::new()),
|
|
_ => {
|
|
return Err(ProxyError::Config(format!(
|
|
"web.vhosts[{idx}].decoy.upstream requires async preparation for resolve = \"startup\""
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
_ => return Err(ProxyError::Config("WEB decoy host is missing".to_string())),
|
|
};
|
|
for addr in &answers {
|
|
validate_address(idx, addr.ip())?;
|
|
if addr.port() != port {
|
|
return Err(ProxyError::Config(
|
|
"WEB decoy DNS snapshot port mismatch".to_string(),
|
|
));
|
|
}
|
|
}
|
|
Ok(answers)
|
|
}
|
|
|
|
/// Captures fresh resolver evidence without publishing any partially prepared configuration.
|
|
pub(super) async fn prepare<F, Fut>(config: &mut ProxyConfig, mut lookup: F) -> Result<()>
|
|
where
|
|
F: FnMut(String, u16) -> Fut,
|
|
Fut: Future<Output = std::io::Result<Vec<SocketAddr>>>,
|
|
{
|
|
let mut snapshot = WebDecoyDnsSnapshot::default();
|
|
for (idx, vhost) in config.web.vhosts.iter().enumerate() {
|
|
let WebDecoyConfig::HttpUpstream { upstream, resolve } = &vhost.decoy else {
|
|
continue;
|
|
};
|
|
let parsed = parse_origin(idx, upstream, *resolve)?;
|
|
let Some(url::Host::Domain(host)) = parsed.host() else {
|
|
continue;
|
|
};
|
|
let port = parsed
|
|
.port_or_known_default()
|
|
.ok_or_else(|| ProxyError::Config("WEB decoy port cannot be resolved".to_string()))?;
|
|
let key = (host.to_string(), port);
|
|
if snapshot.origins.contains_key(&key) {
|
|
continue;
|
|
}
|
|
// Dropping this wait does not forcibly cancel a running OS resolver call.
|
|
let answers = tokio::time::timeout(
|
|
Duration::from_secs(config.web.timeouts.decoy_resolve_secs),
|
|
lookup(key.0.clone(), port),
|
|
)
|
|
.await
|
|
.map_err(|_| {
|
|
ProxyError::Config(format!(
|
|
"web.vhosts[{idx}].decoy.upstream DNS resolution timed out after {} seconds",
|
|
config.web.timeouts.decoy_resolve_secs
|
|
))
|
|
})?
|
|
.map_err(|error| {
|
|
ProxyError::Config(format!(
|
|
"web.vhosts[{idx}].decoy.upstream DNS resolution failed: {error}"
|
|
))
|
|
})?;
|
|
if answers.is_empty() {
|
|
return Err(ProxyError::Config(format!(
|
|
"web.vhosts[{idx}].decoy.upstream DNS resolution returned no addresses"
|
|
)));
|
|
}
|
|
for addr in &answers {
|
|
validate_address(idx, addr.ip())?;
|
|
}
|
|
snapshot.origins.insert(key, answers);
|
|
}
|
|
config.web.decoy_dns = Arc::new(snapshot);
|
|
validate_web::validate_decoy_listener_separation(config)
|
|
}
|
|
|
|
/// Builds the unchanged request-time tuple from immutable configuration evidence.
|
|
pub(super) fn build_upstream(
|
|
config: &ProxyConfig,
|
|
idx: usize,
|
|
upstream: &str,
|
|
resolve: WebDecoyResolve,
|
|
) -> Result<WebRuntimeDecoy> {
|
|
let parsed = parse_origin(idx, upstream, resolve)?;
|
|
let addr = addresses(config, idx, &parsed, true)?
|
|
.first()
|
|
.copied()
|
|
.ok_or_else(|| ProxyError::Config("WEB decoy DNS snapshot is empty".to_string()))?;
|
|
// URL authority keeps the configured hostname and brackets IPv6 literals.
|
|
let authority = parsed[url::Position::BeforeHost..url::Position::AfterPort].to_string();
|
|
Ok(WebRuntimeDecoy::HttpUpstream { addr, authority })
|
|
}
|
|
|
|
impl ProxyConfig {
|
|
/// Compares selected endpoints separately from source-only configuration equality.
|
|
pub(crate) fn web_decoy_endpoints_equal(&self, other: &Self) -> bool {
|
|
fn selected(config: &ProxyConfig) -> impl Iterator<Item = (&String, SocketAddr)> {
|
|
config
|
|
.web
|
|
.runtime
|
|
.iter()
|
|
.flat_map(|runtime| runtime.vhosts.iter())
|
|
.filter_map(|(host, vhost)| match vhost.decoy {
|
|
WebRuntimeDecoy::HttpUpstream { addr, .. } => Some((host, addr)),
|
|
WebRuntimeDecoy::StaticDirectory(_) => None,
|
|
})
|
|
}
|
|
selected(self).eq(selected(other))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|