mirror of
https://github.com/telemt/telemt.git
synced 2026-11-01 05:12:02 +03:00
417 lines
15 KiB
Rust
417 lines
15 KiB
Rust
#![allow(deprecated)]
|
|
|
|
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
|
use std::net::{IpAddr, SocketAddr};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
|
|
use rand::RngExt;
|
|
use serde::{Deserialize, Serialize};
|
|
use tracing::warn;
|
|
|
|
use crate::crypto::sha256;
|
|
use crate::error::{ProxyError, Result};
|
|
|
|
use super::defaults::*;
|
|
use super::types::*;
|
|
|
|
// Domain names, mask targets, and legacy scalar normalization helpers.
|
|
mod normalize;
|
|
// Include preprocessing and rendered config metadata helpers.
|
|
mod includes;
|
|
// Strict-config unknown key detection and suggestions.
|
|
mod strict_keys;
|
|
// Precomputed user authentication data for handshake hot paths.
|
|
mod runtime_auth;
|
|
// Validated immutable WEB configuration and static-site snapshots.
|
|
mod runtime_web;
|
|
// Post-deserialization validation helpers.
|
|
mod decode;
|
|
mod effective;
|
|
mod pipeline;
|
|
// Candidate-owned DNS preparation is separate from pure runtime reconstruction.
|
|
mod decoy_dns;
|
|
mod validate_core;
|
|
mod validate_me;
|
|
mod validate_runtime;
|
|
mod validate_server;
|
|
mod validate_web;
|
|
mod validation;
|
|
|
|
use self::includes::{
|
|
hash_rendered_snapshot, normalize_config_path, preprocess_includes, read_config_source,
|
|
};
|
|
use self::normalize::{
|
|
is_valid_ad_tag, is_valid_tls_domain_name, normalize_domain_to_ascii,
|
|
normalize_exclusive_mask_target, normalize_mask_host_to_ascii, parse_exclusive_mask_target,
|
|
push_unique_nonempty, sanitize_ad_tag,
|
|
};
|
|
pub(crate) use self::runtime_auth::UserAuthSnapshot;
|
|
use self::strict_keys::handle_unknown_config_keys;
|
|
use self::validation::{
|
|
normalize_upstream_family_policy, validate_listener_runtime_profiles, validate_logging_config,
|
|
validate_network_cfg, validate_upstreams,
|
|
};
|
|
|
|
const MAX_ME_WRITER_CMD_CHANNEL_CAPACITY: usize = 16_384;
|
|
const MAX_ME_WRITER_BYTE_BUDGET_BYTES: usize = 256 * 1024 * 1024;
|
|
const MAX_ME_ROUTE_CHANNEL_CAPACITY: usize = 8_192;
|
|
const MAX_ME_C2ME_CHANNEL_CAPACITY: usize = 8_192;
|
|
const MIN_DIRECT_RELAY_BUFFER_BUDGET_BYTES: usize = 16 * 1024 * 1024;
|
|
const MAX_DIRECT_RELAY_BUFFER_BUDGET_BYTES: usize = 2 * 1024 * 1024 * 1024;
|
|
const MIN_MAX_CLIENT_FRAME_BYTES: usize = 4 * 1024;
|
|
const MAX_MAX_CLIENT_FRAME_BYTES: usize = 16 * 1024 * 1024;
|
|
const MAX_API_REQUEST_BODY_LIMIT_BYTES: usize = 1024 * 1024;
|
|
|
|
#[derive(Debug, Clone)]
|
|
/// Validated config plus the exact recursive source snapshot used to build it.
|
|
pub(crate) struct LoadedConfig {
|
|
/// Validated and normalized effective configuration.
|
|
pub(crate) config: ProxyConfig,
|
|
/// Normalized absolute paths participating in the recursive include graph.
|
|
pub(crate) source_files: Vec<PathBuf>,
|
|
/// Raw source bytes keyed by normalized absolute source path.
|
|
pub(crate) source_contents: BTreeMap<PathBuf, String>,
|
|
/// Legacy hash of the include-expanded rendered snapshot.
|
|
pub(crate) rendered_hash: u64,
|
|
}
|
|
|
|
/// Normalized source configuration that has not acquired runtime DNS or static snapshots.
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct ParsedConfigSource {
|
|
/// Source-only configuration; must be prepared before runtime publication.
|
|
pub(crate) config: ProxyConfig,
|
|
/// Normalized paths in the captured include graph.
|
|
pub(crate) source_files: Vec<PathBuf>,
|
|
/// Exact source documents used for revisions and atomic mutation checks.
|
|
pub(crate) source_contents: BTreeMap<PathBuf, String>,
|
|
/// Source-only hash, independent of DNS answers.
|
|
pub(crate) rendered_hash: u64,
|
|
}
|
|
|
|
/// Raw recursive source graph captured before typed deserialization.
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct ConfigSourceGraph {
|
|
/// Raw source bytes keyed by normalized absolute source path.
|
|
pub(crate) source_contents: BTreeMap<PathBuf, String>,
|
|
/// Include-expanded TOML used for typed deserialization.
|
|
pub(crate) rendered: String,
|
|
}
|
|
|
|
/// Main runtime configuration loaded from TOML.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
pub struct ProxyConfig {
|
|
/// General runtime options shared across proxy subsystems.
|
|
#[serde(default)]
|
|
pub general: GeneralConfig,
|
|
|
|
/// Runtime logging destination, rotation, and retention configuration.
|
|
#[serde(default)]
|
|
pub logging: LoggingConfig,
|
|
|
|
/// Network binding, routing, and socket-level configuration.
|
|
#[serde(default)]
|
|
pub network: NetworkConfig,
|
|
|
|
/// Server-side listener, fallback, and API configuration.
|
|
#[serde(default)]
|
|
pub server: ServerConfig,
|
|
|
|
/// WEB carrier ingress and public-site fallback configuration.
|
|
#[serde(default)]
|
|
pub web: WebConfig,
|
|
|
|
/// Timeout values used by client, fallback, and upstream operations.
|
|
#[serde(default)]
|
|
pub timeouts: TimeoutsConfig,
|
|
|
|
/// Anti-censorship behavior and traffic shaping configuration.
|
|
#[serde(default)]
|
|
pub censorship: AntiCensorshipConfig,
|
|
|
|
/// User authentication secrets and admission policy.
|
|
#[serde(default)]
|
|
pub access: AccessConfig,
|
|
|
|
/// Telegram upstream endpoint configuration.
|
|
#[serde(default)]
|
|
pub upstreams: Vec<UpstreamConfig>,
|
|
|
|
/// Optional proxy link rendering controls.
|
|
#[serde(default)]
|
|
pub show_link: ShowLink,
|
|
|
|
/// DC address overrides for non-standard DCs (CDN, media, test, etc.)
|
|
/// Keys are DC indices as strings, values are one or more "ip:port" addresses.
|
|
/// Matches the C implementation's `proxy_for <dc_id> <ip>:<port>` config directive.
|
|
/// Example in config.toml:
|
|
/// [dc_overrides]
|
|
/// "203" = ["149.154.175.100:443", "91.105.192.100:443"]
|
|
#[serde(default, deserialize_with = "deserialize_dc_overrides")]
|
|
pub dc_overrides: HashMap<String, Vec<String>>,
|
|
|
|
/// Default DC index (1-5) for unmapped non-standard DCs.
|
|
/// Matches the C implementation's `default <dc_id>` config directive.
|
|
/// If not set, defaults to 2 (matching Telegram's official `default 2;` in proxy-multi.conf).
|
|
#[serde(default)]
|
|
pub default_dc: Option<u8>,
|
|
|
|
/// Monotonic start of source acquisition, used only for cold quota policy fencing.
|
|
#[serde(skip)]
|
|
pub(crate) quota_source_read_at: Option<std::time::Instant>,
|
|
|
|
/// Precomputed authentication snapshot for handshake hot paths.
|
|
#[serde(skip)]
|
|
pub(crate) runtime_user_auth: Option<Arc<UserAuthSnapshot>>,
|
|
}
|
|
|
|
impl ProxyConfig {
|
|
/// Loads runtime configuration from a TOML file.
|
|
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
|
|
Self::load_with_metadata(path).map(|loaded| loaded.config)
|
|
}
|
|
|
|
/// Loads typed configuration together with its source and rendered metadata.
|
|
pub(crate) fn load_with_metadata<P: AsRef<Path>>(path: P) -> Result<LoadedConfig> {
|
|
Self::load_with_source_overrides(path, &BTreeMap::new())
|
|
}
|
|
|
|
/// Loads a typed snapshot while replacing selected captured source documents.
|
|
pub(crate) fn load_with_source_overrides<P: AsRef<Path>>(
|
|
path: P,
|
|
source_overrides: &BTreeMap<PathBuf, String>,
|
|
) -> Result<LoadedConfig> {
|
|
let read_at = std::time::Instant::now();
|
|
let graph = Self::read_source_graph_with_overrides(path, source_overrides)?;
|
|
let mut loaded = Self::load_source_graph(graph)?;
|
|
loaded.config.quota_source_read_at = Some(read_at);
|
|
Ok(loaded)
|
|
}
|
|
|
|
/// Captures the raw include graph without requiring typed config validity.
|
|
pub(crate) fn read_source_graph<P: AsRef<Path>>(path: P) -> Result<ConfigSourceGraph> {
|
|
Self::read_source_graph_with_overrides(path, &BTreeMap::new())
|
|
}
|
|
|
|
/// Captures a raw include graph with in-memory source replacements.
|
|
pub(crate) fn read_source_graph_with_overrides<P: AsRef<Path>>(
|
|
path: P,
|
|
source_overrides: &BTreeMap<PathBuf, String>,
|
|
) -> Result<ConfigSourceGraph> {
|
|
let path = path.as_ref();
|
|
let mut previous = Self::capture_source_graph(path, source_overrides)?;
|
|
for _ in 0..2 {
|
|
let current = Self::capture_source_graph(path, source_overrides)?;
|
|
if current.source_contents == previous.source_contents
|
|
&& current.rendered == previous.rendered
|
|
{
|
|
return Ok(current);
|
|
}
|
|
previous = current;
|
|
}
|
|
Err(ProxyError::Config(
|
|
"config source graph changed repeatedly while it was read".to_string(),
|
|
))
|
|
}
|
|
|
|
fn capture_source_graph(
|
|
path: &Path,
|
|
source_overrides: &BTreeMap<PathBuf, String>,
|
|
) -> Result<ConfigSourceGraph> {
|
|
let path = path.as_ref();
|
|
let (normalized_path, disk_content) = read_config_source(path)?;
|
|
let content = source_overrides
|
|
.get(&normalized_path)
|
|
.cloned()
|
|
.unwrap_or(disk_content);
|
|
let base_dir = normalized_path
|
|
.parent()
|
|
.unwrap_or(Path::new("."))
|
|
.to_path_buf();
|
|
let mut source_files = BTreeSet::new();
|
|
source_files.insert(normalized_path.clone());
|
|
let mut source_contents = BTreeMap::new();
|
|
source_contents.insert(normalized_path, content.clone());
|
|
let processed = preprocess_includes(
|
|
&content,
|
|
&base_dir,
|
|
0,
|
|
&mut source_files,
|
|
&mut source_contents,
|
|
source_overrides,
|
|
)?;
|
|
|
|
Ok(ConfigSourceGraph {
|
|
source_contents,
|
|
rendered: processed,
|
|
})
|
|
}
|
|
|
|
fn load_source_graph(graph: ConfigSourceGraph) -> Result<LoadedConfig> {
|
|
pipeline::load_source_graph(graph)
|
|
}
|
|
|
|
/// Parses source without resolving decoys or creating runtime WEB snapshots.
|
|
pub(crate) fn parse_source<P: AsRef<Path>>(path: P) -> Result<ParsedConfigSource> {
|
|
Self::parse_source_with_overrides(path, &BTreeMap::new())
|
|
}
|
|
|
|
/// Checks decoy mode-specific keys before a source or API patch loses unknown fields.
|
|
pub(crate) fn validate_decoy_source_keys(document: &toml::Value) -> Result<()> {
|
|
decoy_dns::validate_mode_keys(document)
|
|
}
|
|
|
|
/// Parses an atomic mutation candidate without performing DNS.
|
|
pub(crate) fn parse_source_with_overrides<P: AsRef<Path>>(
|
|
path: P,
|
|
overrides: &BTreeMap<PathBuf, String>,
|
|
) -> Result<ParsedConfigSource> {
|
|
let read_at = std::time::Instant::now();
|
|
let mut parsed =
|
|
pipeline::parse_source_graph(Self::read_source_graph_with_overrides(path, overrides)?)?;
|
|
parsed.config.quota_source_read_at = Some(read_at);
|
|
Ok(parsed)
|
|
}
|
|
|
|
/// Loads and prepares a complete runtime candidate without blocking async workers.
|
|
pub(crate) async fn load_prepared(path: PathBuf) -> Result<LoadedConfig> {
|
|
let parsed = tokio::task::spawn_blocking(move || Self::parse_source(path))
|
|
.await
|
|
.map_err(|error| ProxyError::Config(format!("config reader failed: {error}")))??;
|
|
parsed.prepare().await
|
|
}
|
|
|
|
/// Rebuilds immutable credential lookup data for this configuration.
|
|
pub(crate) fn rebuild_runtime_user_auth(&mut self) -> Result<()> {
|
|
let snapshot = UserAuthSnapshot::from_users(&self.access.users)?;
|
|
self.runtime_user_auth = Some(Arc::new(snapshot));
|
|
Ok(())
|
|
}
|
|
|
|
/// Rebuilds validated WEB capabilities and immutable decoy snapshots.
|
|
pub(crate) fn rebuild_runtime_web(&mut self) -> Result<()> {
|
|
runtime_web::rebuild(self)
|
|
}
|
|
|
|
/// Validates the mixed effective WEB snapshot after restart fields are retained.
|
|
pub(crate) fn validate_effective_web(&mut self) -> Result<()> {
|
|
validate_web::validate(self)
|
|
}
|
|
|
|
/// Revalidates decoy separation after restart-only listener fields are resolved.
|
|
pub(crate) fn validate_web_decoy_listener_separation(&self) -> Result<()> {
|
|
validate_web::validate_decoy_listener_separation(self)
|
|
}
|
|
|
|
/// Returns authentication data owned by this configuration generation.
|
|
pub(crate) fn runtime_user_auth(&self) -> Option<&UserAuthSnapshot> {
|
|
self.runtime_user_auth.as_deref()
|
|
}
|
|
|
|
/// Returns the credential identity frozen into this runtime snapshot.
|
|
pub(crate) fn runtime_user_credential_id(&self, user: &str) -> Option<[u8; 16]> {
|
|
self.runtime_user_auth()
|
|
.and_then(|snapshot| snapshot.credential_id_by_name(user))
|
|
.or_else(|| {
|
|
self.access
|
|
.users
|
|
.get(user)
|
|
.and_then(|secret| hex::decode(secret).ok())
|
|
.and_then(|secret| <[u8; 16]>::try_from(secret).ok())
|
|
.map(|secret| {
|
|
let digest = sha256(&secret);
|
|
let mut credential_id = [0; 16];
|
|
credential_id.copy_from_slice(&digest[..16]);
|
|
credential_id
|
|
})
|
|
})
|
|
}
|
|
|
|
/// Validates cross-field configuration invariants after deserialization.
|
|
pub fn validate(&self) -> Result<()> {
|
|
self.access.validate_quota_periods()?;
|
|
if self.access.users.is_empty() {
|
|
return Err(ProxyError::Config("No users configured".to_string()));
|
|
}
|
|
|
|
validate_logging_config(&self.logging)?;
|
|
|
|
if !self.general.modes.classic && !self.general.modes.secure && !self.general.modes.tls {
|
|
return Err(ProxyError::Config("No modes enabled".to_string()));
|
|
}
|
|
|
|
if !is_valid_tls_domain_name(&self.censorship.tls_domain) {
|
|
return Err(ProxyError::Config(format!(
|
|
"Invalid tls_domain: '{}'. Must be a valid domain name",
|
|
self.censorship.tls_domain
|
|
)));
|
|
}
|
|
|
|
for domain in &self.censorship.tls_domains {
|
|
if !is_valid_tls_domain_name(domain) {
|
|
return Err(ProxyError::Config(format!(
|
|
"Invalid tls_domains entry: '{}'. Must be a valid domain name",
|
|
domain
|
|
)));
|
|
}
|
|
}
|
|
|
|
for (domain, target) in &self.censorship.exclusive_mask {
|
|
if !is_valid_tls_domain_name(domain) {
|
|
return Err(ProxyError::Config(format!(
|
|
"Invalid censorship.exclusive_mask domain: '{}'. Must be a valid domain name",
|
|
domain
|
|
)));
|
|
}
|
|
if parse_exclusive_mask_target(target).is_none() {
|
|
return Err(ProxyError::Config(format!(
|
|
"Invalid censorship.exclusive_mask target for '{}': '{}'. Expected host:port with port > 0",
|
|
domain, target
|
|
)));
|
|
}
|
|
}
|
|
|
|
for (user, tag) in &self.access.user_ad_tags {
|
|
let zeros = "00000000000000000000000000000000";
|
|
if !is_valid_ad_tag(tag) {
|
|
return Err(ProxyError::Config(format!(
|
|
"access.user_ad_tags['{}'] must be exactly 32 hex characters",
|
|
user
|
|
)));
|
|
}
|
|
if tag == zeros {
|
|
warn!(user = %user, "user ad_tag is all zeros; register a valid proxy tag via @MTProxybot to enable sponsored channel");
|
|
}
|
|
}
|
|
|
|
crate::network::dns_overrides::validate_entries(&self.network.dns_overrides)?;
|
|
validate_listener_runtime_profiles(self)?;
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "tests/load_idle_policy_tests.rs"]
|
|
mod load_idle_policy_tests;
|
|
|
|
#[cfg(test)]
|
|
#[path = "tests/load_security_tests.rs"]
|
|
mod load_security_tests;
|
|
|
|
#[cfg(test)]
|
|
#[path = "tests/load_mask_shape_security_tests.rs"]
|
|
mod load_mask_shape_security_tests;
|
|
|
|
#[cfg(test)]
|
|
#[path = "tests/load_mask_classifier_prefetch_timeout_security_tests.rs"]
|
|
mod load_mask_classifier_prefetch_timeout_security_tests;
|
|
|
|
#[cfg(test)]
|
|
#[path = "tests/load_memory_envelope_tests.rs"]
|
|
mod load_memory_envelope_tests;
|
|
|
|
#[cfg(test)]
|
|
#[path = "tests/load_basic_tests.rs"]
|
|
mod tests;
|