mirror of
https://github.com/telemt/telemt.git
synced 2026-11-01 05:12:02 +03:00
WEB: Base Path added
This commit is contained in:
@@ -30,7 +30,8 @@ use crate::util::secure_fs::open_dir_nofollow;
|
||||
#[cfg(not(unix))]
|
||||
mod static_site_fallback;
|
||||
|
||||
const WEB_CAPABILITY_CONTEXT: &[u8] = b"tdesktop-web-proxy-bridge-v1\n";
|
||||
const WEB_CAPABILITY_CONTEXT_V1: &[u8] = b"tdesktop-web-proxy-bridge-v1\n";
|
||||
const WEB_CAPABILITY_CONTEXT_V2: &[u8] = b"tdesktop-web-proxy-bridge-v2\n";
|
||||
const WEB_DEBUG_FINGERPRINT_CONTEXT: &[u8] = b"telemt-web-debug-key-fingerprint-v1\0";
|
||||
const MAX_WEB_STATIC_DEPTH: usize = 64;
|
||||
|
||||
@@ -41,6 +42,7 @@ pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> {
|
||||
})?;
|
||||
let mut runtime_vhosts = BTreeMap::new();
|
||||
let mut runtime_profiles = Vec::new();
|
||||
let mut runtime_capabilities = Vec::new();
|
||||
let mut static_files = 0usize;
|
||||
let mut static_bytes = 0usize;
|
||||
|
||||
@@ -67,8 +69,11 @@ pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> {
|
||||
})?;
|
||||
let (client_secret, client_secret_len) =
|
||||
client_secret(auth_entry.secret, profile.secret_mode);
|
||||
let capability =
|
||||
derive_web_capability(&client_secret[..client_secret_len], vhost.host.as_bytes())?;
|
||||
let capability = derive_web_capability(
|
||||
&client_secret[..client_secret_len],
|
||||
vhost.host.as_bytes(),
|
||||
vhost.base_path.as_bytes(),
|
||||
)?;
|
||||
let key_fingerprint = debug_key_fingerprint(&client_secret[..client_secret_len]);
|
||||
if !capabilities.insert(capability) {
|
||||
return Err(ProxyError::Config(format!(
|
||||
@@ -104,6 +109,7 @@ pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> {
|
||||
.unwrap_or(config.web.limits.max_streams_per_session),
|
||||
});
|
||||
capability_table.push(capability);
|
||||
runtime_capabilities.push(capability);
|
||||
profiles.push(Arc::clone(&runtime_profile));
|
||||
runtime_profiles.push(runtime_profile);
|
||||
}
|
||||
@@ -111,6 +117,11 @@ pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> {
|
||||
vhost.host.clone(),
|
||||
Arc::new(WebRuntimeVhost {
|
||||
host: vhost.host.clone(),
|
||||
base: if vhost.base_path.is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
format!("/{}/", vhost.base_path)
|
||||
},
|
||||
decoy_fasttrack_mode: config.web.decoy_fasttrack_mode,
|
||||
decoy,
|
||||
decoy_header_secs: config.web.timeouts.decoy_header_secs,
|
||||
@@ -123,6 +134,7 @@ pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> {
|
||||
config.web.runtime = Some(Arc::new(WebRuntimeConfig {
|
||||
vhosts: runtime_vhosts,
|
||||
profiles: runtime_profiles,
|
||||
capabilities: runtime_capabilities.into_boxed_slice(),
|
||||
}));
|
||||
Ok(())
|
||||
}
|
||||
@@ -134,12 +146,23 @@ fn debug_key_fingerprint(secret: &[u8]) -> String {
|
||||
hex::encode(&digest.finalize()[..8])
|
||||
}
|
||||
|
||||
/// Derives the Telegram Desktop WEB capability for one exact secret and host.
|
||||
pub(crate) fn derive_web_capability(secret: &[u8], host: &[u8]) -> Result<[u8; 32]> {
|
||||
/// Derives the Telegram Desktop WEB capability for one exact secret, host, and base path.
|
||||
pub(crate) fn derive_web_capability(
|
||||
secret: &[u8],
|
||||
host: &[u8],
|
||||
base_path: &[u8],
|
||||
) -> Result<[u8; 32]> {
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(secret)
|
||||
.map_err(|_| ProxyError::Config("WEB capability secret must not be empty".to_string()))?;
|
||||
mac.update(WEB_CAPABILITY_CONTEXT);
|
||||
mac.update(host);
|
||||
if base_path.is_empty() {
|
||||
mac.update(WEB_CAPABILITY_CONTEXT_V1);
|
||||
mac.update(host);
|
||||
} else {
|
||||
mac.update(WEB_CAPABILITY_CONTEXT_V2);
|
||||
mac.update(host);
|
||||
mac.update(b"\n");
|
||||
mac.update(base_path);
|
||||
}
|
||||
Ok(mac.finalize().into_bytes().into())
|
||||
}
|
||||
|
||||
@@ -482,62 +505,7 @@ fn static_content_type(path: &Path) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
// Runtime WEB construction tests remain separate from the production loader.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use base64::Engine as _;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn capability_matches_reference_vectors() {
|
||||
let secret = hex::decode("000102030405060708090a0b0c0d0e0f").unwrap();
|
||||
let plain = derive_web_capability(&secret, b"proxy.example.com").unwrap();
|
||||
assert_eq!(
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(plain),
|
||||
"MHLEY5PmW1GWqJkSrlmJpvJUiLhBH_QKy6yKg8a0JPk"
|
||||
);
|
||||
let mut dd_secret = vec![0xdd];
|
||||
dd_secret.extend_from_slice(&secret);
|
||||
let dd = derive_web_capability(&dd_secret, b"proxy.example.com").unwrap();
|
||||
assert_eq!(
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(dd),
|
||||
"IpJrt3e7sKtzPyoXy6w-Zj6GGEvsvclN66JzQEfPYLA"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn static_snapshot_remains_anchored_after_root_path_replacement() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("site");
|
||||
let detached = temp.path().join("detached");
|
||||
let replacement = temp.path().join("replacement");
|
||||
fs::create_dir(&root).unwrap();
|
||||
fs::write(root.join("index.html"), b"original").unwrap();
|
||||
fs::create_dir(&replacement).unwrap();
|
||||
fs::write(replacement.join("index.html"), b"replacement").unwrap();
|
||||
|
||||
let directory = open_static_root(&root).unwrap();
|
||||
fs::rename(&root, &detached).unwrap();
|
||||
symlink(&replacement, &root).unwrap();
|
||||
|
||||
let mut assets = BTreeMap::new();
|
||||
let mut total_files = 0;
|
||||
let mut total_bytes = 0;
|
||||
load_static_directory(
|
||||
directory,
|
||||
Path::new(""),
|
||||
&root,
|
||||
&mut assets,
|
||||
&mut total_files,
|
||||
&mut total_bytes,
|
||||
&WebLimitsConfig::default(),
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(assets["/index.html"].body.as_ref(), b"original");
|
||||
}
|
||||
}
|
||||
#[path = "runtime_web/tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
use base64::Engine as _;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn capability_matches_reference_vectors() {
|
||||
let secret = hex::decode("000102030405060708090a0b0c0d0e0f").unwrap();
|
||||
let mut dd_secret = vec![0xdd];
|
||||
dd_secret.extend_from_slice(&secret);
|
||||
for (client_secret, base_path, expected) in [
|
||||
(
|
||||
secret.as_slice(),
|
||||
b"".as_slice(),
|
||||
"MHLEY5PmW1GWqJkSrlmJpvJUiLhBH_QKy6yKg8a0JPk",
|
||||
),
|
||||
(
|
||||
dd_secret.as_slice(),
|
||||
b"".as_slice(),
|
||||
"IpJrt3e7sKtzPyoXy6w-Zj6GGEvsvclN66JzQEfPYLA",
|
||||
),
|
||||
(
|
||||
secret.as_slice(),
|
||||
b"dobry-cola-super-app".as_slice(),
|
||||
"hHz99Xs93EN1j91G9gpNepXwGNNt5YdAFkEVk_LlqdQ",
|
||||
),
|
||||
(
|
||||
dd_secret.as_slice(),
|
||||
b"dobry-cola-super-app".as_slice(),
|
||||
"TGUkZaevsavLbHvlNWipnRoYxgzZ51ioWvbxgGT3wHo",
|
||||
),
|
||||
] {
|
||||
let capability =
|
||||
derive_web_capability(client_secret, b"proxy.example.com", base_path).unwrap();
|
||||
assert_eq!(
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(capability),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn static_snapshot_remains_anchored_after_root_path_replacement() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("site");
|
||||
let detached = temp.path().join("detached");
|
||||
let replacement = temp.path().join("replacement");
|
||||
fs::create_dir(&root).unwrap();
|
||||
fs::write(root.join("index.html"), b"original").unwrap();
|
||||
fs::create_dir(&replacement).unwrap();
|
||||
fs::write(replacement.join("index.html"), b"replacement").unwrap();
|
||||
|
||||
let directory = open_static_root(&root).unwrap();
|
||||
fs::rename(&root, &detached).unwrap();
|
||||
symlink(&replacement, &root).unwrap();
|
||||
|
||||
let mut assets = BTreeMap::new();
|
||||
let mut total_files = 0;
|
||||
let mut total_bytes = 0;
|
||||
load_static_directory(
|
||||
directory,
|
||||
Path::new(""),
|
||||
&root,
|
||||
&mut assets,
|
||||
&mut total_files,
|
||||
&mut total_bytes,
|
||||
&WebLimitsConfig::default(),
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(assets["/index.html"].body.as_ref(), b"original");
|
||||
}
|
||||
@@ -365,7 +365,13 @@ const WEB_TIMEOUTS_CONFIG_KEYS: &[&str] = &[
|
||||
"decoy_header_secs",
|
||||
];
|
||||
|
||||
const WEB_VHOST_CONFIG_KEYS: &[&str] = &["host", "public_addr", "decoy", "profiles"];
|
||||
const WEB_VHOST_CONFIG_KEYS: &[&str] = &[
|
||||
"host",
|
||||
"base_path",
|
||||
"public_addr",
|
||||
"decoy",
|
||||
"profiles",
|
||||
];
|
||||
const WEB_DECOY_CONFIG_KEYS: &[&str] = &["mode", "upstream", "directory", "index"];
|
||||
const WEB_PROFILE_CONFIG_KEYS: &[&str] = &[
|
||||
"user",
|
||||
|
||||
@@ -6,7 +6,8 @@ const WEB_DEBUG_GROUP_SCRATCH_BYTES: usize = 4 * 1024 * 1024;
|
||||
const WEB_CARRIER_LEARNING_ENTRY_BYTES: usize = 512;
|
||||
const WEB_LANE_STATE_BYTES: usize = 512;
|
||||
const WEB_OVERLOAD_CONNECTION_BYTES: usize = 4 * 1024;
|
||||
const WEB_CAPABILITY_INDEX_ENTRY_BYTES: usize = 32;
|
||||
// Each profile capability is stored in its vhost and in the global containment table.
|
||||
const WEB_CAPABILITY_INDEX_ENTRY_BYTES: usize = 64;
|
||||
|
||||
/// Validates process-wide body, header, queue, static, and debug reservations.
|
||||
pub(super) fn validate(limits: &WebLimitsConfig) -> Result<()> {
|
||||
|
||||
@@ -9,6 +9,10 @@ pub(super) fn validate_vhosts(config: &mut ProxyConfig) -> Result<()> {
|
||||
let mut profile_count = 0usize;
|
||||
for (vhost_idx, vhost) in config.web.vhosts.iter_mut().enumerate() {
|
||||
vhost.host = normalize_web_host(&vhost.host, &format!("web.vhosts[{vhost_idx}].host"))?;
|
||||
validate_web_base_path(
|
||||
&vhost.base_path,
|
||||
&format!("web.vhosts[{vhost_idx}].base_path"),
|
||||
)?;
|
||||
if !hosts.insert(vhost.host.clone()) {
|
||||
return config_error(&format!("duplicate WEB vhost host `{}`", vhost.host));
|
||||
}
|
||||
@@ -75,6 +79,23 @@ pub(super) fn validate_vhosts(config: &mut ProxyConfig) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_web_base_path(value: &str, field: &str) -> Result<()> {
|
||||
let valid = value.len() <= 128
|
||||
&& !value.starts_with('/')
|
||||
&& !value.ends_with('/')
|
||||
&& value.split('/').all(|segment| {
|
||||
let mut bytes = segment.bytes();
|
||||
bytes.next().is_some_and(|byte| byte.is_ascii_alphanumeric())
|
||||
&& bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
|
||||
});
|
||||
if value.is_empty() || valid {
|
||||
return Ok(());
|
||||
}
|
||||
config_error(&format!(
|
||||
"{field} must be empty or contain at most 128 ASCII bytes in slash-separated [A-Za-z0-9][A-Za-z0-9_-]* segments"
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn normalize_web_host(value: &str, field: &str) -> Result<String> {
|
||||
let input = value.trim();
|
||||
if input.is_empty()
|
||||
|
||||
@@ -40,9 +40,11 @@ fn web_config_builds_canonical_runtime_snapshot() {
|
||||
.vhosts
|
||||
.get("proxy.example.com")
|
||||
.expect("canonical WEB vhost");
|
||||
assert_eq!(vhost.base, "/");
|
||||
assert_eq!(vhost.profiles.len(), 1);
|
||||
assert_eq!(vhost.capabilities.len(), vhost.profiles.len());
|
||||
assert_eq!(vhost.capabilities[0], vhost.profiles[0].capability);
|
||||
assert_eq!(runtime.capabilities.as_ref(), vhost.capabilities.as_ref());
|
||||
assert_eq!(vhost.decoy_fasttrack_mode, WebDecoyFastTrackMode::Off);
|
||||
assert_eq!(vhost.profiles[0].user, "alice");
|
||||
assert_eq!(vhost.profiles[0].secret_mode, WebSecretMode::Dd);
|
||||
@@ -59,6 +61,61 @@ fn web_config_builds_canonical_runtime_snapshot() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_base_path_is_canonical_and_precomputed() {
|
||||
let maximum = "a".repeat(128);
|
||||
for base_path in ["Dobry-Cola/super_app", maximum.as_str()] {
|
||||
let configured = WEB_CONFIG.replace(
|
||||
"host = \"Proxy.Example.COM\"",
|
||||
&format!("host = \"Proxy.Example.COM\"\nbase_path = \"{base_path}\""),
|
||||
);
|
||||
let config = load_config_from_temp_toml(&configured);
|
||||
assert_eq!(config.web.vhosts[0].base_path, base_path);
|
||||
assert_eq!(
|
||||
config.web.runtime.as_ref().unwrap().vhosts["proxy.example.com"].base,
|
||||
format!("/{base_path}/")
|
||||
);
|
||||
}
|
||||
|
||||
let strict = format!(
|
||||
"[general]\nconfig_strict = true\n{}",
|
||||
WEB_CONFIG.replace(
|
||||
"host = \"Proxy.Example.COM\"",
|
||||
"host = \"Proxy.Example.COM\"\nbase_path = \"relay\"",
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
load_config_from_temp_toml(&strict).web.vhosts[0].base_path,
|
||||
"relay"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_base_path_rejects_noncanonical_forms() {
|
||||
let oversized = "a".repeat(129);
|
||||
for base_path in [
|
||||
"/relay",
|
||||
"relay/",
|
||||
"relay//nested",
|
||||
"-relay",
|
||||
"_relay",
|
||||
"relay/.hidden",
|
||||
"relay/%2fhidden",
|
||||
"relay path",
|
||||
"relay/тест",
|
||||
oversized.as_str(),
|
||||
] {
|
||||
let invalid = WEB_CONFIG.replace(
|
||||
"host = \"Proxy.Example.COM\"",
|
||||
&format!("host = \"Proxy.Example.COM\"\nbase_path = \"{base_path}\""),
|
||||
);
|
||||
assert!(
|
||||
load_config_error_from_temp_toml(&invalid).contains("web.vhosts[0].base_path"),
|
||||
"base path {base_path:?} was accepted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_decoy_fasttrack_mode_is_typed_and_defaults_off() {
|
||||
let defaults = ProxyConfig::default();
|
||||
|
||||
@@ -71,6 +71,9 @@ pub enum WebDecoyConfig {
|
||||
pub struct WebVhostConfig {
|
||||
/// Canonical lowercase ACE hostname used by Telegram Desktop.
|
||||
pub host: String,
|
||||
/// Optional canonical WEB endpoint prefix without surrounding slashes.
|
||||
#[serde(default)]
|
||||
pub base_path: String,
|
||||
/// Stable public destination tuple used by inner relay routing and KDF metadata.
|
||||
pub public_addr: SocketAddr,
|
||||
/// Ordinary-site fallback for this hostname.
|
||||
|
||||
@@ -7,6 +7,8 @@ pub(crate) struct WebRuntimeConfig {
|
||||
pub(crate) vhosts: BTreeMap<String, Arc<WebRuntimeVhost>>,
|
||||
/// Flat profile inventory used by startup link emission.
|
||||
pub(crate) profiles: Vec<Arc<WebRuntimeProfile>>,
|
||||
/// Complete active capability table used to contain misplaced credentials.
|
||||
pub(crate) capabilities: Box<[[u8; 32]]>,
|
||||
}
|
||||
|
||||
/// Precomputed immutable virtual-host data.
|
||||
@@ -14,6 +16,8 @@ pub(crate) struct WebRuntimeConfig {
|
||||
pub(crate) struct WebRuntimeVhost {
|
||||
/// Canonical lowercase ACE hostname.
|
||||
pub(crate) host: String,
|
||||
/// Exact slash-delimited endpoint base, including the trailing slash.
|
||||
pub(crate) base: String,
|
||||
/// Restart-frozen decoy capability-scan policy.
|
||||
pub(crate) decoy_fasttrack_mode: WebDecoyFastTrackMode,
|
||||
/// Immutable ordinary-site fallback snapshot.
|
||||
|
||||
Reference in New Issue
Block a user