mirror of
https://github.com/telemt/telemt.git
synced 2026-10-10 19:35:58 +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))]
|
#[cfg(not(unix))]
|
||||||
mod static_site_fallback;
|
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 WEB_DEBUG_FINGERPRINT_CONTEXT: &[u8] = b"telemt-web-debug-key-fingerprint-v1\0";
|
||||||
const MAX_WEB_STATIC_DEPTH: usize = 64;
|
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_vhosts = BTreeMap::new();
|
||||||
let mut runtime_profiles = Vec::new();
|
let mut runtime_profiles = Vec::new();
|
||||||
|
let mut runtime_capabilities = Vec::new();
|
||||||
let mut static_files = 0usize;
|
let mut static_files = 0usize;
|
||||||
let mut static_bytes = 0usize;
|
let mut static_bytes = 0usize;
|
||||||
|
|
||||||
@@ -67,8 +69,11 @@ pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> {
|
|||||||
})?;
|
})?;
|
||||||
let (client_secret, client_secret_len) =
|
let (client_secret, client_secret_len) =
|
||||||
client_secret(auth_entry.secret, profile.secret_mode);
|
client_secret(auth_entry.secret, profile.secret_mode);
|
||||||
let capability =
|
let capability = derive_web_capability(
|
||||||
derive_web_capability(&client_secret[..client_secret_len], vhost.host.as_bytes())?;
|
&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]);
|
let key_fingerprint = debug_key_fingerprint(&client_secret[..client_secret_len]);
|
||||||
if !capabilities.insert(capability) {
|
if !capabilities.insert(capability) {
|
||||||
return Err(ProxyError::Config(format!(
|
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),
|
.unwrap_or(config.web.limits.max_streams_per_session),
|
||||||
});
|
});
|
||||||
capability_table.push(capability);
|
capability_table.push(capability);
|
||||||
|
runtime_capabilities.push(capability);
|
||||||
profiles.push(Arc::clone(&runtime_profile));
|
profiles.push(Arc::clone(&runtime_profile));
|
||||||
runtime_profiles.push(runtime_profile);
|
runtime_profiles.push(runtime_profile);
|
||||||
}
|
}
|
||||||
@@ -111,6 +117,11 @@ pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> {
|
|||||||
vhost.host.clone(),
|
vhost.host.clone(),
|
||||||
Arc::new(WebRuntimeVhost {
|
Arc::new(WebRuntimeVhost {
|
||||||
host: vhost.host.clone(),
|
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_fasttrack_mode: config.web.decoy_fasttrack_mode,
|
||||||
decoy,
|
decoy,
|
||||||
decoy_header_secs: config.web.timeouts.decoy_header_secs,
|
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 {
|
config.web.runtime = Some(Arc::new(WebRuntimeConfig {
|
||||||
vhosts: runtime_vhosts,
|
vhosts: runtime_vhosts,
|
||||||
profiles: runtime_profiles,
|
profiles: runtime_profiles,
|
||||||
|
capabilities: runtime_capabilities.into_boxed_slice(),
|
||||||
}));
|
}));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -134,12 +146,23 @@ fn debug_key_fingerprint(secret: &[u8]) -> String {
|
|||||||
hex::encode(&digest.finalize()[..8])
|
hex::encode(&digest.finalize()[..8])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Derives the Telegram Desktop WEB capability for one exact secret and host.
|
/// Derives the Telegram Desktop WEB capability for one exact secret, host, and base path.
|
||||||
pub(crate) fn derive_web_capability(secret: &[u8], host: &[u8]) -> Result<[u8; 32]> {
|
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)
|
let mut mac = Hmac::<Sha256>::new_from_slice(secret)
|
||||||
.map_err(|_| ProxyError::Config("WEB capability secret must not be empty".to_string()))?;
|
.map_err(|_| ProxyError::Config("WEB capability secret must not be empty".to_string()))?;
|
||||||
mac.update(WEB_CAPABILITY_CONTEXT);
|
if base_path.is_empty() {
|
||||||
|
mac.update(WEB_CAPABILITY_CONTEXT_V1);
|
||||||
mac.update(host);
|
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())
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "runtime_web/tests.rs"]
|
||||||
use base64::Engine as _;
|
mod tests;
|
||||||
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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",
|
"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_DECOY_CONFIG_KEYS: &[&str] = &["mode", "upstream", "directory", "index"];
|
||||||
const WEB_PROFILE_CONFIG_KEYS: &[&str] = &[
|
const WEB_PROFILE_CONFIG_KEYS: &[&str] = &[
|
||||||
"user",
|
"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_CARRIER_LEARNING_ENTRY_BYTES: usize = 512;
|
||||||
const WEB_LANE_STATE_BYTES: usize = 512;
|
const WEB_LANE_STATE_BYTES: usize = 512;
|
||||||
const WEB_OVERLOAD_CONNECTION_BYTES: usize = 4 * 1024;
|
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.
|
/// Validates process-wide body, header, queue, static, and debug reservations.
|
||||||
pub(super) fn validate(limits: &WebLimitsConfig) -> Result<()> {
|
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;
|
let mut profile_count = 0usize;
|
||||||
for (vhost_idx, vhost) in config.web.vhosts.iter_mut().enumerate() {
|
for (vhost_idx, vhost) in config.web.vhosts.iter_mut().enumerate() {
|
||||||
vhost.host = normalize_web_host(&vhost.host, &format!("web.vhosts[{vhost_idx}].host"))?;
|
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()) {
|
if !hosts.insert(vhost.host.clone()) {
|
||||||
return config_error(&format!("duplicate WEB vhost host `{}`", vhost.host));
|
return config_error(&format!("duplicate WEB vhost host `{}`", vhost.host));
|
||||||
}
|
}
|
||||||
@@ -75,6 +79,23 @@ pub(super) fn validate_vhosts(config: &mut ProxyConfig) -> Result<()> {
|
|||||||
Ok(())
|
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> {
|
pub(super) fn normalize_web_host(value: &str, field: &str) -> Result<String> {
|
||||||
let input = value.trim();
|
let input = value.trim();
|
||||||
if input.is_empty()
|
if input.is_empty()
|
||||||
|
|||||||
@@ -40,9 +40,11 @@ fn web_config_builds_canonical_runtime_snapshot() {
|
|||||||
.vhosts
|
.vhosts
|
||||||
.get("proxy.example.com")
|
.get("proxy.example.com")
|
||||||
.expect("canonical WEB vhost");
|
.expect("canonical WEB vhost");
|
||||||
|
assert_eq!(vhost.base, "/");
|
||||||
assert_eq!(vhost.profiles.len(), 1);
|
assert_eq!(vhost.profiles.len(), 1);
|
||||||
assert_eq!(vhost.capabilities.len(), vhost.profiles.len());
|
assert_eq!(vhost.capabilities.len(), vhost.profiles.len());
|
||||||
assert_eq!(vhost.capabilities[0], vhost.profiles[0].capability);
|
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.decoy_fasttrack_mode, WebDecoyFastTrackMode::Off);
|
||||||
assert_eq!(vhost.profiles[0].user, "alice");
|
assert_eq!(vhost.profiles[0].user, "alice");
|
||||||
assert_eq!(vhost.profiles[0].secret_mode, WebSecretMode::Dd);
|
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]
|
#[test]
|
||||||
fn web_decoy_fasttrack_mode_is_typed_and_defaults_off() {
|
fn web_decoy_fasttrack_mode_is_typed_and_defaults_off() {
|
||||||
let defaults = ProxyConfig::default();
|
let defaults = ProxyConfig::default();
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ pub enum WebDecoyConfig {
|
|||||||
pub struct WebVhostConfig {
|
pub struct WebVhostConfig {
|
||||||
/// Canonical lowercase ACE hostname used by Telegram Desktop.
|
/// Canonical lowercase ACE hostname used by Telegram Desktop.
|
||||||
pub host: String,
|
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.
|
/// Stable public destination tuple used by inner relay routing and KDF metadata.
|
||||||
pub public_addr: SocketAddr,
|
pub public_addr: SocketAddr,
|
||||||
/// Ordinary-site fallback for this hostname.
|
/// Ordinary-site fallback for this hostname.
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ pub(crate) struct WebRuntimeConfig {
|
|||||||
pub(crate) vhosts: BTreeMap<String, Arc<WebRuntimeVhost>>,
|
pub(crate) vhosts: BTreeMap<String, Arc<WebRuntimeVhost>>,
|
||||||
/// Flat profile inventory used by startup link emission.
|
/// Flat profile inventory used by startup link emission.
|
||||||
pub(crate) profiles: Vec<Arc<WebRuntimeProfile>>,
|
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.
|
/// Precomputed immutable virtual-host data.
|
||||||
@@ -14,6 +16,8 @@ pub(crate) struct WebRuntimeConfig {
|
|||||||
pub(crate) struct WebRuntimeVhost {
|
pub(crate) struct WebRuntimeVhost {
|
||||||
/// Canonical lowercase ACE hostname.
|
/// Canonical lowercase ACE hostname.
|
||||||
pub(crate) host: String,
|
pub(crate) host: String,
|
||||||
|
/// Exact slash-delimited endpoint base, including the trailing slash.
|
||||||
|
pub(crate) base: String,
|
||||||
/// Restart-frozen decoy capability-scan policy.
|
/// Restart-frozen decoy capability-scan policy.
|
||||||
pub(crate) decoy_fasttrack_mode: WebDecoyFastTrackMode,
|
pub(crate) decoy_fasttrack_mode: WebDecoyFastTrackMode,
|
||||||
/// Immutable ordinary-site fallback snapshot.
|
/// Immutable ordinary-site fallback snapshot.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use base64::Engine as _;
|
||||||
use tokio::sync::watch;
|
use tokio::sync::watch;
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
@@ -80,19 +81,57 @@ pub(crate) fn print_web_proxy_links(config: &ProxyConfig) {
|
|||||||
let Some(secret) = config.access.users.get(&profile.user) else {
|
let Some(secret) = config.access.users.get(&profile.user) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let prefix = match profile.secret_mode {
|
let Some(vhost) = config
|
||||||
crate::config::WebSecretMode::Plain => "",
|
.web
|
||||||
crate::config::WebSecretMode::Dd => "dd",
|
.vhosts
|
||||||
|
.iter()
|
||||||
|
.find(|vhost| vhost.host == profile.host)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
};
|
};
|
||||||
print_maestro_line(format!(
|
print_maestro_line(format!(
|
||||||
"User: {} ({:?})",
|
"User: {} ({:?})",
|
||||||
profile.user, profile.secret_mode
|
profile.user, profile.secret_mode
|
||||||
));
|
));
|
||||||
print_maestro_line(format!(
|
if let Some(link) = format_web_proxy_link(
|
||||||
"WEB: tg://webproxy?server={}&secret={prefix}{secret}",
|
&profile.host,
|
||||||
profile.host,
|
&vhost.base_path,
|
||||||
|
secret,
|
||||||
|
profile.secret_mode,
|
||||||
|
) {
|
||||||
|
print_maestro_line(format!("WEB: {link}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_web_proxy_link(
|
||||||
|
host: &str,
|
||||||
|
base_path: &str,
|
||||||
|
secret: &str,
|
||||||
|
mode: crate::config::WebSecretMode,
|
||||||
|
) -> Option<String> {
|
||||||
|
if base_path.is_empty() {
|
||||||
|
let prefix = match mode {
|
||||||
|
crate::config::WebSecretMode::Plain => "",
|
||||||
|
crate::config::WebSecretMode::Dd => "dd",
|
||||||
|
};
|
||||||
|
return Some(format!(
|
||||||
|
"tg://webproxy?server={host}&secret={prefix}{secret}"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
let decoded = hex::decode(secret).ok()?;
|
||||||
|
let mut marked = Vec::with_capacity(decoded.len() + 2);
|
||||||
|
marked.push(0x70);
|
||||||
|
if mode == crate::config::WebSecretMode::Dd {
|
||||||
|
marked.push(0xdd);
|
||||||
|
}
|
||||||
|
marked.extend_from_slice(&decoded);
|
||||||
|
let server = url::form_urlencoded::byte_serialize(format!("{host}/{base_path}").as_bytes())
|
||||||
|
.collect::<String>();
|
||||||
|
let marked = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(marked);
|
||||||
|
Some(format!(
|
||||||
|
"tg://webproxy?server={server}&secret={marked}"
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Durably replaces one Beobachten snapshot without following Unix symlinks.
|
/// Durably replaces one Beobachten snapshot without following Unix symlinks.
|
||||||
@@ -367,3 +406,49 @@ pub(crate) async fn load_startup_proxy_config_snapshot(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::config::WebSecretMode;
|
||||||
|
|
||||||
|
const SECRET: &str = "000102030405060708090a0b0c0d0e0f";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn root_web_proxy_links_keep_the_legacy_secret_form() {
|
||||||
|
assert_eq!(
|
||||||
|
format_web_proxy_link("proxy.example.com", "", SECRET, WebSecretMode::Plain),
|
||||||
|
Some(format!(
|
||||||
|
"tg://webproxy?server=proxy.example.com&secret={SECRET}"
|
||||||
|
))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
format_web_proxy_link("proxy.example.com", "", SECRET, WebSecretMode::Dd),
|
||||||
|
Some(format!(
|
||||||
|
"tg://webproxy?server=proxy.example.com&secret=dd{SECRET}"
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn path_web_proxy_links_use_the_tdesktop_marker() {
|
||||||
|
assert_eq!(
|
||||||
|
format_web_proxy_link(
|
||||||
|
"proxy.example.com",
|
||||||
|
"dobry-cola/super_app",
|
||||||
|
SECRET,
|
||||||
|
WebSecretMode::Plain,
|
||||||
|
),
|
||||||
|
Some("tg://webproxy?server=proxy.example.com%2Fdobry-cola%2Fsuper_app&secret=cAABAgMEBQYHCAkKCwwNDg8".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
format_web_proxy_link(
|
||||||
|
"proxy.example.com",
|
||||||
|
"dobry-cola/super_app",
|
||||||
|
SECRET,
|
||||||
|
WebSecretMode::Dd,
|
||||||
|
),
|
||||||
|
Some("tg://webproxy?server=proxy.example.com%2Fdobry-cola%2Fsuper_app&secret=cN0AAQIDBAUGBwgJCgsMDQ4P".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ pub(crate) struct BridgePage {
|
|||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub(crate) fn render(
|
pub(crate) fn render(
|
||||||
host: &str,
|
host: &str,
|
||||||
|
base: &str,
|
||||||
bootstrap: &str,
|
bootstrap: &str,
|
||||||
batch_limit: usize,
|
batch_limit: usize,
|
||||||
queue_limit: usize,
|
queue_limit: usize,
|
||||||
@@ -55,6 +56,7 @@ pub(crate) fn render(
|
|||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
|
let base_prefix = base.strip_suffix('/').unwrap_or(base);
|
||||||
let body = DOCUMENT
|
let body = DOCUMENT
|
||||||
.replace("__DIAGNOSTIC_RUNTIME__\n", &diagnostic_script)
|
.replace("__DIAGNOSTIC_RUNTIME__\n", &diagnostic_script)
|
||||||
.replace("__RESPONSE_RUNTIME__", RESPONSE_RUNTIME)
|
.replace("__RESPONSE_RUNTIME__", RESPONSE_RUNTIME)
|
||||||
@@ -120,6 +122,7 @@ pub(crate) fn render(
|
|||||||
)
|
)
|
||||||
.replace("__NONCE__", &nonce)
|
.replace("__NONCE__", &nonce)
|
||||||
.replace("__HOST__", host)
|
.replace("__HOST__", host)
|
||||||
|
.replace("__BASE_PREFIX__", base_prefix)
|
||||||
.replace("__BOOTSTRAP__", bootstrap)
|
.replace("__BOOTSTRAP__", bootstrap)
|
||||||
.replace("__BATCH_LIMIT__", &batch_limit.to_string())
|
.replace("__BATCH_LIMIT__", &batch_limit.to_string())
|
||||||
.replace("__QUEUE_LIMIT__", &queue_limit.to_string())
|
.replace("__QUEUE_LIMIT__", &queue_limit.to_string())
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
(()=>{'use strict';
|
(()=>{'use strict';
|
||||||
let bootstrap="__BOOTSTRAP__",hello=false,emitted=0,boundaryTimer=null;
|
let bootstrap="__BOOTSTRAP__",hello=false,emitted=0,boundaryTimer=null;
|
||||||
const relayOrigin='https://__HOST__',requestMs=__BRIDGE_REQUEST_SECS__*1000;
|
const relayOrigin='https://__HOST__',relayBase=relayOrigin+'__BASE_PREFIX__',requestMs=__BRIDGE_REQUEST_SECS__*1000;
|
||||||
function eventBit(event){
|
function eventBit(event){
|
||||||
if(event==='runtime_started')return 1;if(event==='status_posted')return 2;if(event==='hello_received')return 4;if(event==='boundary_timeout')return 8;
|
if(event==='runtime_started')return 1;if(event==='status_posted')return 2;if(event==='hello_received')return 4;if(event==='boundary_timeout')return 8;
|
||||||
if(event==='hello_timeout')return 16;if(event==='client_close_before_hello')return 32;if(event==='document_unloaded_before_hello')return 64;
|
if(event==='hello_timeout')return 16;if(event==='client_close_before_hello')return 32;if(event==='document_unloaded_before_hello')return 64;
|
||||||
@@ -12,7 +12,7 @@ function report(event){
|
|||||||
let timer=null;
|
let timer=null;
|
||||||
try{
|
try{
|
||||||
const controller=new AbortController(),body=JSON.stringify({v:1,event});timer=setTimeout(()=>controller.abort(),requestMs);
|
const controller=new AbortController(),body=JSON.stringify({v:1,event});timer=setTimeout(()=>controller.abort(),requestMs);
|
||||||
fetch(relayOrigin+'/api/v1/diagnostic',{method:'POST',body,signal:controller.signal,keepalive:true,mode:'same-origin',credentials:'omit',cache:'no-store',redirect:'error',referrerPolicy:'no-referrer',headers:{Authorization:'Bearer '+bootstrap,'Content-Type':'application/json'}})
|
fetch(relayBase+'/api/v1/diagnostic',{method:'POST',body,signal:controller.signal,keepalive:true,mode:'same-origin',credentials:'omit',cache:'no-store',redirect:'error',referrerPolicy:'no-referrer',headers:{Authorization:'Bearer '+bootstrap,'Content-Type':'application/json'}})
|
||||||
.then(discard,()=>{}).then(()=>clearTimeout(timer),()=>clearTimeout(timer));
|
.then(discard,()=>{}).then(()=>clearTimeout(timer),()=>clearTimeout(timer));
|
||||||
}catch(error){if(timer)clearTimeout(timer)}
|
}catch(error){if(timer)clearTimeout(timer)}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ function create(settings){
|
|||||||
const timer=setTimeout(()=>{timedOut=true;controller.abort()},Math.max(1,Math.min(attemptLimit,remaining)));
|
const timer=setTimeout(()=>{timedOut=true;controller.abort()},Math.max(1,Math.min(attemptLimit,remaining)));
|
||||||
let response=null,wait=0;
|
let response=null,wait=0;
|
||||||
try{
|
try{
|
||||||
const fetched=await fetch(settings.origin()+path,requestOptions);
|
const fetched=await fetch(settings.base()+path,requestOptions);
|
||||||
if(retryableStatus(fetched.status)){
|
if(retryableStatus(fetched.status)){
|
||||||
lastReason='http';wait=retryAfterMs(fetched);settings.cancel(fetched);
|
lastReason='http';wait=retryAfterMs(fetched);settings.cancel(fetched);
|
||||||
}else{
|
}else{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
(()=>{'use strict';
|
(()=>{'use strict';
|
||||||
let bootstrap="__BOOTSTRAP__";
|
let bootstrap="__BOOTSTRAP__";
|
||||||
const relayOrigin='https://__HOST__',carrierCapabilities='https,https-lanes,websocket,websocket-lanes';
|
const relayOrigin='https://__HOST__',relayBase=relayOrigin+'__BASE_PREFIX__',carrierCapabilities='https,https-lanes,websocket,websocket-lanes';
|
||||||
__DIAGNOSTIC_BINDING__;
|
__DIAGNOSTIC_BINDING__;
|
||||||
__DIAGNOSTIC_RUNTIME_STARTED__;
|
__DIAGNOSTIC_RUNTIME_STARTED__;
|
||||||
const responseBody=globalThis.TelemtBridgeResponse;if(!responseBody)throw new Error('missing response runtime');
|
const responseBody=globalThis.TelemtBridgeResponse;if(!responseBody)throw new Error('missing response runtime');
|
||||||
@@ -26,9 +26,9 @@ const canonicalFailures=['timeout','network','upgrade','http','protocol'];
|
|||||||
const failure=(reason,message)=>Object.assign(new Error(message||reason),{telemtReason:reason});
|
const failure=(reason,message)=>Object.assign(new Error(message||reason),{telemtReason:reason});
|
||||||
const failureReason=(error,fallback)=>error&&canonicalFailures.includes(error.telemtReason)?error.telemtReason:fallback;
|
const failureReason=(error,fallback)=>error&&canonicalFailures.includes(error.telemtReason)?error.telemtReason:fallback;
|
||||||
const status=__STATUS_FUNCTION__;
|
const status=__STATUS_FUNCTION__;
|
||||||
const socketURL=()=>relayOrigin.replace(/^https:/,'wss:')+'/api/v1/ws';
|
const socketURL=()=>relayBase.replace(/^https:/,'wss:')+'/api/v1/ws';
|
||||||
const requestClient=requestSupport.create({
|
const requestClient=requestSupport.create({
|
||||||
origin:()=>relayOrigin,closed:()=>closed,retryMs:()=>bridgeRetryMs,longPollMs:()=>longPollMs,requestMs:()=>bridgeRequestMs,
|
base:()=>relayBase,closed:()=>closed,retryMs:()=>bridgeRetryMs,longPollMs:()=>longPollMs,requestMs:()=>bridgeRequestMs,
|
||||||
batchLimit:()=>batchLimit,read:(response,limit,exact,signal)=>responseBody.read(response,limit,exact,signal),cancel:responseBody.cancel,
|
batchLimit:()=>batchLimit,read:(response,limit,exact,signal)=>responseBody.read(response,limit,exact,signal),cancel:responseBody.cancel,
|
||||||
failure,reason:failureReason,retrying:()=>status('reconnecting')
|
failure,reason:failureReason,retrying:()=>status('reconnecting')
|
||||||
});
|
});
|
||||||
@@ -478,7 +478,7 @@ async function pollLane(lane){
|
|||||||
}
|
}
|
||||||
function deleteSession(){
|
function deleteSession(){
|
||||||
const token=cleanupToken||sessionToken,headers=canonicalFailures.includes(terminalFailure)?{'X-Carrier-Failure':terminalFailure}:null;
|
const token=cleanupToken||sessionToken,headers=canonicalFailures.includes(terminalFailure)?{'X-Carrier-Failure':terminalFailure}:null;
|
||||||
if(token)fetch(relayOrigin+'/api/v1/session',options('DELETE',token,null,headers,undefined,true)).catch(()=>{});
|
if(token)fetch(relayBase+'/api/v1/session',options('DELETE',token,null,headers,undefined,true)).catch(()=>{});
|
||||||
}
|
}
|
||||||
function close(notifyServer){
|
function close(notifyServer){
|
||||||
if(closed)return;closed=true;if(recoveryController)recoveryController.cancel();rejectRecoveryCommit(failure('network','bridge closed'));if(helloTimer)clearTimeout(helloTimer);helloTimer=null;if(carrierTimer)clearTimeout(carrierTimer);clearProbeTimer();if(schedulerTimer)clearTimeout(schedulerTimer);schedulerTimer=null;if(attemptController)attemptController.abort();if(pollController)pollController.abort();
|
if(closed)return;closed=true;if(recoveryController)recoveryController.cancel();rejectRecoveryCommit(failure('network','bridge closed'));if(helloTimer)clearTimeout(helloTimer);helloTimer=null;if(carrierTimer)clearTimeout(carrierTimer);clearProbeTimer();if(schedulerTimer)clearTimeout(schedulerTimer);schedulerTimer=null;if(attemptController)attemptController.abort();if(pollController)pollController.abort();
|
||||||
|
|||||||
+42
-1
@@ -3,6 +3,7 @@ use super::*;
|
|||||||
fn render_page(bootstrap: &str, candidate_count: usize) -> BridgePage {
|
fn render_page(bootstrap: &str, candidate_count: usize) -> BridgePage {
|
||||||
render(
|
render(
|
||||||
"proxy.example.com",
|
"proxy.example.com",
|
||||||
|
"/",
|
||||||
bootstrap,
|
bootstrap,
|
||||||
2 * 1024 * 1024,
|
2 * 1024 * 1024,
|
||||||
32 * 1024 * 1024,
|
32 * 1024 * 1024,
|
||||||
@@ -26,6 +27,7 @@ fn render_page(bootstrap: &str, candidate_count: usize) -> BridgePage {
|
|||||||
fn render_diagnostic_page(bootstrap: &str) -> BridgePage {
|
fn render_diagnostic_page(bootstrap: &str) -> BridgePage {
|
||||||
render(
|
render(
|
||||||
"proxy.example.com",
|
"proxy.example.com",
|
||||||
|
"/",
|
||||||
bootstrap,
|
bootstrap,
|
||||||
2 * 1024 * 1024,
|
2 * 1024 * 1024,
|
||||||
32 * 1024 * 1024,
|
32 * 1024 * 1024,
|
||||||
@@ -72,6 +74,43 @@ fn rendered_page_contains_bounded_negotiation_contract() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rendered_page_resolves_carriers_against_the_exact_base_path() {
|
||||||
|
let page = render(
|
||||||
|
"proxy.example.com",
|
||||||
|
"/Dobry-Cola/super_app/",
|
||||||
|
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||||
|
2 * 1024 * 1024,
|
||||||
|
32 * 1024 * 1024,
|
||||||
|
16 * 1024,
|
||||||
|
1024,
|
||||||
|
true,
|
||||||
|
4,
|
||||||
|
[3, 5, 8, 12],
|
||||||
|
25,
|
||||||
|
10,
|
||||||
|
90,
|
||||||
|
15,
|
||||||
|
15,
|
||||||
|
120,
|
||||||
|
0,
|
||||||
|
true,
|
||||||
|
&SecureRandom::new(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(page.body.contains(
|
||||||
|
"relayBase=relayOrigin+'/Dobry-Cola/super_app'"
|
||||||
|
));
|
||||||
|
assert!(page.body.contains("fetch(settings.base()+path"));
|
||||||
|
assert!(page.body.contains(
|
||||||
|
"relayBase.replace(/^https:/,'wss:')+'/api/v1/ws'"
|
||||||
|
));
|
||||||
|
assert!(page.body.contains("fetch(relayBase+'/api/v1/diagnostic'"));
|
||||||
|
assert!(page.body.contains("url:()=>relayOrigin+recoveryPath"));
|
||||||
|
assert!(!page.body.contains("/Dobry-Cola/super_app/Dobry-Cola/super_app"));
|
||||||
|
assert!(!page.body.contains("__BASE_PREFIX__"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rendered_page_preserves_the_ios_bootstrap_literal() {
|
fn rendered_page_preserves_the_ios_bootstrap_literal() {
|
||||||
let bootstrap = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
|
let bootstrap = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
|
||||||
@@ -86,6 +125,7 @@ fn rendered_page_preserves_the_ios_bootstrap_literal() {
|
|||||||
fn rendered_page_embeds_the_configured_bridge_timing_policy() {
|
fn rendered_page_embeds_the_configured_bridge_timing_policy() {
|
||||||
let page = render(
|
let page = render(
|
||||||
"proxy.example.com",
|
"proxy.example.com",
|
||||||
|
"/",
|
||||||
"GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG",
|
"GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG",
|
||||||
2 * 1024 * 1024,
|
2 * 1024 * 1024,
|
||||||
32 * 1024 * 1024,
|
32 * 1024 * 1024,
|
||||||
@@ -138,6 +178,7 @@ fn effective_deadline_formula_uses_the_final_checkpoint() {
|
|||||||
fn disabled_negotiation_does_not_arm_a_carrier_deadline() {
|
fn disabled_negotiation_does_not_arm_a_carrier_deadline() {
|
||||||
let page = render(
|
let page = render(
|
||||||
"proxy.example.com",
|
"proxy.example.com",
|
||||||
|
"/",
|
||||||
"DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD",
|
"DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD",
|
||||||
2 * 1024 * 1024,
|
2 * 1024 * 1024,
|
||||||
32 * 1024 * 1024,
|
32 * 1024 * 1024,
|
||||||
@@ -284,7 +325,7 @@ fn enabled_bridge_diagnostics_use_the_https_sideband_only() {
|
|||||||
let page = render_diagnostic_page("JJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJ");
|
let page = render_diagnostic_page("JJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJ");
|
||||||
|
|
||||||
assert!(!page.body.contains("__"));
|
assert!(!page.body.contains("__"));
|
||||||
assert!(page.body.contains("fetch(relayOrigin+'/api/v1/diagnostic'"));
|
assert!(page.body.contains("fetch(relayBase+'/api/v1/diagnostic'"));
|
||||||
assert!(page.body.contains("JSON.stringify({v:1,event})"));
|
assert!(page.body.contains("JSON.stringify({v:1,event})"));
|
||||||
assert!(page.body.contains("'Content-Type':'application/json'"));
|
assert!(page.body.contains("'Content-Type':'application/json'"));
|
||||||
assert!(page.body.contains("keepalive:true"));
|
assert!(page.body.contains("keepalive:true"));
|
||||||
|
|||||||
+21
-15
@@ -28,6 +28,8 @@ mod activity;
|
|||||||
mod body;
|
mod body;
|
||||||
// Canonical capability parsing and complete scans remain isolated from HTTP routing.
|
// Canonical capability parsing and complete scans remain isolated from HTTP routing.
|
||||||
mod capability;
|
mod capability;
|
||||||
|
// Authentic credential containment stays independent from carrier routing.
|
||||||
|
mod secrets;
|
||||||
// Decoy routing and upstream proxying are isolated from carrier authentication.
|
// Decoy routing and upstream proxying are isolated from carrier authentication.
|
||||||
mod decoy;
|
mod decoy;
|
||||||
// Authenticated generated-bridge diagnostics remain outside carrier framing.
|
// Authenticated generated-bridge diagnostics remain outside carrier framing.
|
||||||
@@ -71,13 +73,13 @@ type BoxError = Box<dyn Error + Send + Sync>;
|
|||||||
type HttpBody = UnsyncBoxBody<Bytes, BoxError>;
|
type HttpBody = UnsyncBoxBody<Bytes, BoxError>;
|
||||||
type HttpResponse = Response<HttpBody>;
|
type HttpResponse = Response<HttpBody>;
|
||||||
|
|
||||||
const TRANSPORT_PATHS: [&str; 4] = [
|
const TRANSPORT_SUFFIXES: [&str; 4] = [
|
||||||
"/api/v1/session",
|
"api/v1/session",
|
||||||
"/api/v1/up",
|
"api/v1/up",
|
||||||
"/api/v1/down",
|
"api/v1/down",
|
||||||
"/api/v1/diagnostic",
|
"api/v1/diagnostic",
|
||||||
];
|
];
|
||||||
const WEBSOCKET_PATH: &str = "/api/v1/ws";
|
const WEBSOCKET_SUFFIX: &str = "api/v1/ws";
|
||||||
|
|
||||||
/// Serves one bounded HTTP/1.1 connection accepted from an external TLS terminator.
|
/// Serves one bounded HTTP/1.1 connection accepted from an external TLS terminator.
|
||||||
pub(crate) async fn serve_connection(
|
pub(crate) async fn serve_connection(
|
||||||
@@ -185,8 +187,9 @@ async fn handle_request(
|
|||||||
let Some(vhost) = web_runtime.vhosts.get(host).cloned() else {
|
let Some(vhost) = web_runtime.vhosts.get(host).cloned() else {
|
||||||
return generic_not_found();
|
return generic_not_found();
|
||||||
};
|
};
|
||||||
let path = request.uri().path();
|
secrets::mark_internal_credential(&mut request, web_runtime, &runtime);
|
||||||
if path == WEBSOCKET_PATH {
|
let suffix = request.uri().path().strip_prefix(&vhost.base);
|
||||||
|
if suffix == Some(WEBSOCKET_SUFFIX) {
|
||||||
return websocket::handle(
|
return websocket::handle(
|
||||||
request,
|
request,
|
||||||
peer,
|
peer,
|
||||||
@@ -197,7 +200,7 @@ async fn handle_request(
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
if TRANSPORT_PATHS.contains(&path) {
|
if suffix.is_some_and(|suffix| TRANSPORT_SUFFIXES.contains(&suffix)) {
|
||||||
return handle_api(
|
return handle_api(
|
||||||
request,
|
request,
|
||||||
peer,
|
peer,
|
||||||
@@ -208,7 +211,7 @@ async fn handle_request(
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
if path == "/" && matches!(*request.method(), Method::GET | Method::HEAD) {
|
if suffix == Some("") && matches!(*request.method(), Method::GET | Method::HEAD) {
|
||||||
return handle_root(
|
return handle_root(
|
||||||
request,
|
request,
|
||||||
peer,
|
peer,
|
||||||
@@ -369,6 +372,7 @@ async fn handle_root(
|
|||||||
}
|
}
|
||||||
let page = bridge::render(
|
let page = bridge::render(
|
||||||
&vhost.host,
|
&vhost.host,
|
||||||
|
&vhost.base,
|
||||||
&bootstrap.token,
|
&bootstrap.token,
|
||||||
config.web.limits.carrier_batch_bytes,
|
config.web.limits.carrier_batch_bytes,
|
||||||
config.web.limits.pending_bytes_per_session,
|
config.web.limits.pending_bytes_per_session,
|
||||||
@@ -440,11 +444,13 @@ async fn handle_api(
|
|||||||
let Some(token_hash) = bearer_token_hash(&request) else {
|
let Some(token_hash) = bearer_token_hash(&request) else {
|
||||||
return serve_decoy(request, vhost, true, &runtime).await;
|
return serve_decoy(request, vhost, true, &runtime).await;
|
||||||
};
|
};
|
||||||
match request.uri().path() {
|
match request.uri().path().strip_prefix(&vhost.base) {
|
||||||
"/api/v1/session" => handle_session(request, runtime, vhost, token_hash, client_ip).await,
|
Some("api/v1/session") => {
|
||||||
"/api/v1/up" => handle_up(request, runtime, vhost, token_hash).await,
|
handle_session(request, runtime, vhost, token_hash, client_ip).await
|
||||||
"/api/v1/down" => handle_down(request, runtime, vhost, token_hash).await,
|
}
|
||||||
"/api/v1/diagnostic" => {
|
Some("api/v1/up") => handle_up(request, runtime, vhost, token_hash).await,
|
||||||
|
Some("api/v1/down") => handle_down(request, runtime, vhost, token_hash).await,
|
||||||
|
Some("api/v1/diagnostic") => {
|
||||||
diagnostic::handle(request, runtime, vhost, token_hash, client_ip).await
|
diagnostic::handle(request, runtime, vhost, token_hash, client_ip).await
|
||||||
}
|
}
|
||||||
_ => serve_decoy(request, vhost, true, &runtime).await,
|
_ => serve_decoy(request, vhost, true, &runtime).await,
|
||||||
|
|||||||
@@ -0,0 +1,431 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn bridge_request(path: &str) -> Vec<u8> {
|
||||||
|
format!(
|
||||||
|
"GET {path} HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nConnection: close\r\n\r\n"
|
||||||
|
)
|
||||||
|
.into_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bridge_token(body: &[u8]) -> String {
|
||||||
|
std::str::from_utf8(body)
|
||||||
|
.unwrap()
|
||||||
|
.split_once("bootstrap=\"")
|
||||||
|
.and_then(|(_, suffix)| suffix.split_once('"'))
|
||||||
|
.map(|(token, _)| token.to_string())
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request_without_body(
|
||||||
|
listener: &TcpListener,
|
||||||
|
runtime: &Arc<WebProcessRuntime>,
|
||||||
|
request_head: &[u8],
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let (accepted, client) = tokio::join!(listener.accept(), TcpStream::connect(addr));
|
||||||
|
let (server, peer) = accepted.unwrap();
|
||||||
|
let mut client = client.unwrap();
|
||||||
|
let permit = runtime.try_http_connection().unwrap();
|
||||||
|
let task = tokio::spawn(serve_connection(
|
||||||
|
server,
|
||||||
|
peer,
|
||||||
|
WebClientIpSource::XForwardedFor,
|
||||||
|
Arc::from(["127.0.0.1/32".parse().unwrap()]),
|
||||||
|
Arc::clone(runtime),
|
||||||
|
CancellationToken::new(),
|
||||||
|
permit,
|
||||||
|
));
|
||||||
|
client.write_all(request_head).await.unwrap();
|
||||||
|
let mut response = Vec::new();
|
||||||
|
tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(2),
|
||||||
|
client.read_to_end(&mut response),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("private rejection waited for the request body")
|
||||||
|
.unwrap();
|
||||||
|
task.await.unwrap();
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn base_path_routes_only_the_exact_prefixed_contract() {
|
||||||
|
let capability = [21u8; 32];
|
||||||
|
let config = runtime_config_with_base(capability, WebCarrier::Https, "/dobry-cola/");
|
||||||
|
let generation = test_runtime_generation(1, config);
|
||||||
|
let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(Arc::clone(&generation))));
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(capability);
|
||||||
|
|
||||||
|
let response = request(
|
||||||
|
&listener,
|
||||||
|
&runtime,
|
||||||
|
bridge_request(&format!("/dobry-cola/?bridge={encoded}")),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (headers, body) = split_response(&response);
|
||||||
|
assert!(headers.starts_with(b"HTTP/1.1 200"));
|
||||||
|
assert!(std::str::from_utf8(body)
|
||||||
|
.unwrap()
|
||||||
|
.contains("relayBase=relayOrigin+'/dobry-cola'"));
|
||||||
|
|
||||||
|
for path in [
|
||||||
|
format!("/?bridge={encoded}"),
|
||||||
|
format!("/dobry-cola?bridge={encoded}"),
|
||||||
|
format!("/dobry-cola/nested/?bridge={encoded}"),
|
||||||
|
] {
|
||||||
|
let response = request(&listener, &runtime, bridge_request(&path)).await;
|
||||||
|
let (headers, body) = split_response(&response);
|
||||||
|
assert!(headers.starts_with(b"HTTP/1.1 404"));
|
||||||
|
assert_eq!(response_header(headers, "cache-control"), "no-store");
|
||||||
|
assert_eq!(body, b"not found\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
for path in [
|
||||||
|
"/",
|
||||||
|
"/api/v1/session",
|
||||||
|
"/dobry-cola/unknown?q=1",
|
||||||
|
"/dobry-cola//api/v1/up",
|
||||||
|
"/dobry-cola%2Fapi/v1/up",
|
||||||
|
] {
|
||||||
|
let response = request(&listener, &runtime, bridge_request(path)).await;
|
||||||
|
let (_, body) = split_response(&response);
|
||||||
|
assert_eq!(body, b"<!doctype html><title>decoy</title>");
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.shutdown().await;
|
||||||
|
generation.stop_sessions().await;
|
||||||
|
generation.stop_background_tasks().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn prefixed_https_carrier_creates_uses_and_closes_a_session() {
|
||||||
|
let capability = [22u8; 32];
|
||||||
|
let mut config = runtime_config_with_base(capability, WebCarrier::Https, "/relay/nested/");
|
||||||
|
config.web.timeouts.long_poll_secs = 1;
|
||||||
|
let generation = test_runtime_generation(1, config);
|
||||||
|
let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(Arc::clone(&generation))));
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(capability);
|
||||||
|
|
||||||
|
let bridge = request(
|
||||||
|
&listener,
|
||||||
|
&runtime,
|
||||||
|
bridge_request(&format!("/relay/nested/?bridge={encoded}")),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let bootstrap = bridge_token(split_response(&bridge).1);
|
||||||
|
let hello = frame::encode(FrameType::Hello, 0, &[1]);
|
||||||
|
let mut create = format!(
|
||||||
|
"POST /relay/nested/api/v1/session HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {bootstrap}\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||||
|
hello.len()
|
||||||
|
)
|
||||||
|
.into_bytes();
|
||||||
|
create.extend_from_slice(&hello);
|
||||||
|
let created = request(&listener, &runtime, create).await;
|
||||||
|
let (headers, _) = split_response(&created);
|
||||||
|
assert!(headers.starts_with(b"HTTP/1.1 200"));
|
||||||
|
let session = response_header(headers, "x-session-token").to_string();
|
||||||
|
|
||||||
|
let misplaced = format!(
|
||||||
|
"GET /wrong HTTP/1.1\r\nHost: proxy.example.com\r\nAuthorization: Bearer {session}\r\nConnection: close\r\n\r\n"
|
||||||
|
)
|
||||||
|
.into_bytes();
|
||||||
|
let misplaced = request(&listener, &runtime, misplaced).await;
|
||||||
|
let (headers, body) = split_response(&misplaced);
|
||||||
|
assert!(headers.starts_with(b"HTTP/1.1 404"));
|
||||||
|
assert_eq!(response_header(headers, "cache-control"), "no-store");
|
||||||
|
assert_eq!(body, b"not found\n");
|
||||||
|
|
||||||
|
let pong = frame::encode(FrameType::Pong, 0, &[]);
|
||||||
|
let mut uplink = format!(
|
||||||
|
"POST /relay/nested/api/v1/up HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {session}\r\nContent-Type: application/octet-stream\r\nX-Up-Seq: 1\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||||
|
pong.len()
|
||||||
|
)
|
||||||
|
.into_bytes();
|
||||||
|
uplink.extend_from_slice(&pong);
|
||||||
|
assert!(
|
||||||
|
request(&listener, &runtime, uplink)
|
||||||
|
.await
|
||||||
|
.starts_with(b"HTTP/1.1 204")
|
||||||
|
);
|
||||||
|
|
||||||
|
let downlink = format!(
|
||||||
|
"POST /relay/nested/api/v1/down HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {session}\r\nX-Down-Cursor: 0\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||||
|
)
|
||||||
|
.into_bytes();
|
||||||
|
assert!(
|
||||||
|
request(&listener, &runtime, downlink)
|
||||||
|
.await
|
||||||
|
.starts_with(b"HTTP/1.1 204")
|
||||||
|
);
|
||||||
|
|
||||||
|
let close = format!(
|
||||||
|
"DELETE /relay/nested/api/v1/session HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {session}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||||
|
)
|
||||||
|
.into_bytes();
|
||||||
|
assert!(
|
||||||
|
request(&listener, &runtime, close)
|
||||||
|
.await
|
||||||
|
.starts_with(b"HTTP/1.1 204")
|
||||||
|
);
|
||||||
|
|
||||||
|
runtime.shutdown().await;
|
||||||
|
generation.stop_sessions().await;
|
||||||
|
generation.stop_background_tasks().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn authentic_credentials_never_reach_the_decoy() {
|
||||||
|
let capability = [23u8; 32];
|
||||||
|
let config = runtime_config_with_base(capability, WebCarrier::Https, "/relay/");
|
||||||
|
let generation = test_runtime_generation(1, config);
|
||||||
|
let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(Arc::clone(&generation))));
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(capability);
|
||||||
|
let bridge = request(
|
||||||
|
&listener,
|
||||||
|
&runtime,
|
||||||
|
bridge_request(&format!("/relay/?bridge={encoded}")),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let bootstrap = bridge_token(split_response(&bridge).1);
|
||||||
|
let escaped = format!("%{:02X}{}", bootstrap.as_bytes()[0], &bootstrap[1..]);
|
||||||
|
|
||||||
|
for raw in [
|
||||||
|
format!(
|
||||||
|
"GET /wrong/{bootstrap} HTTP/1.1\r\nHost: proxy.example.com\r\nConnection: close\r\n\r\n"
|
||||||
|
),
|
||||||
|
format!(
|
||||||
|
"GET /wrong/{escaped} HTTP/1.1\r\nHost: proxy.example.com\r\nConnection: close\r\n\r\n"
|
||||||
|
),
|
||||||
|
format!(
|
||||||
|
"GET /wrong HTTP/1.1\r\nHost: proxy.example.com\r\nCookie: opaque={bootstrap}\r\nConnection: close\r\n\r\n"
|
||||||
|
),
|
||||||
|
format!(
|
||||||
|
"GET /wrong HTTP/1.1\r\nHost: proxy.example.com\r\nReferer: https://example.invalid/{encoded}\r\nConnection: close\r\n\r\n"
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let response = request(&listener, &runtime, raw.into_bytes()).await;
|
||||||
|
let (headers, body) = split_response(&response);
|
||||||
|
assert!(headers.starts_with(b"HTTP/1.1 404"));
|
||||||
|
assert_eq!(response_header(headers, "cache-control"), "no-store");
|
||||||
|
assert_eq!(body, b"not found\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut forged = bootstrap.into_bytes();
|
||||||
|
forged[0] = if forged[0] == b'A' { b'B' } else { b'A' };
|
||||||
|
let forged = String::from_utf8(forged).unwrap();
|
||||||
|
let response = request(
|
||||||
|
&listener,
|
||||||
|
&runtime,
|
||||||
|
bridge_request(&format!("/wrong/{forged}")),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
split_response(&response).1,
|
||||||
|
b"<!doctype html><title>decoy</title>"
|
||||||
|
);
|
||||||
|
|
||||||
|
runtime.shutdown().await;
|
||||||
|
generation.stop_sessions().await;
|
||||||
|
generation.stop_background_tasks().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn misplaced_process_token_is_rejected_without_reading_the_body() {
|
||||||
|
let capability = [24u8; 32];
|
||||||
|
let config = runtime_config_with_base(capability, WebCarrier::Https, "/relay/");
|
||||||
|
let generation = test_runtime_generation(1, config);
|
||||||
|
let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(Arc::clone(&generation))));
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(capability);
|
||||||
|
let bridge = request(
|
||||||
|
&listener,
|
||||||
|
&runtime,
|
||||||
|
bridge_request(&format!("/relay/?bridge={encoded}")),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let bootstrap = bridge_token(split_response(&bridge).1);
|
||||||
|
let head = format!(
|
||||||
|
"POST /wrong HTTP/1.1\r\nHost: proxy.example.com\r\nAuthorization: Bearer {bootstrap}\r\nContent-Length: 1048576\r\nConnection: close\r\n\r\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = request_without_body(&listener, &runtime, head.as_bytes()).await;
|
||||||
|
let (headers, body) = split_response(&response);
|
||||||
|
assert!(headers.starts_with(b"HTTP/1.1 404"));
|
||||||
|
assert_eq!(response_header(headers, "cache-control"), "no-store");
|
||||||
|
assert_eq!(body, b"not found\n");
|
||||||
|
|
||||||
|
runtime.shutdown().await;
|
||||||
|
generation.stop_sessions().await;
|
||||||
|
generation.stop_background_tasks().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn process_token_provenance_survives_registry_expiry() {
|
||||||
|
let capability = [25u8; 32];
|
||||||
|
let mut config = runtime_config_with_base(capability, WebCarrier::Https, "/relay/");
|
||||||
|
config.web.timeouts.bootstrap_lifetime_secs = 1;
|
||||||
|
let generation = test_runtime_generation(1, config);
|
||||||
|
let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(Arc::clone(&generation))));
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(capability);
|
||||||
|
let bridge = request(
|
||||||
|
&listener,
|
||||||
|
&runtime,
|
||||||
|
bridge_request(&format!("/relay/?bridge={encoded}")),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let bootstrap = bridge_token(split_response(&bridge).1);
|
||||||
|
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(4), async {
|
||||||
|
loop {
|
||||||
|
let status = serde_json::to_value(runtime.try_status()).unwrap();
|
||||||
|
if status["manager"]["bootstraps"] == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("bootstrap registry entry did not expire");
|
||||||
|
|
||||||
|
let response = request(
|
||||||
|
&listener,
|
||||||
|
&runtime,
|
||||||
|
bridge_request(&format!("/wrong/{bootstrap}")),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (headers, body) = split_response(&response);
|
||||||
|
assert!(headers.starts_with(b"HTTP/1.1 404"));
|
||||||
|
assert_eq!(response_header(headers, "cache-control"), "no-store");
|
||||||
|
assert_eq!(body, b"not found\n");
|
||||||
|
|
||||||
|
runtime.shutdown().await;
|
||||||
|
generation.stop_sessions().await;
|
||||||
|
generation.stop_background_tasks().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn generation_swap_switches_base_path_and_capability_together() {
|
||||||
|
let initial_capability = [26u8; 32];
|
||||||
|
let mut initial =
|
||||||
|
runtime_config_with_base(initial_capability, WebCarrier::Https, "/old-path/");
|
||||||
|
initial.web.limits.max_bootstraps_per_ip = 2;
|
||||||
|
let generation = test_runtime_generation(1, initial);
|
||||||
|
let active = Arc::new(ArcSwap::from(Arc::clone(&generation)));
|
||||||
|
let runtime = WebProcessRuntime::start(Arc::clone(&active));
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let initial_encoded =
|
||||||
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(initial_capability);
|
||||||
|
|
||||||
|
let old_bridge = request(
|
||||||
|
&listener,
|
||||||
|
&runtime,
|
||||||
|
bridge_request(&format!("/old-path/?bridge={initial_encoded}")),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(old_bridge.starts_with(b"HTTP/1.1 200"));
|
||||||
|
|
||||||
|
let replacement_capability = [27u8; 32];
|
||||||
|
let replacement = test_runtime_generation(
|
||||||
|
2,
|
||||||
|
runtime_config_with_base(
|
||||||
|
replacement_capability,
|
||||||
|
WebCarrier::Https,
|
||||||
|
"/new-path/",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
active.store(Arc::clone(&replacement));
|
||||||
|
let stale = request(
|
||||||
|
&listener,
|
||||||
|
&runtime,
|
||||||
|
bridge_request(&format!("/old-path/?bridge={initial_encoded}")),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
split_response(&stale).1,
|
||||||
|
b"<!doctype html><title>decoy</title>"
|
||||||
|
);
|
||||||
|
|
||||||
|
let replacement_encoded =
|
||||||
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(replacement_capability);
|
||||||
|
let current = request(
|
||||||
|
&listener,
|
||||||
|
&runtime,
|
||||||
|
bridge_request(&format!("/new-path/?bridge={replacement_encoded}")),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (headers, body) = split_response(¤t);
|
||||||
|
assert!(headers.starts_with(b"HTTP/1.1 200"));
|
||||||
|
assert!(std::str::from_utf8(body)
|
||||||
|
.unwrap()
|
||||||
|
.contains("relayBase=relayOrigin+'/new-path'"));
|
||||||
|
|
||||||
|
runtime.shutdown().await;
|
||||||
|
generation.stop_sessions().await;
|
||||||
|
generation.stop_background_tasks().await;
|
||||||
|
replacement.stop_sessions().await;
|
||||||
|
replacement.stop_background_tasks().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn prefixed_decoy_request_keeps_its_original_path_and_query() {
|
||||||
|
let site = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let site_addr = site.local_addr().unwrap();
|
||||||
|
let site_task = tokio::spawn(async move {
|
||||||
|
let (mut stream, _) = site.accept().await.unwrap();
|
||||||
|
let mut request = vec![0; 4096];
|
||||||
|
let read = stream.read(&mut request).await.unwrap();
|
||||||
|
request.truncate(read);
|
||||||
|
stream
|
||||||
|
.write_all(
|
||||||
|
b"HTTP/1.1 404 Not Found\r\nContent-Length: 4\r\nConnection: close\r\n\r\nsite",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
request
|
||||||
|
});
|
||||||
|
|
||||||
|
let capability = [28u8; 32];
|
||||||
|
let mut config = runtime_config_with_base(capability, WebCarrier::Https, "/relay/");
|
||||||
|
let profile = Arc::clone(&config.web.runtime.as_ref().unwrap().profiles[0]);
|
||||||
|
let vhost = Arc::new(WebRuntimeVhost {
|
||||||
|
host: "proxy.example.com".to_string(),
|
||||||
|
base: "/relay/".to_string(),
|
||||||
|
decoy_fasttrack_mode: WebDecoyFastTrackMode::Off,
|
||||||
|
decoy: WebRuntimeDecoy::HttpUpstream {
|
||||||
|
addr: site_addr,
|
||||||
|
authority: "decoy.internal".to_string(),
|
||||||
|
},
|
||||||
|
decoy_header_secs: 1,
|
||||||
|
profiles: vec![Arc::clone(&profile)],
|
||||||
|
capabilities: vec![capability].into_boxed_slice(),
|
||||||
|
});
|
||||||
|
let mut vhosts = BTreeMap::new();
|
||||||
|
vhosts.insert("proxy.example.com".to_string(), vhost);
|
||||||
|
config.web.runtime = Some(Arc::new(WebRuntimeConfig {
|
||||||
|
vhosts,
|
||||||
|
profiles: vec![profile],
|
||||||
|
capabilities: vec![capability].into_boxed_slice(),
|
||||||
|
}));
|
||||||
|
let generation = test_runtime_generation(1, config);
|
||||||
|
let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(Arc::clone(&generation))));
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
|
||||||
|
let response = request(
|
||||||
|
&listener,
|
||||||
|
&runtime,
|
||||||
|
bridge_request("/relay/ordinary?q=1"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(response.starts_with(b"HTTP/1.1 404"));
|
||||||
|
assert_eq!(split_response(&response).1, b"site");
|
||||||
|
let forwarded = site_task.await.unwrap();
|
||||||
|
assert!(forwarded.starts_with(b"GET /relay/ordinary?q=1 HTTP/1.1\r\n"));
|
||||||
|
|
||||||
|
runtime.shutdown().await;
|
||||||
|
generation.stop_sessions().await;
|
||||||
|
generation.stop_background_tasks().await;
|
||||||
|
}
|
||||||
@@ -32,28 +32,35 @@ pub(super) fn bridge_candidate(query: Option<&str>) -> BridgeCandidate {
|
|||||||
let Some(value) = query.and_then(|query| query.strip_prefix("bridge=")) else {
|
let Some(value) = query.and_then(|query| query.strip_prefix("bridge=")) else {
|
||||||
return BridgeCandidate::NonCanonical;
|
return BridgeCandidate::NonCanonical;
|
||||||
};
|
};
|
||||||
|
canonical_credential(value.as_bytes())
|
||||||
|
.map(BridgeCandidate::Canonical)
|
||||||
|
.unwrap_or(BridgeCandidate::NonCanonical)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes one exact canonical 32-byte base64url credential.
|
||||||
|
pub(super) fn canonical_credential(value: &[u8]) -> Option<[u8; 32]> {
|
||||||
if value.len() != 43 {
|
if value.len() != 43 {
|
||||||
return BridgeCandidate::NonCanonical;
|
return None;
|
||||||
}
|
}
|
||||||
let mut decoded = [0u8; 32];
|
let mut decoded = [0u8; 32];
|
||||||
let Ok(decoded_len) =
|
let Ok(decoded_len) =
|
||||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.decode_slice(value, &mut decoded)
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.decode_slice(value, &mut decoded)
|
||||||
else {
|
else {
|
||||||
return BridgeCandidate::NonCanonical;
|
return None;
|
||||||
};
|
};
|
||||||
let mut canonical = [0u8; 43];
|
let mut canonical = [0u8; 43];
|
||||||
let Ok(encoded_len) =
|
let Ok(encoded_len) =
|
||||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode_slice(decoded, &mut canonical)
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode_slice(decoded, &mut canonical)
|
||||||
else {
|
else {
|
||||||
return BridgeCandidate::NonCanonical;
|
return None;
|
||||||
};
|
};
|
||||||
if decoded_len != decoded.len()
|
if decoded_len != decoded.len()
|
||||||
|| encoded_len != canonical.len()
|
|| encoded_len != canonical.len()
|
||||||
|| !bool::from(canonical.ct_eq(value.as_bytes()))
|
|| !bool::from(canonical.ct_eq(value))
|
||||||
{
|
{
|
||||||
return BridgeCandidate::NonCanonical;
|
return None;
|
||||||
}
|
}
|
||||||
BridgeCandidate::Canonical(decoded)
|
Some(decoded)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Internal result of one complete capability-table scan.
|
/// Internal result of one complete capability-table scan.
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ where
|
|||||||
B: hyper::body::Body<Data = Bytes> + Send + 'static,
|
B: hyper::body::Body<Data = Bytes> + Send + 'static,
|
||||||
B::Error: Error + Send + Sync + 'static,
|
B::Error: Error + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
|
if super::secrets::has_internal_credential(&request) {
|
||||||
|
return super::response::private_not_found();
|
||||||
|
}
|
||||||
super::set_trace_route(&request, crate::web::trace::TraceRoute::Decoy);
|
super::set_trace_route(&request, crate::web::trace::TraceRoute::Decoy);
|
||||||
if sanitize_transport {
|
if sanitize_transport {
|
||||||
sanitize_transport_request(&mut request);
|
sanitize_transport_request(&mut request);
|
||||||
|
|||||||
@@ -153,12 +153,9 @@ async fn pause_preserves_decoy_retry_and_exact_session_replay() {
|
|||||||
.into_bytes();
|
.into_bytes();
|
||||||
let decoy = request(&listener, &runtime, decoy).await;
|
let decoy = request(&listener, &runtime, decoy).await;
|
||||||
let (decoy_headers, decoy_body) = split_response(&decoy);
|
let (decoy_headers, decoy_body) = split_response(&decoy);
|
||||||
assert!(decoy_headers.starts_with(b"HTTP/1.1 200"));
|
assert!(decoy_headers.starts_with(b"HTTP/1.1 404"));
|
||||||
assert!(
|
assert_eq!(response_header(decoy_headers, "cache-control"), "no-store");
|
||||||
!decoy_body
|
assert_eq!(decoy_body, b"not found\n");
|
||||||
.windows(11)
|
|
||||||
.any(|window| window == b"bootstrap=\"")
|
|
||||||
);
|
|
||||||
|
|
||||||
runtime.resume_operator().await.unwrap();
|
runtime.resume_operator().await.unwrap();
|
||||||
let created = request(&listener, &runtime, create_request(&bootstrap, &hello)).await;
|
let created = request(&listener, &runtime, create_request(&bootstrap, &hello)).await;
|
||||||
|
|||||||
@@ -195,12 +195,12 @@ async fn malformed_or_over_capacity_recovery_is_indistinguishable_from_decoy() {
|
|||||||
|
|
||||||
let malformed = recover(&listener, &runtime, &encoded, "Bearer malformed").await;
|
let malformed = recover(&listener, &runtime, &encoded, "Bearer malformed").await;
|
||||||
let (malformed_headers, malformed_body) = split_response(&malformed);
|
let (malformed_headers, malformed_body) = split_response(&malformed);
|
||||||
assert!(malformed_headers.starts_with(b"HTTP/1.1 200"));
|
assert!(malformed_headers.starts_with(b"HTTP/1.1 404"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
response_header(malformed_headers, "cache-control"),
|
response_header(malformed_headers, "cache-control"),
|
||||||
"no-store"
|
"no-store"
|
||||||
);
|
);
|
||||||
assert_eq!(malformed_body, b"<!doctype html><title>decoy</title>");
|
assert_eq!(malformed_body, b"not found\n");
|
||||||
|
|
||||||
let invalid_capability = recover(
|
let invalid_capability = recover(
|
||||||
&listener,
|
&listener,
|
||||||
@@ -209,7 +209,10 @@ async fn malformed_or_over_capacity_recovery_is_indistinguishable_from_decoy() {
|
|||||||
&format!("Bearer {}", "U".repeat(43)),
|
&format!("Bearer {}", "U".repeat(43)),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(invalid_capability, malformed);
|
let (invalid_headers, invalid_body) = split_response(&invalid_capability);
|
||||||
|
assert!(invalid_headers.starts_with(b"HTTP/1.1 200"));
|
||||||
|
assert_eq!(response_header(invalid_headers, "cache-control"), "no-store");
|
||||||
|
assert_eq!(invalid_body, b"<!doctype html><title>decoy</title>");
|
||||||
|
|
||||||
let malformed_accept = request(
|
let malformed_accept = request(
|
||||||
&listener,
|
&listener,
|
||||||
@@ -231,12 +234,12 @@ async fn malformed_or_over_capacity_recovery_is_indistinguishable_from_decoy() {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let (capacity_headers, capacity_body) = split_response(&over_capacity);
|
let (capacity_headers, capacity_body) = split_response(&over_capacity);
|
||||||
assert!(capacity_headers.starts_with(b"HTTP/1.1 200"));
|
assert!(capacity_headers.starts_with(b"HTTP/1.1 404"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
response_header(capacity_headers, "cache-control"),
|
response_header(capacity_headers, "cache-control"),
|
||||||
"no-store"
|
"no-store"
|
||||||
);
|
);
|
||||||
assert_eq!(capacity_body, b"<!doctype html><title>decoy</title>");
|
assert_eq!(capacity_body, b"not found\n");
|
||||||
|
|
||||||
runtime.shutdown().await;
|
runtime.shutdown().await;
|
||||||
generation.stop_sessions().await;
|
generation.stop_sessions().await;
|
||||||
|
|||||||
@@ -62,6 +62,15 @@ pub(super) fn generic_not_found() -> HttpResponse {
|
|||||||
full_response(StatusCode::NOT_FOUND, Bytes::from_static(b"not found\n"))
|
full_response(StatusCode::NOT_FOUND, Bytes::from_static(b"not found\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds a non-cacheable local rejection for misplaced internal credentials.
|
||||||
|
pub(super) fn private_not_found() -> HttpResponse {
|
||||||
|
let mut response = generic_not_found();
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
/// Builds one in-memory response with an exact content length.
|
/// Builds one in-memory response with an exact content length.
|
||||||
pub(super) fn full_response(status: StatusCode, body: Bytes) -> HttpResponse {
|
pub(super) fn full_response(status: StatusCode, body: Bytes) -> HttpResponse {
|
||||||
let length = body.len();
|
let length = body.len();
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Builds a static-decoy runtime with one explicit WEB endpoint base.
|
||||||
|
pub(in crate::web::http) fn runtime_config_with_base(
|
||||||
|
capability: [u8; 32],
|
||||||
|
carrier: WebCarrier,
|
||||||
|
base: &str,
|
||||||
|
) -> ProxyConfig {
|
||||||
|
runtime_config_with_carriers_and_deadlines(
|
||||||
|
capability,
|
||||||
|
carrier,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
Arc::from([carrier]),
|
||||||
|
TEST_CARRIER_DEADLINES_SECS,
|
||||||
|
base,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
use hyper::Request;
|
||||||
|
|
||||||
|
use super::capability::{canonical_credential, scan_capabilities};
|
||||||
|
use crate::config::WebRuntimeConfig;
|
||||||
|
use crate::web::manager::WebProcessRuntime;
|
||||||
|
|
||||||
|
/// Request extension proving that metadata contains an authentic internal credential.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct InternalCredential;
|
||||||
|
|
||||||
|
/// Marks requests whose metadata contains a capability or process token.
|
||||||
|
pub(super) fn mark_internal_credential<B>(
|
||||||
|
request: &mut Request<B>,
|
||||||
|
config: &WebRuntimeConfig,
|
||||||
|
runtime: &WebProcessRuntime,
|
||||||
|
) {
|
||||||
|
let uri = request.uri();
|
||||||
|
let uri_contains = uri
|
||||||
|
.authority()
|
||||||
|
.is_some_and(|authority| contains_secret(authority.as_str().as_bytes(), config, runtime))
|
||||||
|
|| uri.path_and_query().is_some_and(|path| {
|
||||||
|
contains_secret(path.as_str().as_bytes(), config, runtime)
|
||||||
|
});
|
||||||
|
let headers_contain = request.headers().iter().any(|(name, value)| {
|
||||||
|
contains_secret(name.as_str().as_bytes(), config, runtime)
|
||||||
|
|| contains_secret(value.as_bytes(), config, runtime)
|
||||||
|
});
|
||||||
|
if uri_contains || headers_contain {
|
||||||
|
request.extensions_mut().insert(InternalCredential);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether request metadata was authenticated before routing.
|
||||||
|
pub(super) fn has_internal_credential<B>(request: &Request<B>) -> bool {
|
||||||
|
request.extensions().get::<InternalCredential>().is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contains_secret(text: &[u8], config: &WebRuntimeConfig, runtime: &WebProcessRuntime) -> bool {
|
||||||
|
let mut window = [0; 43];
|
||||||
|
let mut run_len = 0usize;
|
||||||
|
let mut offset = 0usize;
|
||||||
|
while offset < text.len() {
|
||||||
|
let (byte, consumed) = if text[offset] == b'%' && offset + 2 < text.len() {
|
||||||
|
match (hex_value(text[offset + 1]), hex_value(text[offset + 2])) {
|
||||||
|
(Some(high), Some(low)) => ((high << 4) | low, 3),
|
||||||
|
_ => (text[offset], 1),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(text[offset], 1)
|
||||||
|
};
|
||||||
|
offset += consumed;
|
||||||
|
if base64url_byte(byte) {
|
||||||
|
if run_len < window.len() {
|
||||||
|
window[run_len] = byte;
|
||||||
|
run_len += 1;
|
||||||
|
} else {
|
||||||
|
window.copy_within(1.., 0);
|
||||||
|
window[42] = byte;
|
||||||
|
}
|
||||||
|
if run_len >= window.len()
|
||||||
|
&& canonical_credential(&window).is_some_and(|candidate| {
|
||||||
|
bool::from(scan_capabilities(&config.capabilities, &candidate).matched)
|
||||||
|
|| runtime.authentic_token(&candidate)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
run_len = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base64url_byte(value: u8) -> bool {
|
||||||
|
value.is_ascii_alphanumeric() || matches!(value, b'-' | b'_')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_value(value: u8) -> Option<u8> {
|
||||||
|
match value {
|
||||||
|
b'0'..=b'9' => Some(value - b'0'),
|
||||||
|
b'a'..=b'f' => Some(value - b'a' + 10),
|
||||||
|
b'A'..=b'F' => Some(value - b'A' + 10),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-2
@@ -49,11 +49,18 @@ mod recovery_tests;
|
|||||||
// Decoy fast-track routing and telemetry remain isolated from carrier protocol scenarios.
|
// Decoy fast-track routing and telemetry remain isolated from carrier protocol scenarios.
|
||||||
#[path = "decoy_fasttrack_tests.rs"]
|
#[path = "decoy_fasttrack_tests.rs"]
|
||||||
mod decoy_fasttrack_tests;
|
mod decoy_fasttrack_tests;
|
||||||
|
// Base-path routing and credential containment share reference-contract coverage.
|
||||||
|
#[path = "base_path_tests.rs"]
|
||||||
|
mod base_path_tests;
|
||||||
// Raw response parsing helpers are shared by the HTTP integration test modules.
|
// Raw response parsing helpers are shared by the HTTP integration test modules.
|
||||||
#[path = "response_test_support.rs"]
|
#[path = "response_test_support.rs"]
|
||||||
mod response_test_support;
|
mod response_test_support;
|
||||||
|
// Alternate runtime fixtures remain separate from the main integration scenarios.
|
||||||
|
#[path = "runtime_test_support.rs"]
|
||||||
|
mod runtime_test_support;
|
||||||
|
|
||||||
pub(super) use response_test_support::{response_header, split_response};
|
pub(super) use response_test_support::{response_header, split_response};
|
||||||
|
pub(super) use runtime_test_support::runtime_config_with_base;
|
||||||
|
|
||||||
const TEST_CARRIER_DEADLINES_SECS: [u64; 4] = [3, 5, 8, 12];
|
const TEST_CARRIER_DEADLINES_SECS: [u64; 4] = [3, 5, 8, 12];
|
||||||
|
|
||||||
@@ -86,6 +93,7 @@ fn runtime_config_with_carriers(
|
|||||||
carrier_learning,
|
carrier_learning,
|
||||||
carriers,
|
carriers,
|
||||||
TEST_CARRIER_DEADLINES_SECS,
|
TEST_CARRIER_DEADLINES_SECS,
|
||||||
|
"/",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +112,7 @@ pub(super) fn negotiation_runtime_config_with_deadlines(
|
|||||||
carrier_learning,
|
carrier_learning,
|
||||||
carriers,
|
carriers,
|
||||||
carrier_negotiation_deadlines_secs,
|
carrier_negotiation_deadlines_secs,
|
||||||
|
"/",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +123,7 @@ fn runtime_config_with_carriers_and_deadlines(
|
|||||||
carrier_learning: bool,
|
carrier_learning: bool,
|
||||||
carriers: Arc<[WebCarrier]>,
|
carriers: Arc<[WebCarrier]>,
|
||||||
carrier_negotiation_deadlines_secs: [u64; 4],
|
carrier_negotiation_deadlines_secs: [u64; 4],
|
||||||
|
base: &str,
|
||||||
) -> ProxyConfig {
|
) -> ProxyConfig {
|
||||||
let profile = Arc::new(WebRuntimeProfile {
|
let profile = Arc::new(WebRuntimeProfile {
|
||||||
host: "proxy.example.com".to_string(),
|
host: "proxy.example.com".to_string(),
|
||||||
@@ -147,6 +157,7 @@ fn runtime_config_with_carriers_and_deadlines(
|
|||||||
});
|
});
|
||||||
let vhost = Arc::new(WebRuntimeVhost {
|
let vhost = Arc::new(WebRuntimeVhost {
|
||||||
host: "proxy.example.com".to_string(),
|
host: "proxy.example.com".to_string(),
|
||||||
|
base: base.to_string(),
|
||||||
decoy_fasttrack_mode: WebDecoyFastTrackMode::Off,
|
decoy_fasttrack_mode: WebDecoyFastTrackMode::Off,
|
||||||
decoy: WebRuntimeDecoy::StaticDirectory(Arc::clone(&site)),
|
decoy: WebRuntimeDecoy::StaticDirectory(Arc::clone(&site)),
|
||||||
decoy_header_secs: 1,
|
decoy_header_secs: 1,
|
||||||
@@ -159,6 +170,7 @@ fn runtime_config_with_carriers_and_deadlines(
|
|||||||
"other.example.com".to_string(),
|
"other.example.com".to_string(),
|
||||||
Arc::new(WebRuntimeVhost {
|
Arc::new(WebRuntimeVhost {
|
||||||
host: "other.example.com".to_string(),
|
host: "other.example.com".to_string(),
|
||||||
|
base: "/".to_string(),
|
||||||
decoy_fasttrack_mode: WebDecoyFastTrackMode::Off,
|
decoy_fasttrack_mode: WebDecoyFastTrackMode::Off,
|
||||||
decoy: WebRuntimeDecoy::StaticDirectory(site),
|
decoy: WebRuntimeDecoy::StaticDirectory(site),
|
||||||
decoy_header_secs: 1,
|
decoy_header_secs: 1,
|
||||||
@@ -181,6 +193,7 @@ fn runtime_config_with_carriers_and_deadlines(
|
|||||||
config.web.runtime = Some(Arc::new(WebRuntimeConfig {
|
config.web.runtime = Some(Arc::new(WebRuntimeConfig {
|
||||||
vhosts,
|
vhosts,
|
||||||
profiles: vec![profile],
|
profiles: vec![profile],
|
||||||
|
capabilities: vec![capability].into_boxed_slice(),
|
||||||
}));
|
}));
|
||||||
config
|
config
|
||||||
}
|
}
|
||||||
@@ -328,12 +341,12 @@ async fn rejected_bridge_bootstrap_falls_back_to_uncacheable_static_index() {
|
|||||||
|
|
||||||
let fallback_response = request(&listener, &runtime, bridge_request()).await;
|
let fallback_response = request(&listener, &runtime, bridge_request()).await;
|
||||||
let (fallback_headers, fallback_body) = split_response(&fallback_response);
|
let (fallback_headers, fallback_body) = split_response(&fallback_response);
|
||||||
assert!(fallback_headers.starts_with(b"HTTP/1.1 200"));
|
assert!(fallback_headers.starts_with(b"HTTP/1.1 404"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
response_header(fallback_headers, "cache-control"),
|
response_header(fallback_headers, "cache-control"),
|
||||||
"no-store"
|
"no-store"
|
||||||
);
|
);
|
||||||
assert_eq!(fallback_body, b"<!doctype html><title>decoy</title>");
|
assert_eq!(fallback_body, b"not found\n");
|
||||||
|
|
||||||
runtime.shutdown().await;
|
runtime.shutdown().await;
|
||||||
generation.stop_sessions().await;
|
generation.stop_sessions().await;
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ use tokio_util::sync::CancellationToken;
|
|||||||
|
|
||||||
use crate::maestro::generation::{RuntimeGeneration, test_runtime_generation};
|
use crate::maestro::generation::{RuntimeGeneration, test_runtime_generation};
|
||||||
use crate::web::frame::{self, FrameType};
|
use crate::web::frame::{self, FrameType};
|
||||||
use crate::web::http::tests::{negotiation_runtime_config, runtime_config};
|
use crate::web::http::tests::{
|
||||||
|
negotiation_runtime_config, runtime_config, runtime_config_with_base,
|
||||||
|
};
|
||||||
use crate::web::manager::{
|
use crate::web::manager::{
|
||||||
CarrierCapabilities, CarrierClientClass, CarrierFailure, CarrierRequest, WebProcessRuntime,
|
CarrierCapabilities, CarrierClientClass, CarrierFailure, CarrierRequest, WebProcessRuntime,
|
||||||
};
|
};
|
||||||
@@ -210,6 +212,15 @@ async fn upgrade(
|
|||||||
listener: &TcpListener,
|
listener: &TcpListener,
|
||||||
runtime: &Arc<WebProcessRuntime>,
|
runtime: &Arc<WebProcessRuntime>,
|
||||||
protocol: &str,
|
protocol: &str,
|
||||||
|
) -> WebSocketStream<TcpStream> {
|
||||||
|
upgrade_at(listener, runtime, "/api/v1/ws", protocol).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upgrade_at(
|
||||||
|
listener: &TcpListener,
|
||||||
|
runtime: &Arc<WebProcessRuntime>,
|
||||||
|
path: &str,
|
||||||
|
protocol: &str,
|
||||||
) -> WebSocketStream<TcpStream> {
|
) -> WebSocketStream<TcpStream> {
|
||||||
let addr = listener.local_addr().unwrap();
|
let addr = listener.local_addr().unwrap();
|
||||||
let (accepted, client) = tokio::join!(listener.accept(), TcpStream::connect(addr));
|
let (accepted, client) = tokio::join!(listener.accept(), TcpStream::connect(addr));
|
||||||
@@ -226,7 +237,7 @@ async fn upgrade(
|
|||||||
permit,
|
permit,
|
||||||
));
|
));
|
||||||
let request = format!(
|
let request = format!(
|
||||||
"GET /api/v1/ws HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Protocol: {protocol}\r\nCookie: browser-state=allowed\r\n\r\n"
|
"GET {path} HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Protocol: {protocol}\r\nCookie: browser-state=allowed\r\n\r\n"
|
||||||
);
|
);
|
||||||
client.write_all(request.as_bytes()).await.unwrap();
|
client.write_all(request.as_bytes()).await.unwrap();
|
||||||
let mut response = Vec::new();
|
let mut response = Vec::new();
|
||||||
@@ -244,6 +255,38 @@ async fn upgrade(
|
|||||||
WebSocketStream::from_raw_socket(client, Role::Client, None).await
|
WebSocketStream::from_raw_socket(client, Role::Client, None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn prefixed_websocket_route_upgrades_at_the_exact_base() {
|
||||||
|
let live = live_runtime_from_config(
|
||||||
|
runtime_config_with_base([31; 32], WebCarrier::Websocket, "/relay/nested/"),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let (session, _) = create_session(&live.runtime);
|
||||||
|
let protocol = format!("tproxy-v1.{session}");
|
||||||
|
let mut socket = upgrade_at(
|
||||||
|
&listener,
|
||||||
|
&live.runtime,
|
||||||
|
"/relay/nested/api/v1/ws",
|
||||||
|
&protocol,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
socket
|
||||||
|
.send(Message::Ping(Bytes::from_static(b"prefixed")))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let response = tokio::time::timeout(Duration::from_secs(2), socket.next())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response, Message::Pong(Bytes::from_static(b"prefixed")));
|
||||||
|
|
||||||
|
let _ = socket.close(None).await;
|
||||||
|
live.shutdown().await;
|
||||||
|
}
|
||||||
|
|
||||||
fn masked_message(opcode: u8, payload: &[u8], mask: [u8; 4]) -> Vec<u8> {
|
fn masked_message(opcode: u8, payload: &[u8], mask: [u8; 4]) -> Vec<u8> {
|
||||||
masked_frame(true, opcode, payload, mask)
|
masked_frame(true, opcode, payload, mask)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,17 @@ use std::sync::atomic::AtomicU64;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use arc_swap::ArcSwap;
|
use arc_swap::ArcSwap;
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
|
use sha2::Sha256;
|
||||||
|
use subtle::ConstantTimeEq;
|
||||||
use tokio::sync::{Notify, OwnedSemaphorePermit, Semaphore, TryAcquireError};
|
use tokio::sync::{Notify, OwnedSemaphorePermit, Semaphore, TryAcquireError};
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
use tokio_util::task::TaskTracker;
|
use tokio_util::task::TaskTracker;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
use crate::config::{WebCarrier, WebLimitsConfig};
|
use crate::config::{WebCarrier, WebLimitsConfig};
|
||||||
|
use crate::crypto::SecureRandom;
|
||||||
use crate::maestro::generation::RuntimeGeneration;
|
use crate::maestro::generation::RuntimeGeneration;
|
||||||
use crate::web::telemetry::{WebRejectionReason, WebTelemetry};
|
use crate::web::telemetry::{WebRejectionReason, WebTelemetry};
|
||||||
use crate::web::trace::WebTraceStore;
|
use crate::web::trace::WebTraceStore;
|
||||||
@@ -67,6 +72,58 @@ pub(crate) use websocket::{WebSocketConnection, WebSocketKind};
|
|||||||
|
|
||||||
const TOKEN_BYTES: usize = 32;
|
const TOKEN_BYTES: usize = 32;
|
||||||
const CLEANUP_INTERVAL: Duration = Duration::from_secs(1);
|
const CLEANUP_INTERVAL: Duration = Duration::from_secs(1);
|
||||||
|
const TOKEN_NONCE_BYTES: usize = 16;
|
||||||
|
const BOOTSTRAP_TOKEN_CONTEXT: &[u8] = b"telemt-web-bootstrap-token-v1\0";
|
||||||
|
const SESSION_TOKEN_CONTEXT: &[u8] = b"telemt-web-session-token-v1\0";
|
||||||
|
|
||||||
|
/// Distinguishes process-authenticated WEB credential domains.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum TokenKind {
|
||||||
|
Bootstrap,
|
||||||
|
Session,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TokenAuthenticator {
|
||||||
|
key: Zeroizing<[u8; 32]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TokenAuthenticator {
|
||||||
|
fn new(rng: &SecureRandom) -> Self {
|
||||||
|
let mut key = Zeroizing::new([0; 32]);
|
||||||
|
rng.fill(key.as_mut());
|
||||||
|
Self { key }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn issue(&self, kind: TokenKind, nonce: [u8; TOKEN_NONCE_BYTES]) -> [u8; TOKEN_BYTES] {
|
||||||
|
let mut token = [0; TOKEN_BYTES];
|
||||||
|
token[..TOKEN_NONCE_BYTES].copy_from_slice(&nonce);
|
||||||
|
let tag = self.tag(kind, &nonce);
|
||||||
|
token[TOKEN_NONCE_BYTES..].copy_from_slice(&tag[..TOKEN_NONCE_BYTES]);
|
||||||
|
token
|
||||||
|
}
|
||||||
|
|
||||||
|
fn authentic(&self, token: &[u8; TOKEN_BYTES]) -> bool {
|
||||||
|
let nonce = &token[..TOKEN_NONCE_BYTES];
|
||||||
|
let tag = &token[TOKEN_NONCE_BYTES..];
|
||||||
|
let bootstrap = self.tag(TokenKind::Bootstrap, nonce);
|
||||||
|
let session = self.tag(TokenKind::Session, nonce);
|
||||||
|
bool::from(
|
||||||
|
bootstrap[..TOKEN_NONCE_BYTES].ct_eq(tag)
|
||||||
|
| session[..TOKEN_NONCE_BYTES].ct_eq(tag),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tag(&self, kind: TokenKind, nonce: &[u8]) -> [u8; 32] {
|
||||||
|
let mut mac = Hmac::<Sha256>::new_from_slice(self.key.as_ref())
|
||||||
|
.expect("HMAC accepts every WEB token key length");
|
||||||
|
mac.update(match kind {
|
||||||
|
TokenKind::Bootstrap => BOOTSTRAP_TOKEN_CONTEXT,
|
||||||
|
TokenKind::Session => SESSION_TOKEN_CONTEXT,
|
||||||
|
});
|
||||||
|
mac.update(nonce);
|
||||||
|
mac.finalize().into_bytes().into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Stable hash key used for bootstrap and session credentials.
|
/// Stable hash key used for bootstrap and session credentials.
|
||||||
pub(crate) type TokenHash = [u8; TOKEN_BYTES];
|
pub(crate) type TokenHash = [u8; TOKEN_BYTES];
|
||||||
@@ -162,6 +219,7 @@ pub(crate) struct WebProcessRuntime {
|
|||||||
runtime_instance: Arc<str>,
|
runtime_instance: Arc<str>,
|
||||||
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
||||||
trace: Arc<WebTraceStore>,
|
trace: Arc<WebTraceStore>,
|
||||||
|
token_authenticator: TokenAuthenticator,
|
||||||
limits: WebLimitsConfig,
|
limits: WebLimitsConfig,
|
||||||
state: Mutex<ManagerState>,
|
state: Mutex<ManagerState>,
|
||||||
stream_admission: Mutex<StreamAdmissionState>,
|
stream_admission: Mutex<StreamAdmissionState>,
|
||||||
@@ -205,6 +263,7 @@ impl WebProcessRuntime {
|
|||||||
) -> Arc<Self> {
|
) -> Arc<Self> {
|
||||||
let initial_generation = active_runtime.load_full();
|
let initial_generation = active_runtime.load_full();
|
||||||
let config = initial_generation.config();
|
let config = initial_generation.config();
|
||||||
|
let token_authenticator = TokenAuthenticator::new(&initial_generation.rng);
|
||||||
trace.apply_policy(initial_generation.id, &config.web.debug);
|
trace.apply_policy(initial_generation.id, &config.web.debug);
|
||||||
let limits = config.web.limits.clone();
|
let limits = config.web.limits.clone();
|
||||||
let learning_capacity = limits.max_carrier_learning_entries;
|
let learning_capacity = limits.max_carrier_learning_entries;
|
||||||
@@ -231,6 +290,7 @@ impl WebProcessRuntime {
|
|||||||
runtime_instance,
|
runtime_instance,
|
||||||
active_runtime,
|
active_runtime,
|
||||||
trace,
|
trace,
|
||||||
|
token_authenticator,
|
||||||
http_connections: Arc::new(Semaphore::new(limits.max_http_connections)),
|
http_connections: Arc::new(Semaphore::new(limits.max_http_connections)),
|
||||||
http_overload_connections: Arc::new(Semaphore::new(
|
http_overload_connections: Arc::new(Semaphore::new(
|
||||||
limits.max_http_overload_connections,
|
limits.max_http_overload_connections,
|
||||||
@@ -302,6 +362,11 @@ impl WebProcessRuntime {
|
|||||||
&self.telemetry
|
&self.telemetry
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns whether one canonical raw credential was minted by this process.
|
||||||
|
pub(crate) fn authentic_token(&self, token: &[u8; TOKEN_BYTES]) -> bool {
|
||||||
|
self.token_authenticator.authentic(token)
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns whether terminal process shutdown has started.
|
/// Returns whether terminal process shutdown has started.
|
||||||
pub(crate) fn is_shutdown(&self) -> bool {
|
pub(crate) fn is_shutdown(&self) -> bool {
|
||||||
self.shutdown.is_cancelled()
|
self.shutdown.is_cancelled()
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ use super::state::{
|
|||||||
Bootstrap, CarrierChainPhase, allow_rate, evict_oldest_unused_bootstrap, matching_profile,
|
Bootstrap, CarrierChainPhase, allow_rate, evict_oldest_unused_bootstrap, matching_profile,
|
||||||
new_unique_token, profile_key, remove_expired_locked,
|
new_unique_token, profile_key, remove_expired_locked,
|
||||||
};
|
};
|
||||||
use super::{BootstrapResult, ManagerError, TOKEN_BYTES, TokenHash, WebProcessRuntime};
|
use super::{
|
||||||
|
BootstrapResult, ManagerError, TOKEN_BYTES, TokenHash, TokenKind, WebProcessRuntime,
|
||||||
|
};
|
||||||
use crate::config::WebRuntimeProfile;
|
use crate::config::WebRuntimeProfile;
|
||||||
use crate::maestro::generation::RuntimeGeneration;
|
use crate::maestro::generation::RuntimeGeneration;
|
||||||
use crate::web::session::{SessionCloseReason, WebSession};
|
use crate::web::session::{SessionCloseReason, WebSession};
|
||||||
@@ -123,7 +125,12 @@ impl WebProcessRuntime {
|
|||||||
.record_rejection(WebRejectionReason::BootstrapCapacity);
|
.record_rejection(WebRejectionReason::BootstrapCapacity);
|
||||||
return Err(ManagerError::Limit);
|
return Err(ManagerError::Limit);
|
||||||
}
|
}
|
||||||
let Some((token, hash)) = new_unique_token(generation, &state) else {
|
let Some((token, hash)) = new_unique_token(
|
||||||
|
generation,
|
||||||
|
&state,
|
||||||
|
&self.token_authenticator,
|
||||||
|
TokenKind::Bootstrap,
|
||||||
|
) else {
|
||||||
self.record_limit_hit();
|
self.record_limit_hit();
|
||||||
self.telemetry
|
self.telemetry
|
||||||
.record_rejection(WebRejectionReason::BootstrapCapacity);
|
.record_rejection(WebRejectionReason::BootstrapCapacity);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use super::state::{
|
|||||||
profile_key, remember_closed_token_locked, remove_expired_locked,
|
profile_key, remember_closed_token_locked, remove_expired_locked,
|
||||||
};
|
};
|
||||||
use super::{
|
use super::{
|
||||||
CarrierLearningContext, CarrierRequest, CreateResult, ManagerError, TokenHash,
|
CarrierLearningContext, CarrierRequest, CreateResult, ManagerError, TokenHash, TokenKind,
|
||||||
WebProcessRuntime,
|
WebProcessRuntime,
|
||||||
};
|
};
|
||||||
use crate::config::{WebCarrier, WebRuntimeProfile};
|
use crate::config::{WebCarrier, WebRuntimeProfile};
|
||||||
@@ -316,7 +316,12 @@ impl WebProcessRuntime {
|
|||||||
if !admit_initial(self, &mut state, now, client_ip, profile_key, &profile) {
|
if !admit_initial(self, &mut state, now, client_ip, profile_key, &profile) {
|
||||||
return Err(ManagerError::Limit);
|
return Err(ManagerError::Limit);
|
||||||
}
|
}
|
||||||
let Some((session_token, session_hash)) = new_unique_token(&generation, &state) else {
|
let Some((session_token, session_hash)) = new_unique_token(
|
||||||
|
&generation,
|
||||||
|
&state,
|
||||||
|
&self.token_authenticator,
|
||||||
|
TokenKind::Session,
|
||||||
|
) else {
|
||||||
self.record_limit_hit();
|
self.record_limit_hit();
|
||||||
self.telemetry
|
self.telemetry
|
||||||
.record_rejection(crate::web::telemetry::WebRejectionReason::SessionCapacity);
|
.record_rejection(crate::web::telemetry::WebRejectionReason::SessionCapacity);
|
||||||
|
|||||||
@@ -60,7 +60,12 @@ impl WebProcessRuntime {
|
|||||||
self.cancel_replacement(bootstrap_hash, &replacement.old_session);
|
self.cancel_replacement(bootstrap_hash, &replacement.old_session);
|
||||||
return Err(ManagerError::Closed);
|
return Err(ManagerError::Closed);
|
||||||
};
|
};
|
||||||
let Some((session_token, session_hash)) = new_unique_token(&generation, &state) else {
|
let Some((session_token, session_hash)) = new_unique_token(
|
||||||
|
&generation,
|
||||||
|
&state,
|
||||||
|
&self.token_authenticator,
|
||||||
|
TokenKind::Session,
|
||||||
|
) else {
|
||||||
self.record_limit_hit();
|
self.record_limit_hit();
|
||||||
self.telemetry
|
self.telemetry
|
||||||
.record_rejection(crate::web::telemetry::WebRejectionReason::SessionCapacity);
|
.record_rejection(crate::web::telemetry::WebRejectionReason::SessionCapacity);
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ use base64::Engine as _;
|
|||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
use super::{CarrierRequest, ProfileKey, TOKEN_BYTES, TokenHash};
|
use super::{
|
||||||
|
CarrierRequest, ProfileKey, TokenAuthenticator, TokenHash, TokenKind,
|
||||||
|
};
|
||||||
use crate::config::{WebCarrier, WebRuntimeConfig, WebRuntimeProfile, WebTimeoutsConfig};
|
use crate::config::{WebCarrier, WebRuntimeConfig, WebRuntimeProfile, WebTimeoutsConfig};
|
||||||
use crate::maestro::generation::RuntimeGeneration;
|
use crate::maestro::generation::RuntimeGeneration;
|
||||||
use crate::proxy::user_admission::UserSessionRegistration;
|
use crate::proxy::user_admission::UserSessionRegistration;
|
||||||
@@ -233,10 +235,13 @@ impl Default for ManagerState {
|
|||||||
pub(super) fn new_unique_token(
|
pub(super) fn new_unique_token(
|
||||||
generation: &RuntimeGeneration,
|
generation: &RuntimeGeneration,
|
||||||
state: &ManagerState,
|
state: &ManagerState,
|
||||||
|
authenticator: &TokenAuthenticator,
|
||||||
|
kind: TokenKind,
|
||||||
) -> Option<(String, TokenHash)> {
|
) -> Option<(String, TokenHash)> {
|
||||||
for _ in 0..8 {
|
for _ in 0..8 {
|
||||||
let mut raw = [0u8; TOKEN_BYTES];
|
let mut nonce = [0u8; 16];
|
||||||
generation.rng.fill(&mut raw);
|
generation.rng.fill(&mut nonce);
|
||||||
|
let raw = authenticator.issue(kind, nonce);
|
||||||
let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw);
|
let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw);
|
||||||
let hash = Sha256::digest(raw).into();
|
let hash = Sha256::digest(raw).into();
|
||||||
if !state.bootstraps.contains_key(&hash)
|
if !state.bootstraps.contains_key(&hash)
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ fn test_runtime_with_dc(
|
|||||||
config.web.runtime = Some(Arc::new(WebRuntimeConfig {
|
config.web.runtime = Some(Arc::new(WebRuntimeConfig {
|
||||||
vhosts: BTreeMap::new(),
|
vhosts: BTreeMap::new(),
|
||||||
profiles: vec![Arc::clone(&profile)],
|
profiles: vec![Arc::clone(&profile)],
|
||||||
|
capabilities: vec![[7; 32]].into_boxed_slice(),
|
||||||
}));
|
}));
|
||||||
config.rebuild_runtime_user_auth().unwrap();
|
config.rebuild_runtime_user_auth().unwrap();
|
||||||
let limits = config.web.limits.clone();
|
let limits = config.web.limits.clone();
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ fn runtime(admission: bool) -> TestRuntime {
|
|||||||
config.web.runtime = Some(Arc::new(WebRuntimeConfig {
|
config.web.runtime = Some(Arc::new(WebRuntimeConfig {
|
||||||
vhosts: BTreeMap::new(),
|
vhosts: BTreeMap::new(),
|
||||||
profiles: vec![Arc::clone(&profile)],
|
profiles: vec![Arc::clone(&profile)],
|
||||||
|
capabilities: vec![[7; 32]].into_boxed_slice(),
|
||||||
}));
|
}));
|
||||||
config.rebuild_runtime_user_auth().unwrap();
|
config.rebuild_runtime_user_auth().unwrap();
|
||||||
let limits = config.web.limits.clone();
|
let limits = config.web.limits.clone();
|
||||||
|
|||||||
Reference in New Issue
Block a user