mirror of
https://github.com/telemt/telemt.git
synced 2026-09-26 20:45:58 +03:00
Compare commits
1 Commits
thundering
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 935b5a3527 |
@@ -36,7 +36,6 @@ nix = { version = "0.31.3", default-features = false, features = [
|
|||||||
"net",
|
"net",
|
||||||
"user",
|
"user",
|
||||||
"process",
|
"process",
|
||||||
"dir",
|
|
||||||
"fs",
|
"fs",
|
||||||
"signal",
|
"signal",
|
||||||
] }
|
] }
|
||||||
|
|||||||
+30
-10
@@ -1,12 +1,32 @@
|
|||||||
# LICENSING
|
# Licensing
|
||||||
## Licenses for Versions
|
|
||||||
| Version ≥ | Version ≤ | License |
|
|
||||||
|-----------|-----------|---------------|
|
|
||||||
| 1.0 | 3.3.17 | NO LICNESE |
|
|
||||||
| 3.3.18 | 3.4.0 | TELEMT PL 3 |
|
|
||||||
|
|
||||||
### License Types
|
Telemt is currently distributed under the **TELEMT Public License**
|
||||||
- **NO LICENSE** = ***ALL RIGHT RESERVED***
|
|
||||||
- **TELEMT PL** - special Telemt Public License based on Apache License 2 principles
|
|
||||||
|
|
||||||
## [Telemt Public License 3](https://github.com/telemt/telemt/blob/main/LICENSE)
|
For the complete and legally binding license terms, see [`LICENSE`](./LICENSE).
|
||||||
|
|
||||||
|
The license file accompanying a particular release is authoritative for that release. A future release may use a different version of the TELEMT Public License or another license without changing the licensing terms of previously released versions.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Under the TELEMT Public License, you may generally:
|
||||||
|
|
||||||
|
* use and reproduce the Software;
|
||||||
|
* modify it and create derivative works;
|
||||||
|
* publish and redistribute source or binary copies;
|
||||||
|
* sublicense the Software;
|
||||||
|
* sell copies or products containing the Software.
|
||||||
|
|
||||||
|
The license includes several conditions and limitations, including:
|
||||||
|
|
||||||
|
* copyright notices, attribution notices, and the license text must be preserved when redistributing Telemt-derived code;
|
||||||
|
* modified versions must be clearly identified as modified and include a brief description of the changes;
|
||||||
|
* modified or redistributed versions must not be presented as official Telemt releases;
|
||||||
|
* the license does not grant rights to Telemt trademarks, logos, or branding;
|
||||||
|
* contributors provide a patent license for patent claims necessarily infringed by their contributions, subject to defensive termination provisions;
|
||||||
|
* contributions submitted for inclusion in Telemt are licensed under the same license unless explicitly stated otherwise;
|
||||||
|
* operators of publicly accessible services using Telemt are encouraged to provide attribution;
|
||||||
|
* providing corresponding source code and build instructions with binary distributions is encouraged, but not required.
|
||||||
|
|
||||||
|
The Software is provided **WITHOUT ANY KIND OF WARRANTY**, as described in [`LICENSE`](./LICENSE).
|
||||||
|
|
||||||
|
This document is only a summary intended to make Telemt's licensing easier to understand. If anything in this document conflicts with the applicable [`LICENSE`](./LICENSE), the license text controls.
|
||||||
|
|||||||
+4
-47
@@ -6,13 +6,10 @@ use serde_json::Value as Json;
|
|||||||
use toml::Value as Toml;
|
use toml::Value as Toml;
|
||||||
|
|
||||||
use super::ApiShared;
|
use super::ApiShared;
|
||||||
#[cfg(test)]
|
|
||||||
use super::config_store::write_atomic;
|
|
||||||
use super::config_store::{
|
use super::config_store::{
|
||||||
EDITABLE_SECTIONS, EDITABLE_SERVER_FIELDS, compute_snapshot_revision, is_editable_section,
|
EDITABLE_SECTIONS, EDITABLE_SERVER_FIELDS, compute_snapshot_revision, is_editable_section,
|
||||||
load_candidate_snapshot, load_config_snapshot, render_server_listeners,
|
load_candidate_snapshot, load_config_snapshot, render_server_listeners,
|
||||||
render_top_level_section, resolve_single_source_owner, upsert_toml_table,
|
render_top_level_section, resolve_single_source_owner, upsert_toml_table, write_atomic,
|
||||||
write_atomic_if_unchanged,
|
|
||||||
};
|
};
|
||||||
use super::model::ApiFailure;
|
use super::model::ApiFailure;
|
||||||
use crate::config::ProxyConfig;
|
use crate::config::ProxyConfig;
|
||||||
@@ -46,10 +43,7 @@ pub(super) struct PatchConfigResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct PreparedConfigPatch {
|
struct PreparedConfigPatch {
|
||||||
config_path: PathBuf,
|
|
||||||
expected_revision: String,
|
|
||||||
owner_path: PathBuf,
|
owner_path: PathBuf,
|
||||||
expected_owner_contents: String,
|
|
||||||
owner_contents: String,
|
owner_contents: String,
|
||||||
desired_config: Arc<ProxyConfig>,
|
desired_config: Arc<ProxyConfig>,
|
||||||
response: PatchConfigResponse,
|
response: PatchConfigResponse,
|
||||||
@@ -62,21 +56,6 @@ pub(super) async fn patch_config(
|
|||||||
expected_revision: Option<String>,
|
expected_revision: Option<String>,
|
||||||
reload_request: Option<ReloadRequest>,
|
reload_request: Option<ReloadRequest>,
|
||||||
shared: &ApiShared,
|
shared: &ApiShared,
|
||||||
) -> Result<PatchConfigResponse, ApiFailure> {
|
|
||||||
let shared = shared.clone();
|
|
||||||
shared
|
|
||||||
.clone()
|
|
||||||
.run_mutation_completion(async move {
|
|
||||||
patch_config_to_completion(patch_json, expected_revision, reload_request, &shared).await
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn patch_config_to_completion(
|
|
||||||
patch_json: Json,
|
|
||||||
expected_revision: Option<String>,
|
|
||||||
reload_request: Option<ReloadRequest>,
|
|
||||||
shared: &ApiShared,
|
|
||||||
) -> Result<PatchConfigResponse, ApiFailure> {
|
) -> Result<PatchConfigResponse, ApiFailure> {
|
||||||
let _guard = shared.mutation_lock.lock().await;
|
let _guard = shared.mutation_lock.lock().await;
|
||||||
let active_config = shared.active_runtime.load_full().config();
|
let active_config = shared.active_runtime.load_full().config();
|
||||||
@@ -98,14 +77,7 @@ async fn patch_config_to_completion(
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
prepared.response.revision = write_atomic_if_unchanged(
|
write_atomic(prepared.owner_path, prepared.owner_contents).await?;
|
||||||
prepared.config_path,
|
|
||||||
prepared.expected_revision,
|
|
||||||
prepared.owner_path,
|
|
||||||
prepared.expected_owner_contents,
|
|
||||||
prepared.owner_contents,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
if let Some(reservation) = reservation {
|
if let Some(reservation) = reservation {
|
||||||
prepared.response.reload = Some(reservation.enqueue(prepared.desired_config));
|
prepared.response.reload = Some(reservation.enqueue(prepared.desired_config));
|
||||||
}
|
}
|
||||||
@@ -138,16 +110,8 @@ pub(super) async fn apply_patch_to_path(
|
|||||||
patch_json: &Json,
|
patch_json: &Json,
|
||||||
expected_revision: Option<String>,
|
expected_revision: Option<String>,
|
||||||
) -> Result<PatchConfigResponse, ApiFailure> {
|
) -> Result<PatchConfigResponse, ApiFailure> {
|
||||||
let mut prepared = prepare_patch_to_path(config_path, patch_json, expected_revision).await?;
|
let prepared = prepare_patch_to_path(config_path, patch_json, expected_revision).await?;
|
||||||
let revision = write_atomic_if_unchanged(
|
write_atomic(prepared.owner_path, prepared.owner_contents).await?;
|
||||||
prepared.config_path,
|
|
||||||
prepared.expected_revision,
|
|
||||||
prepared.owner_path,
|
|
||||||
prepared.expected_owner_contents,
|
|
||||||
prepared.owner_contents,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
prepared.response.revision = revision;
|
|
||||||
Ok(prepared.response)
|
Ok(prepared.response)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,7 +197,6 @@ async fn prepare_patch_to_path(
|
|||||||
.get(&owner_path)
|
.get(&owner_path)
|
||||||
.cloned()
|
.cloned()
|
||||||
.ok_or_else(|| ApiFailure::internal("config source owner is missing from snapshot"))?;
|
.ok_or_else(|| ApiFailure::internal("config source owner is missing from snapshot"))?;
|
||||||
let expected_owner_contents = owner_contents.clone();
|
|
||||||
for section in &touched {
|
for section in &touched {
|
||||||
if *section == "server" {
|
if *section == "server" {
|
||||||
let rendered = render_server_listeners(&requested_cfg)?;
|
let rendered = render_server_listeners(&requested_cfg)?;
|
||||||
@@ -270,10 +233,7 @@ async fn prepare_patch_to_path(
|
|||||||
deferred_process_fields(&old_cfg, &new_cfg).map_err(ApiFailure::bad_request)?;
|
deferred_process_fields(&old_cfg, &new_cfg).map_err(ApiFailure::bad_request)?;
|
||||||
|
|
||||||
Ok(PreparedConfigPatch {
|
Ok(PreparedConfigPatch {
|
||||||
config_path: config_path.to_path_buf(),
|
|
||||||
expected_revision: current,
|
|
||||||
owner_path,
|
owner_path,
|
||||||
expected_owner_contents,
|
|
||||||
owner_contents,
|
owner_contents,
|
||||||
desired_config: Arc::new(new_cfg),
|
desired_config: Arc::new(new_cfg),
|
||||||
response: PatchConfigResponse {
|
response: PatchConfigResponse {
|
||||||
@@ -447,9 +407,6 @@ fn deep_merge(base: &mut Toml, patch: &Toml) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[path = "config_edit/base_path_tests.rs"]
|
|
||||||
mod base_path_tests;
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "config_edit/tests.rs"]
|
#[path = "config_edit/tests.rs"]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
use super::*;
|
|
||||||
|
|
||||||
fn web_config() -> &'static str {
|
|
||||||
r#"
|
|
||||||
[access.users]
|
|
||||||
alice = "000102030405060708090a0b0c0d0e0f"
|
|
||||||
|
|
||||||
[[server.listeners]]
|
|
||||||
ip = "127.0.0.1"
|
|
||||||
port = 18080
|
|
||||||
transport = "web"
|
|
||||||
proxy_protocol = false
|
|
||||||
web_client_ip_source = "x_forwarded_for"
|
|
||||||
web_trusted_proxy_cidrs = ["127.0.0.1/32"]
|
|
||||||
|
|
||||||
[web]
|
|
||||||
enabled = true
|
|
||||||
|
|
||||||
[[web.vhosts]]
|
|
||||||
host = "proxy.example.com"
|
|
||||||
public_addr = "203.0.113.10:443"
|
|
||||||
|
|
||||||
[web.vhosts.decoy]
|
|
||||||
mode = "http_upstream"
|
|
||||||
upstream = "http://127.0.0.1:18081"
|
|
||||||
|
|
||||||
[[web.vhosts.profiles]]
|
|
||||||
user = "alice"
|
|
||||||
secret_mode = "plain"
|
|
||||||
"#
|
|
||||||
}
|
|
||||||
|
|
||||||
fn vhosts_patch(base_path: &str) -> Json {
|
|
||||||
serde_json::json!({
|
|
||||||
"web": {
|
|
||||||
"vhosts": [{
|
|
||||||
"host": "proxy.example.com",
|
|
||||||
"base_path": base_path,
|
|
||||||
"public_addr": "203.0.113.10:443",
|
|
||||||
"decoy": {
|
|
||||||
"mode": "http_upstream",
|
|
||||||
"upstream": "http://127.0.0.1:18081"
|
|
||||||
},
|
|
||||||
"profiles": [{
|
|
||||||
"user": "alice",
|
|
||||||
"secret_mode": "plain"
|
|
||||||
}]
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn config_api_applies_valid_base_path_and_preserves_source_on_invalid_patch() {
|
|
||||||
let directory = tempfile::tempdir().unwrap();
|
|
||||||
let path = directory.path().join("config.toml");
|
|
||||||
std::fs::write(&path, web_config()).unwrap();
|
|
||||||
let active = ProxyConfig::load(&path).unwrap();
|
|
||||||
|
|
||||||
let mut response = apply_patch_to_path(&path, &vhosts_patch("MixedCase/path"), None)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let desired = ProxyConfig::load(&path).unwrap();
|
|
||||||
let resolved = reconcile_runtime_effect(&mut response, &active, &desired).unwrap();
|
|
||||||
assert!(!response.restart_required);
|
|
||||||
assert!(response.runtime_reload_required);
|
|
||||||
assert!(!response.process_restart_required);
|
|
||||||
assert!(response.deferred_process_fields.is_empty());
|
|
||||||
assert!(resolved.runtime_changed);
|
|
||||||
assert_eq!(desired.web.vhosts[0].base_path, "MixedCase/path");
|
|
||||||
assert_eq!(
|
|
||||||
resolved.effective.web.runtime.as_ref().unwrap().vhosts["proxy.example.com"].base,
|
|
||||||
"/MixedCase/path/"
|
|
||||||
);
|
|
||||||
|
|
||||||
let (managed, _revision) = read_managed_config(&path).await.unwrap();
|
|
||||||
let vhosts = managed["web"]["vhosts"].as_array().unwrap();
|
|
||||||
assert_eq!(vhosts[0]["base_path"].as_str(), Some("MixedCase/path"));
|
|
||||||
assert!(managed["web"].get("runtime").is_none());
|
|
||||||
assert!(!managed.as_table().unwrap().contains_key("access"));
|
|
||||||
|
|
||||||
let before_invalid = std::fs::read(&path).unwrap();
|
|
||||||
let error = apply_patch_to_path(&path, &vhosts_patch("/invalid"), None)
|
|
||||||
.await
|
|
||||||
.unwrap_err();
|
|
||||||
assert_eq!(error.status, hyper::StatusCode::BAD_REQUEST);
|
|
||||||
assert_eq!(std::fs::read(&path).unwrap(), before_invalid);
|
|
||||||
}
|
|
||||||
@@ -107,24 +107,14 @@ async fn read_managed_config_exposes_web_without_runtime_or_access_secrets() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn patch_web_debug_is_hot_and_limits_are_process_deferred() {
|
async fn patch_web_debug_is_hot_and_limits_are_process_deferred() {
|
||||||
let (path, _directory) = temp_config("[web]\nenabled = false\n");
|
let (path, _directory) = temp_config("[web]\nenabled = false\n");
|
||||||
let active = ProxyConfig::load(&path).unwrap();
|
|
||||||
let debug_patch: Json = serde_json::json!({
|
let debug_patch: Json = serde_json::json!({
|
||||||
"web": {"debug": {
|
"web": {"debug": {"enabled": true, "capture_headers": false}}
|
||||||
"enabled": true,
|
|
||||||
"sideband": true,
|
|
||||||
"capture_headers": false
|
|
||||||
}}
|
|
||||||
});
|
});
|
||||||
let mut debug = apply_patch_to_path(&path, &debug_patch, None)
|
let debug = apply_patch_to_path(&path, &debug_patch, None)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let desired = ProxyConfig::load(&path).unwrap();
|
|
||||||
reconcile_runtime_effect(&mut debug, &active, &desired).unwrap();
|
|
||||||
assert!(!debug.restart_required);
|
|
||||||
assert!(debug.runtime_reload_required);
|
|
||||||
assert!(!debug.process_restart_required);
|
assert!(!debug.process_restart_required);
|
||||||
assert!(debug.changed.iter().any(|section| section == "web"));
|
assert!(debug.changed.iter().any(|section| section == "web"));
|
||||||
assert!(desired.web.debug.sideband);
|
|
||||||
|
|
||||||
let limits_patch: Json = serde_json::json!({
|
let limits_patch: Json = serde_json::json!({
|
||||||
"web": {"limits": {"max_http_connections": 2049}}
|
"web": {"limits": {"max_http_connections": 2049}}
|
||||||
@@ -323,30 +313,6 @@ async fn patch_writes_the_included_section_owner_only() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn prepared_patch_rejects_external_edit_before_commit() {
|
|
||||||
let (path, _directory) = temp_config("[censorship]\ntls_domain = \"old.example\"\n");
|
|
||||||
let patch: Json = serde_json::json!({
|
|
||||||
"censorship": {"tls_domain": "api.example"}
|
|
||||||
});
|
|
||||||
let prepared = prepare_patch_to_path(&path, &patch, None).await.unwrap();
|
|
||||||
let external = "[censorship]\ntls_domain = \"external.example\"\n";
|
|
||||||
tokio::fs::write(&path, external).await.unwrap();
|
|
||||||
|
|
||||||
let error = write_atomic_if_unchanged(
|
|
||||||
prepared.config_path,
|
|
||||||
prepared.expected_revision,
|
|
||||||
prepared.owner_path,
|
|
||||||
prepared.expected_owner_contents,
|
|
||||||
prepared.owner_contents,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap_err();
|
|
||||||
|
|
||||||
assert_eq!(error.code, "revision_conflict");
|
|
||||||
assert_eq!(tokio::fs::read_to_string(&path).await.unwrap(), external);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn patch_rejects_multiple_source_owners_without_writing() {
|
async fn patch_rejects_multiple_source_owners_without_writing() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
+12
-23
@@ -10,15 +10,12 @@ use super::model::ApiFailure;
|
|||||||
|
|
||||||
// Source-preserving TOML rendering and atomic persistence helpers.
|
// Source-preserving TOML rendering and atomic persistence helpers.
|
||||||
mod persistence;
|
mod persistence;
|
||||||
// Compare-and-replace file persistence and metadata preservation.
|
|
||||||
mod atomic;
|
|
||||||
|
|
||||||
pub(in crate::api) use atomic::{write_atomic, write_atomic_if_unchanged};
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use persistence::{find_toml_table_bounds, render_access_section, save_sections_to_disk};
|
use persistence::{find_toml_table_bounds, render_access_section, save_sections_to_disk};
|
||||||
pub(in crate::api) use persistence::{
|
pub(in crate::api) use persistence::{
|
||||||
render_server_listeners, render_top_level_section, save_access_sections_to_disk,
|
render_server_listeners, render_top_level_section, save_access_sections_to_disk,
|
||||||
save_access_sections_to_disk_if_revision, upsert_toml_table,
|
upsert_toml_table, write_atomic,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
@@ -57,21 +54,22 @@ pub(super) fn parse_if_match(headers: &hyper::HeaderMap) -> Option<String> {
|
|||||||
.map(|value| value.trim_matches('"').to_string())
|
.map(|value| value.trim_matches('"').to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Loads one mutation base and validates its revision from the same source snapshot.
|
pub(super) async fn ensure_expected_revision(
|
||||||
pub(super) async fn load_config_for_mutation(
|
|
||||||
config_path: &Path,
|
config_path: &Path,
|
||||||
expected_revision: Option<&str>,
|
expected_revision: Option<&str>,
|
||||||
) -> Result<(ProxyConfig, String), ApiFailure> {
|
) -> Result<(), ApiFailure> {
|
||||||
let loaded = load_config_snapshot(config_path, false).await?;
|
let Some(expected) = expected_revision else {
|
||||||
let revision = compute_snapshot_revision(&loaded);
|
return Ok(());
|
||||||
if expected_revision.is_some_and(|expected| expected != revision) {
|
};
|
||||||
|
let current = current_revision(config_path).await?;
|
||||||
|
if current != expected {
|
||||||
return Err(ApiFailure::new(
|
return Err(ApiFailure::new(
|
||||||
hyper::StatusCode::CONFLICT,
|
hyper::StatusCode::CONFLICT,
|
||||||
"revision_conflict",
|
"revision_conflict",
|
||||||
"Config revision mismatch",
|
"Config revision mismatch",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok((loaded.config, revision))
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn current_revision(config_path: &Path) -> Result<String, ApiFailure> {
|
pub(super) async fn current_revision(config_path: &Path) -> Result<String, ApiFailure> {
|
||||||
@@ -245,24 +243,15 @@ pub(super) async fn load_candidate_snapshot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_source_path(path: &Path) -> PathBuf {
|
fn normalize_source_path(path: &Path) -> PathBuf {
|
||||||
let absolute = if path.is_absolute() {
|
path.canonicalize().unwrap_or_else(|_| {
|
||||||
|
if path.is_absolute() {
|
||||||
path.to_path_buf()
|
path.to_path_buf()
|
||||||
} else {
|
} else {
|
||||||
std::env::current_dir()
|
std::env::current_dir()
|
||||||
.map(|cwd| cwd.join(path))
|
.map(|cwd| cwd.join(path))
|
||||||
.unwrap_or_else(|_| path.to_path_buf())
|
.unwrap_or_else(|_| path.to_path_buf())
|
||||||
};
|
|
||||||
let mut normalized = PathBuf::new();
|
|
||||||
for component in absolute.components() {
|
|
||||||
match component {
|
|
||||||
std::path::Component::CurDir => {}
|
|
||||||
std::path::Component::ParentDir => {
|
|
||||||
normalized.pop();
|
|
||||||
}
|
}
|
||||||
component => normalized.push(component.as_os_str()),
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
normalized
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn load_config_from_disk(config_path: &Path) -> Result<ProxyConfig, ApiFailure> {
|
pub(super) async fn load_config_from_disk(config_path: &Path) -> Result<ProxyConfig, ApiFailure> {
|
||||||
|
|||||||
@@ -1,432 +0,0 @@
|
|||||||
use std::fs::File;
|
|
||||||
use std::io::{Read, Write};
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
use nix::fcntl::{Flock, FlockArg, OFlag, openat, renameat};
|
|
||||||
#[cfg(unix)]
|
|
||||||
use nix::sys::stat::Mode;
|
|
||||||
#[cfg(unix)]
|
|
||||||
use nix::unistd::{UnlinkatFlags, fsync, unlinkat};
|
|
||||||
#[cfg(unix)]
|
|
||||||
use tracing::warn;
|
|
||||||
|
|
||||||
use super::compute_source_revision;
|
|
||||||
use crate::api::model::ApiFailure;
|
|
||||||
use crate::config::ProxyConfig;
|
|
||||||
#[cfg(unix)]
|
|
||||||
use crate::util::secure_fs::AnchoredPath;
|
|
||||||
|
|
||||||
const MAX_CONFIG_SOURCE_BYTES: u64 = 8 * 1024 * 1024;
|
|
||||||
|
|
||||||
enum AtomicWriteError {
|
|
||||||
Conflict,
|
|
||||||
ReadGraph(String),
|
|
||||||
Io(std::io::Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ExistingTarget {
|
|
||||||
contents: String,
|
|
||||||
metadata: std::fs::Metadata,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct GraphFence<'a> {
|
|
||||||
config_path: &'a Path,
|
|
||||||
expected_revision: &'a str,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ConfigWriteLock {
|
|
||||||
#[cfg(unix)]
|
|
||||||
_file: Flock<File>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ConfigWriteLock {
|
|
||||||
fn acquire(path: &Path) -> std::io::Result<Self> {
|
|
||||||
let path = normalize_path(path);
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
let lock_path = sibling_lock_path(&path);
|
|
||||||
let anchored = AnchoredPath::open_creating_parents(&lock_path, 0o750)?;
|
|
||||||
let descriptor = openat(
|
|
||||||
anchored.parent(),
|
|
||||||
anchored.name(),
|
|
||||||
OFlag::O_RDWR | OFlag::O_CREAT | OFlag::O_NOFOLLOW | OFlag::O_CLOEXEC,
|
|
||||||
Mode::from_bits_truncate(0o600),
|
|
||||||
)
|
|
||||||
.map_err(errno_to_io)?;
|
|
||||||
let file = File::from(descriptor);
|
|
||||||
let metadata = file.metadata()?;
|
|
||||||
if !metadata.is_file() || metadata.nlink() != 1 {
|
|
||||||
return Err(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidInput,
|
|
||||||
"config lock must be a regular file with one directory entry",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let file = Flock::lock(file, FlockArg::LockExclusive)
|
|
||||||
.map_err(|(_, error)| errno_to_io(error))?;
|
|
||||||
Ok(Self { _file: file })
|
|
||||||
}
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
{
|
|
||||||
let _ = path;
|
|
||||||
Ok(Self {})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Replaces one config source through a same-directory rename after syncing file data.
|
|
||||||
pub(in crate::api) async fn write_atomic(
|
|
||||||
path: PathBuf,
|
|
||||||
contents: String,
|
|
||||||
) -> Result<(), ApiFailure> {
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
let _lock = ConfigWriteLock::acquire(&path)?;
|
|
||||||
write_atomic_sync(&path, None, &contents, None).map(|_| ())
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|error| ApiFailure::internal(format!("failed to join writer: {error}")))?
|
|
||||||
.map_err(|error| ApiFailure::internal(format!("failed to write config: {error}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Replaces one source only if both its graph revision and owner contents are unchanged.
|
|
||||||
pub(in crate::api) async fn write_atomic_if_unchanged(
|
|
||||||
config_path: PathBuf,
|
|
||||||
expected_revision: String,
|
|
||||||
path: PathBuf,
|
|
||||||
expected_contents: String,
|
|
||||||
contents: String,
|
|
||||||
) -> Result<String, ApiFailure> {
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
let config_path = normalize_path(&config_path);
|
|
||||||
let path = normalize_path(&path);
|
|
||||||
// Every API mutation locks the root source so writes to different includes serialize.
|
|
||||||
let _lock = ConfigWriteLock::acquire(&config_path).map_err(AtomicWriteError::Io)?;
|
|
||||||
let graph = ProxyConfig::read_source_graph(&config_path)
|
|
||||||
.map_err(|error| AtomicWriteError::ReadGraph(error.to_string()))?;
|
|
||||||
if compute_source_revision(&graph) != expected_revision {
|
|
||||||
return Err(AtomicWriteError::Conflict);
|
|
||||||
}
|
|
||||||
write_atomic_sync(
|
|
||||||
&path,
|
|
||||||
Some(&expected_contents),
|
|
||||||
&contents,
|
|
||||||
Some(GraphFence {
|
|
||||||
config_path: &config_path,
|
|
||||||
expected_revision: &expected_revision,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.map_err(|error| {
|
|
||||||
if error.kind() == std::io::ErrorKind::AlreadyExists {
|
|
||||||
AtomicWriteError::Conflict
|
|
||||||
} else {
|
|
||||||
AtomicWriteError::Io(error)
|
|
||||||
}
|
|
||||||
})?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
AtomicWriteError::Io(std::io::Error::other(
|
|
||||||
"config graph fence did not produce a committed revision",
|
|
||||||
))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|error| ApiFailure::internal(format!("failed to join writer: {error}")))?
|
|
||||||
.map_err(|error| match error {
|
|
||||||
AtomicWriteError::Conflict => revision_conflict(),
|
|
||||||
AtomicWriteError::ReadGraph(error) => {
|
|
||||||
ApiFailure::internal(format!("failed to verify config graph: {error}"))
|
|
||||||
}
|
|
||||||
AtomicWriteError::Io(error) => {
|
|
||||||
ApiFailure::internal(format!("failed to write config: {error}"))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn revision_conflict() -> ApiFailure {
|
|
||||||
ApiFailure::new(
|
|
||||||
hyper::StatusCode::CONFLICT,
|
|
||||||
"revision_conflict",
|
|
||||||
"Config revision changed before persistence",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sibling_lock_path(path: &Path) -> PathBuf {
|
|
||||||
let mut name = path
|
|
||||||
.file_name()
|
|
||||||
.unwrap_or_else(|| std::ffi::OsStr::new("config.toml"))
|
|
||||||
.to_os_string();
|
|
||||||
name.push(".lock");
|
|
||||||
path.parent().unwrap_or_else(|| Path::new(".")).join(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_path(path: &Path) -> PathBuf {
|
|
||||||
let absolute = if path.is_absolute() {
|
|
||||||
path.to_path_buf()
|
|
||||||
} else {
|
|
||||||
std::env::current_dir()
|
|
||||||
.map(|current| current.join(path))
|
|
||||||
.unwrap_or_else(|_| path.to_path_buf())
|
|
||||||
};
|
|
||||||
let mut normalized = PathBuf::new();
|
|
||||||
for component in absolute.components() {
|
|
||||||
match component {
|
|
||||||
std::path::Component::CurDir => {}
|
|
||||||
std::path::Component::ParentDir => {
|
|
||||||
normalized.pop();
|
|
||||||
}
|
|
||||||
component => normalized.push(component.as_os_str()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fenced_post_commit_revision(
|
|
||||||
fence: GraphFence<'_>,
|
|
||||||
path: &Path,
|
|
||||||
contents: &str,
|
|
||||||
) -> std::io::Result<String> {
|
|
||||||
let mut graph = ProxyConfig::read_source_graph(fence.config_path)
|
|
||||||
.map_err(|error| std::io::Error::other(error.to_string()))?;
|
|
||||||
if compute_source_revision(&graph) != fence.expected_revision {
|
|
||||||
return Err(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::AlreadyExists,
|
|
||||||
"config graph changed during persistence",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let path = normalize_path(path);
|
|
||||||
let Some(owner) = graph.source_contents.get_mut(&path) else {
|
|
||||||
return Err(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidInput,
|
|
||||||
"config source owner left the source graph during persistence",
|
|
||||||
));
|
|
||||||
};
|
|
||||||
*owner = contents.to_string();
|
|
||||||
Ok(compute_source_revision(&graph))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn open_existing_target(anchored: &AnchoredPath) -> std::io::Result<Option<ExistingTarget>> {
|
|
||||||
let descriptor = match openat(
|
|
||||||
anchored.parent(),
|
|
||||||
anchored.name(),
|
|
||||||
OFlag::O_RDONLY | OFlag::O_NONBLOCK | OFlag::O_NOFOLLOW | OFlag::O_CLOEXEC,
|
|
||||||
Mode::empty(),
|
|
||||||
) {
|
|
||||||
Ok(descriptor) => descriptor,
|
|
||||||
Err(nix::errno::Errno::ENOENT) => return Ok(None),
|
|
||||||
Err(error) => return Err(errno_to_io(error)),
|
|
||||||
};
|
|
||||||
let mut file = File::from(descriptor);
|
|
||||||
let metadata = file.metadata()?;
|
|
||||||
if !metadata.is_file() || metadata.nlink() != 1 || metadata.len() > MAX_CONFIG_SOURCE_BYTES {
|
|
||||||
return Err(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidInput,
|
|
||||||
"config target must be a bounded regular file with one directory entry",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let mut contents = String::with_capacity(metadata.len() as usize);
|
|
||||||
Read::take(&mut file, MAX_CONFIG_SOURCE_BYTES + 1).read_to_string(&mut contents)?;
|
|
||||||
if contents.len() as u64 > MAX_CONFIG_SOURCE_BYTES {
|
|
||||||
return Err(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidData,
|
|
||||||
"config target exceeds the source size limit",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let completed = file.metadata()?;
|
|
||||||
if !same_target(&metadata, &completed) || metadata.len() != completed.len() {
|
|
||||||
return Err(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidData,
|
|
||||||
"config target changed while it was read",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(Some(ExistingTarget { contents, metadata }))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
fn open_existing_target(path: &Path) -> std::io::Result<Option<ExistingTarget>> {
|
|
||||||
let mut file = match File::open(path) {
|
|
||||||
Ok(file) => file,
|
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
|
||||||
Err(error) => return Err(error),
|
|
||||||
};
|
|
||||||
let metadata = file.metadata()?;
|
|
||||||
if !metadata.is_file() || metadata.len() > MAX_CONFIG_SOURCE_BYTES {
|
|
||||||
return Err(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidInput,
|
|
||||||
"config target must be a bounded regular file",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let mut contents = String::new();
|
|
||||||
file.read_to_string(&mut contents)?;
|
|
||||||
Ok(Some(ExistingTarget { contents, metadata }))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn same_target(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
left.dev() == right.dev() && left.ino() == right.ino()
|
|
||||||
}
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
{
|
|
||||||
left.len() == right.len() && left.modified().ok() == right.modified().ok()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn write_atomic_sync(
|
|
||||||
path: &Path,
|
|
||||||
expected_contents: Option<&str>,
|
|
||||||
contents: &str,
|
|
||||||
graph_fence: Option<GraphFence<'_>>,
|
|
||||||
) -> std::io::Result<Option<String>> {
|
|
||||||
let anchored = AnchoredPath::open_creating_parents(path, 0o750)?;
|
|
||||||
let existing = open_existing_target(&anchored)?;
|
|
||||||
validate_expected_contents(existing.as_ref(), expected_contents)?;
|
|
||||||
let temp_name = format!(
|
|
||||||
".{}.tmp-{}",
|
|
||||||
path.file_name()
|
|
||||||
.and_then(|name| name.to_str())
|
|
||||||
.unwrap_or("config.toml"),
|
|
||||||
rand::random::<u64>()
|
|
||||||
);
|
|
||||||
let descriptor = openat(
|
|
||||||
anchored.parent(),
|
|
||||||
temp_name.as_str(),
|
|
||||||
OFlag::O_WRONLY | OFlag::O_CREAT | OFlag::O_EXCL | OFlag::O_NOFOLLOW | OFlag::O_CLOEXEC,
|
|
||||||
Mode::from_bits_truncate(0o600),
|
|
||||||
)
|
|
||||||
.map_err(errno_to_io)?;
|
|
||||||
let write_result = write_and_publish(
|
|
||||||
descriptor,
|
|
||||||
path,
|
|
||||||
&anchored,
|
|
||||||
&temp_name,
|
|
||||||
existing.as_ref(),
|
|
||||||
contents,
|
|
||||||
graph_fence,
|
|
||||||
);
|
|
||||||
if write_result.is_err() {
|
|
||||||
let _ = unlinkat(
|
|
||||||
anchored.parent(),
|
|
||||||
temp_name.as_str(),
|
|
||||||
UnlinkatFlags::NoRemoveDir,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
write_result
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn write_and_publish(
|
|
||||||
descriptor: std::os::fd::OwnedFd,
|
|
||||||
path: &Path,
|
|
||||||
anchored: &AnchoredPath,
|
|
||||||
temp_name: &str,
|
|
||||||
existing: Option<&ExistingTarget>,
|
|
||||||
contents: &str,
|
|
||||||
graph_fence: Option<GraphFence<'_>>,
|
|
||||||
) -> std::io::Result<Option<String>> {
|
|
||||||
let mut file = File::from(descriptor);
|
|
||||||
if let Some(existing) = existing {
|
|
||||||
use nix::unistd::{Gid, Uid, fchown};
|
|
||||||
|
|
||||||
fchown(
|
|
||||||
&file,
|
|
||||||
Some(Uid::from_raw(existing.metadata.uid())),
|
|
||||||
Some(Gid::from_raw(existing.metadata.gid())),
|
|
||||||
)
|
|
||||||
.map_err(errno_to_io)?;
|
|
||||||
file.set_permissions(std::fs::Permissions::from_mode(
|
|
||||||
existing.metadata.mode() & 0o7777,
|
|
||||||
))?;
|
|
||||||
}
|
|
||||||
file.write_all(contents.as_bytes())?;
|
|
||||||
file.sync_all()?;
|
|
||||||
let current = open_existing_target(anchored)?;
|
|
||||||
if !target_unchanged(existing, current.as_ref()) {
|
|
||||||
return Err(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::AlreadyExists,
|
|
||||||
"config target changed during persistence",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let committed_revision = graph_fence
|
|
||||||
.map(|fence| fenced_post_commit_revision(fence, path, contents))
|
|
||||||
.transpose()?;
|
|
||||||
renameat(
|
|
||||||
anchored.parent(),
|
|
||||||
temp_name,
|
|
||||||
anchored.parent(),
|
|
||||||
anchored.name(),
|
|
||||||
)
|
|
||||||
.map_err(errno_to_io)?;
|
|
||||||
// Rename is the commit boundary. A later directory-sync error cannot be reported as an
|
|
||||||
// uncommitted mutation because mandatory in-process publication must still run.
|
|
||||||
if let Err(error) = fsync(anchored.parent()).map_err(errno_to_io) {
|
|
||||||
warn!(
|
|
||||||
path = %path.display(),
|
|
||||||
%error,
|
|
||||||
"Config rename committed but directory durability sync failed"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(committed_revision)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
fn write_atomic_sync(
|
|
||||||
path: &Path,
|
|
||||||
expected_contents: Option<&str>,
|
|
||||||
contents: &str,
|
|
||||||
graph_fence: Option<GraphFence<'_>>,
|
|
||||||
) -> std::io::Result<Option<String>> {
|
|
||||||
let parent = path.parent().unwrap_or_else(|| Path::new("."));
|
|
||||||
std::fs::create_dir_all(parent)?;
|
|
||||||
let existing = open_existing_target(path)?;
|
|
||||||
validate_expected_contents(existing.as_ref(), expected_contents)?;
|
|
||||||
let temp = parent.join(format!(".telemt.tmp-{}", rand::random::<u64>()));
|
|
||||||
std::fs::write(&temp, contents)?;
|
|
||||||
let current = open_existing_target(path)?;
|
|
||||||
if !target_unchanged(existing.as_ref(), current.as_ref()) {
|
|
||||||
let _ = std::fs::remove_file(&temp);
|
|
||||||
return Err(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::AlreadyExists,
|
|
||||||
"config target changed during persistence",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let committed_revision = graph_fence
|
|
||||||
.map(|fence| fenced_post_commit_revision(fence, path, contents))
|
|
||||||
.transpose()?;
|
|
||||||
std::fs::rename(temp, path)?;
|
|
||||||
Ok(committed_revision)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_expected_contents(
|
|
||||||
existing: Option<&ExistingTarget>,
|
|
||||||
expected_contents: Option<&str>,
|
|
||||||
) -> std::io::Result<()> {
|
|
||||||
if expected_contents
|
|
||||||
.is_some_and(|expected| existing.is_none_or(|target| target.contents != expected))
|
|
||||||
{
|
|
||||||
return Err(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::AlreadyExists,
|
|
||||||
"config source changed before persistence",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn target_unchanged(existing: Option<&ExistingTarget>, current: Option<&ExistingTarget>) -> bool {
|
|
||||||
match (existing, current) {
|
|
||||||
(Some(expected), Some(current)) => {
|
|
||||||
same_target(&expected.metadata, ¤t.metadata)
|
|
||||||
&& expected.contents == current.contents
|
|
||||||
}
|
|
||||||
(None, None) => true,
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn errno_to_io(error: nix::errno::Errno) -> std::io::Error {
|
|
||||||
std::io::Error::from_raw_os_error(error as i32)
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::path::Path;
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::config::{ProxyConfig, RateLimitBps};
|
use crate::config::{ProxyConfig, RateLimitBps};
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
use super::atomic::write_atomic;
|
|
||||||
use super::atomic::write_atomic_if_unchanged;
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use super::compute_revision;
|
use super::compute_revision;
|
||||||
use super::{
|
use super::{
|
||||||
@@ -101,22 +99,8 @@ pub(in crate::api) async fn save_access_sections_to_disk(
|
|||||||
config_path: &Path,
|
config_path: &Path,
|
||||||
cfg: &ProxyConfig,
|
cfg: &ProxyConfig,
|
||||||
sections: &[AccessSection],
|
sections: &[AccessSection],
|
||||||
) -> Result<String, ApiFailure> {
|
|
||||||
save_access_sections_to_disk_if_revision(config_path, cfg, sections, None).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Persists access tables only while the complete source graph remains unchanged.
|
|
||||||
pub(in crate::api) async fn save_access_sections_to_disk_if_revision(
|
|
||||||
config_path: &Path,
|
|
||||||
cfg: &ProxyConfig,
|
|
||||||
sections: &[AccessSection],
|
|
||||||
expected_revision: Option<&str>,
|
|
||||||
) -> Result<String, ApiFailure> {
|
) -> Result<String, ApiFailure> {
|
||||||
let loaded = load_config_snapshot(config_path, false).await?;
|
let loaded = load_config_snapshot(config_path, false).await?;
|
||||||
let loaded_revision = compute_snapshot_revision(&loaded);
|
|
||||||
if expected_revision.is_some_and(|expected| expected != loaded_revision) {
|
|
||||||
return Err(revision_conflict());
|
|
||||||
}
|
|
||||||
let mut applied = Vec::new();
|
let mut applied = Vec::new();
|
||||||
for section in sections {
|
for section in sections {
|
||||||
if applied.contains(section) {
|
if applied.contains(section) {
|
||||||
@@ -133,7 +117,7 @@ pub(in crate::api) async fn save_access_sections_to_disk_if_revision(
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
if applied.is_empty() {
|
if applied.is_empty() {
|
||||||
return Ok(loaded_revision);
|
return Ok(compute_snapshot_revision(&loaded));
|
||||||
}
|
}
|
||||||
|
|
||||||
let targets = applied
|
let targets = applied
|
||||||
@@ -146,7 +130,6 @@ pub(in crate::api) async fn save_access_sections_to_disk_if_revision(
|
|||||||
.get(&owner_path)
|
.get(&owner_path)
|
||||||
.cloned()
|
.cloned()
|
||||||
.ok_or_else(|| ApiFailure::internal("config source owner is missing from snapshot"))?;
|
.ok_or_else(|| ApiFailure::internal("config source owner is missing from snapshot"))?;
|
||||||
let expected_owner_contents = owner_contents.clone();
|
|
||||||
for section in applied {
|
for section in applied {
|
||||||
let rendered = render_access_section(cfg, section)?;
|
let rendered = render_access_section(cfg, section)?;
|
||||||
owner_contents = upsert_toml_table(&owner_contents, section.table_name(), &rendered);
|
owner_contents = upsert_toml_table(&owner_contents, section.table_name(), &rendered);
|
||||||
@@ -159,15 +142,8 @@ pub(in crate::api) async fn save_access_sections_to_disk_if_revision(
|
|||||||
owner_contents.clone(),
|
owner_contents.clone(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let _candidate_revision = compute_snapshot_revision(&candidate);
|
let revision = compute_snapshot_revision(&candidate);
|
||||||
let revision = write_atomic_if_unchanged(
|
write_atomic(owner_path, owner_contents).await?;
|
||||||
config_path.to_path_buf(),
|
|
||||||
loaded_revision,
|
|
||||||
owner_path,
|
|
||||||
expected_owner_contents,
|
|
||||||
owner_contents,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(revision)
|
Ok(revision)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,10 +373,46 @@ fn find_all_table_blocks(source: &str, table_name: &str) -> Vec<(usize, usize)>
|
|||||||
blocks
|
blocks
|
||||||
}
|
}
|
||||||
|
|
||||||
fn revision_conflict() -> ApiFailure {
|
/// Replaces one config source through a durable same-directory rename.
|
||||||
ApiFailure::new(
|
pub(in crate::api) async fn write_atomic(
|
||||||
hyper::StatusCode::CONFLICT,
|
path: PathBuf,
|
||||||
"revision_conflict",
|
contents: String,
|
||||||
"Config revision changed before persistence",
|
) -> Result<(), ApiFailure> {
|
||||||
)
|
tokio::task::spawn_blocking(move || write_atomic_sync(&path, &contents))
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiFailure::internal(format!("failed to join writer: {}", e)))?
|
||||||
|
.map_err(|e| ApiFailure::internal(format!("failed to write config: {}", e)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_atomic_sync(path: &Path, contents: &str) -> std::io::Result<()> {
|
||||||
|
let parent = path.parent().unwrap_or_else(|| Path::new("."));
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
|
||||||
|
let tmp_name = format!(
|
||||||
|
".{}.tmp-{}",
|
||||||
|
path.file_name()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("config.toml"),
|
||||||
|
rand::random::<u64>()
|
||||||
|
);
|
||||||
|
let tmp_path = parent.join(tmp_name);
|
||||||
|
|
||||||
|
let write_result = (|| {
|
||||||
|
let mut file = std::fs::OpenOptions::new()
|
||||||
|
.create_new(true)
|
||||||
|
.write(true)
|
||||||
|
.open(&tmp_path)?;
|
||||||
|
file.write_all(contents.as_bytes())?;
|
||||||
|
file.sync_all()?;
|
||||||
|
std::fs::rename(&tmp_path, path)?;
|
||||||
|
if let Ok(dir) = std::fs::File::open(parent) {
|
||||||
|
let _ = dir.sync_all();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
|
||||||
|
if write_result.is_err() {
|
||||||
|
let _ = std::fs::remove_file(&tmp_path);
|
||||||
|
}
|
||||||
|
write_result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -260,104 +260,6 @@ async fn access_mutation_writes_only_the_single_included_owner() {
|
|||||||
assert_eq!(revision, current_revision(&root).await.unwrap());
|
assert_eq!(revision, current_revision(&root).await.unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn access_mutation_rejects_source_graph_change_after_snapshot() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let root = dir.path().join("config.toml");
|
|
||||||
let included = dir.path().join("users.toml");
|
|
||||||
let root_body = "include = \"users.toml\"\n[censorship]\ntls_domain = \"one.example\"\n";
|
|
||||||
let external_root = "include = \"users.toml\"\n[censorship]\ntls_domain = \"two.example\"\n";
|
|
||||||
let included_body = "[access.users]\nalice = \"00000000000000000000000000000000\"\n";
|
|
||||||
tokio::fs::write(&root, root_body).await.unwrap();
|
|
||||||
tokio::fs::write(&included, included_body).await.unwrap();
|
|
||||||
let (mut cfg, revision) = load_config_for_mutation(&root, None).await.unwrap();
|
|
||||||
cfg.access.users.insert(
|
|
||||||
"bob".to_string(),
|
|
||||||
"11111111111111111111111111111111".to_string(),
|
|
||||||
);
|
|
||||||
tokio::fs::write(&root, external_root).await.unwrap();
|
|
||||||
|
|
||||||
let error = save_access_sections_to_disk_if_revision(
|
|
||||||
&root,
|
|
||||||
&cfg,
|
|
||||||
&[AccessSection::Users],
|
|
||||||
Some(&revision),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap_err();
|
|
||||||
|
|
||||||
assert_eq!(error.code, "revision_conflict");
|
|
||||||
assert_eq!(
|
|
||||||
tokio::fs::read_to_string(&root).await.unwrap(),
|
|
||||||
external_root
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
tokio::fs::read_to_string(&included).await.unwrap(),
|
|
||||||
included_body
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[tokio::test]
|
|
||||||
async fn atomic_write_preserves_existing_file_mode() {
|
|
||||||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
|
||||||
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let path = dir.path().join("config.toml");
|
|
||||||
tokio::fs::write(&path, "old").await.unwrap();
|
|
||||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap();
|
|
||||||
let before = std::fs::metadata(&path).unwrap();
|
|
||||||
|
|
||||||
write_atomic(path.clone(), "new".to_string()).await.unwrap();
|
|
||||||
|
|
||||||
let after = std::fs::metadata(&path).unwrap();
|
|
||||||
assert_eq!(after.mode() & 0o7777, 0o640);
|
|
||||||
assert_eq!(after.uid(), before.uid());
|
|
||||||
assert_eq!(after.gid(), before.gid());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn config_sidecar_lock_serializes_competing_revision_writers() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let path = dir.path().join("config.toml");
|
|
||||||
let original = concat!(
|
|
||||||
"[censorship]\n",
|
|
||||||
"tls_domain = \"original.example\"\n",
|
|
||||||
"[access.users]\n",
|
|
||||||
"alice = \"00000000000000000000000000000000\"\n"
|
|
||||||
);
|
|
||||||
tokio::fs::write(&path, original).await.unwrap();
|
|
||||||
let graph = ProxyConfig::read_source_graph(&path).unwrap();
|
|
||||||
let revision = compute_source_revision(&graph);
|
|
||||||
|
|
||||||
let first = tokio::spawn(write_atomic_if_unchanged(
|
|
||||||
path.clone(),
|
|
||||||
revision.clone(),
|
|
||||||
path.clone(),
|
|
||||||
original.to_string(),
|
|
||||||
original.replace("original.example", "first.example"),
|
|
||||||
));
|
|
||||||
let second = tokio::spawn(write_atomic_if_unchanged(
|
|
||||||
path.clone(),
|
|
||||||
revision,
|
|
||||||
path.clone(),
|
|
||||||
original.to_string(),
|
|
||||||
original.replace("original.example", "second.example"),
|
|
||||||
));
|
|
||||||
let first = first.await.unwrap();
|
|
||||||
let second = second.await.unwrap();
|
|
||||||
|
|
||||||
assert_ne!(first.is_ok(), second.is_ok());
|
|
||||||
let (winner_revision, conflict) = match (first, second) {
|
|
||||||
(Ok(revision), Err(error)) | (Err(error), Ok(revision)) => (revision, error),
|
|
||||||
_ => unreachable!("exactly one cooperative writer must commit"),
|
|
||||||
};
|
|
||||||
assert_eq!(conflict.code, "revision_conflict");
|
|
||||||
assert_eq!(winner_revision, current_revision(&path).await.unwrap());
|
|
||||||
let persisted = tokio::fs::read_to_string(&path).await.unwrap();
|
|
||||||
assert!(persisted.contains("first.example") || persisted.contains("second.example"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn access_mutation_rejects_sections_with_different_source_owners() {
|
async fn access_mutation_rejects_sections_with_different_source_owners() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -405,17 +307,3 @@ fn render_user_rate_limits_section() {
|
|||||||
assert!(rendered.starts_with("[access.user_rate_limits]\n"));
|
assert!(rendered.starts_with("[access.user_rate_limits]\n"));
|
||||||
assert!(rendered.contains("alice = { up_bps = 1024, down_bps = 2048 }"));
|
assert!(rendered.contains("alice = { up_bps = 1024, down_bps = 2048 }"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[test]
|
|
||||||
fn source_owner_normalization_preserves_symlinks() {
|
|
||||||
use std::os::unix::fs::symlink;
|
|
||||||
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let real = dir.path().join("real.toml");
|
|
||||||
let linked = dir.path().join("linked.toml");
|
|
||||||
std::fs::write(&real, "").unwrap();
|
|
||||||
symlink(&real, &linked).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(normalize_source_path(&linked), linked);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ pub(super) async fn create_user_route(
|
|||||||
}
|
}
|
||||||
let expected_revision = parse_if_match(req.headers());
|
let expected_revision = parse_if_match(req.headers());
|
||||||
let body = read_json::<CreateUserRequest>(req.into_body(), body_limit).await?;
|
let body = read_json::<CreateUserRequest>(req.into_body(), body_limit).await?;
|
||||||
|
let requested_enabled = body.enabled;
|
||||||
let result = create_user(body, expected_revision, shared).await;
|
let result = create_user(body, expected_revision, shared).await;
|
||||||
let (mut data, revision) = match result {
|
let (mut data, revision) = match result {
|
||||||
Ok(ok) => ok,
|
Ok(ok) => ok,
|
||||||
@@ -33,6 +34,25 @@ pub(super) async fn create_user_route(
|
|||||||
};
|
};
|
||||||
let runtime_cfg = config_rx.borrow().clone();
|
let runtime_cfg = config_rx.borrow().clone();
|
||||||
data.user.in_runtime = runtime_cfg.access.users.contains_key(&data.user.username);
|
data.user.in_runtime = runtime_cfg.access.users.contains_key(&data.user.username);
|
||||||
|
if let Some(enabled) = requested_enabled {
|
||||||
|
shared
|
||||||
|
.proxy_shared
|
||||||
|
.set_user_enabled(&data.user.username, enabled);
|
||||||
|
if !enabled {
|
||||||
|
let cancelled = shared
|
||||||
|
.proxy_shared
|
||||||
|
.cancel_user_sessions(&data.user.username);
|
||||||
|
if cancelled > 0 {
|
||||||
|
shared.runtime_events.record(
|
||||||
|
"api.user.disable.runtime",
|
||||||
|
format!(
|
||||||
|
"username={} cancelled_sessions={}",
|
||||||
|
data.user.username, cancelled
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
shared.runtime_events.record(
|
shared.runtime_events.record(
|
||||||
"api.user.create.ok",
|
"api.user.create.ok",
|
||||||
format!("username={}", data.user.username),
|
format!("username={}", data.user.username),
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ pub(super) async fn handle(
|
|||||||
};
|
};
|
||||||
let runtime_cfg = config_rx.borrow().clone();
|
let runtime_cfg = config_rx.borrow().clone();
|
||||||
data.in_runtime = runtime_cfg.access.users.contains_key(&data.username);
|
data.in_runtime = runtime_cfg.access.users.contains_key(&data.username);
|
||||||
|
shared.proxy_shared.set_user_enabled(base_user, true);
|
||||||
shared
|
shared
|
||||||
.runtime_events
|
.runtime_events
|
||||||
.record("api.user.enable.ok", format!("username={}", base_user));
|
.record("api.user.enable.ok", format!("username={}", base_user));
|
||||||
@@ -103,9 +104,15 @@ pub(super) async fn handle(
|
|||||||
};
|
};
|
||||||
let runtime_cfg = config_rx.borrow().clone();
|
let runtime_cfg = config_rx.borrow().clone();
|
||||||
data.in_runtime = runtime_cfg.access.users.contains_key(&data.username);
|
data.in_runtime = runtime_cfg.access.users.contains_key(&data.username);
|
||||||
shared
|
let newly_disabled = shared.proxy_shared.set_user_enabled(base_user, false);
|
||||||
.runtime_events
|
let cancelled = shared.proxy_shared.cancel_user_sessions(base_user);
|
||||||
.record("api.user.disable.ok", format!("username={}", base_user));
|
shared.runtime_events.record(
|
||||||
|
"api.user.disable.ok",
|
||||||
|
format!(
|
||||||
|
"username={} newly_disabled={} cancelled_sessions={}",
|
||||||
|
base_user, newly_disabled, cancelled
|
||||||
|
),
|
||||||
|
);
|
||||||
let status = if data.in_runtime {
|
let status = if data.in_runtime {
|
||||||
StatusCode::OK
|
StatusCode::OK
|
||||||
} else {
|
} else {
|
||||||
@@ -132,21 +139,13 @@ pub(super) async fn handle(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let expected_revision = parse_if_match(req.headers());
|
let expected_revision = parse_if_match(req.headers());
|
||||||
let completion_shared = shared.as_ref().clone();
|
let _mutation_guard = shared.mutation_lock.lock().await;
|
||||||
let user_owned = user.to_string();
|
let disk_cfg = load_config_from_disk(&shared.config_path).await?;
|
||||||
let completion = shared
|
ensure_expected_revision(&shared.config_path, expected_revision.as_deref()).await?;
|
||||||
.run_mutation_completion(async move {
|
if !disk_cfg.access.users.contains_key(user) {
|
||||||
let _mutation_guard = completion_shared.mutation_lock.lock().await;
|
return Ok(error_response(
|
||||||
let (disk_cfg, _) = load_config_for_mutation(
|
request_id,
|
||||||
&completion_shared.config_path,
|
ApiFailure::new(StatusCode::NOT_FOUND, "not_found", "User not found"),
|
||||||
expected_revision.as_deref(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
if !disk_cfg.access.users.contains_key(&user_owned) {
|
|
||||||
return Err(ApiFailure::new(
|
|
||||||
StatusCode::NOT_FOUND,
|
|
||||||
"not_found",
|
|
||||||
"User not found",
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let configured_users = disk_cfg
|
let configured_users = disk_cfg
|
||||||
@@ -155,32 +154,23 @@ pub(super) async fn handle(
|
|||||||
.keys()
|
.keys()
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect::<BTreeSet<_>>();
|
.collect::<BTreeSet<_>>();
|
||||||
let snapshot = completion_shared
|
let snapshot = match shared.quota_state.reset_user(&configured_users, user).await {
|
||||||
.quota_state
|
Ok(snapshot) => snapshot,
|
||||||
.reset_user(&configured_users, &user_owned)
|
Err(error) => {
|
||||||
.await
|
shared.runtime_events.record(
|
||||||
.map_err(|error| {
|
|
||||||
completion_shared.runtime_events.record(
|
|
||||||
"api.user.reset_quota.failed",
|
"api.user.reset_quota.failed",
|
||||||
format!("username={} error={}", user_owned, error),
|
format!("username={} error={}", user, error),
|
||||||
);
|
);
|
||||||
ApiFailure::internal(format!("Failed to reset user quota: {}", error))
|
return Err(ApiFailure::internal(format!(
|
||||||
})?;
|
"Failed to reset user quota: {}",
|
||||||
completion_shared.runtime_events.record(
|
error
|
||||||
"api.user.reset_quota.ok",
|
)));
|
||||||
format!("username={}", user_owned),
|
|
||||||
);
|
|
||||||
let revision = current_revision(&completion_shared.config_path).await?;
|
|
||||||
Ok((snapshot, revision))
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
let (snapshot, revision) = match completion {
|
|
||||||
Ok(result) => result,
|
|
||||||
Err(error) if error.code == "not_found" => {
|
|
||||||
return Ok(error_response(request_id, error));
|
|
||||||
}
|
}
|
||||||
Err(error) => return Err(error),
|
|
||||||
};
|
};
|
||||||
|
shared
|
||||||
|
.runtime_events
|
||||||
|
.record("api.user.reset_quota.ok", format!("username={}", user));
|
||||||
|
let revision = current_revision(&shared.config_path).await?;
|
||||||
return Ok(success_response(
|
return Ok(success_response(
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
ResetUserQuotaResponse {
|
ResetUserQuotaResponse {
|
||||||
@@ -281,6 +271,11 @@ pub(super) async fn handle(
|
|||||||
}
|
}
|
||||||
let expected_revision = parse_if_match(req.headers());
|
let expected_revision = parse_if_match(req.headers());
|
||||||
let body = read_json::<PatchUserRequest>(req.into_body(), body_limit).await?;
|
let body = read_json::<PatchUserRequest>(req.into_body(), body_limit).await?;
|
||||||
|
let enabled_update = match &body.enabled {
|
||||||
|
Patch::Unchanged => None,
|
||||||
|
Patch::Remove => Some(true),
|
||||||
|
Patch::Set(enabled) => Some(*enabled),
|
||||||
|
};
|
||||||
let result = patch_user(user, body, expected_revision, shared).await;
|
let result = patch_user(user, body, expected_revision, shared).await;
|
||||||
let (mut data, revision) = match result {
|
let (mut data, revision) = match result {
|
||||||
Ok(ok) => ok,
|
Ok(ok) => ok,
|
||||||
@@ -294,6 +289,21 @@ pub(super) async fn handle(
|
|||||||
};
|
};
|
||||||
let runtime_cfg = config_rx.borrow().clone();
|
let runtime_cfg = config_rx.borrow().clone();
|
||||||
data.in_runtime = runtime_cfg.access.users.contains_key(&data.username);
|
data.in_runtime = runtime_cfg.access.users.contains_key(&data.username);
|
||||||
|
if let Some(enabled) = enabled_update {
|
||||||
|
shared
|
||||||
|
.proxy_shared
|
||||||
|
.set_user_enabled(&data.username, enabled);
|
||||||
|
if !enabled {
|
||||||
|
let cancelled = shared.proxy_shared.cancel_user_sessions(&data.username);
|
||||||
|
shared.runtime_events.record(
|
||||||
|
"api.user.disable.runtime",
|
||||||
|
format!(
|
||||||
|
"username={} cancelled_sessions={}",
|
||||||
|
data.username, cancelled
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
shared
|
shared
|
||||||
.runtime_events
|
.runtime_events
|
||||||
.record("api.user.patch.ok", format!("username={}", data.username));
|
.record("api.user.patch.ok", format!("username={}", data.username));
|
||||||
@@ -327,9 +337,12 @@ pub(super) async fn handle(
|
|||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
shared
|
shared.proxy_shared.set_user_enabled(&deleted_user, true);
|
||||||
.runtime_events
|
let cancelled = shared.proxy_shared.cancel_user_sessions(&deleted_user);
|
||||||
.record("api.user.delete.ok", format!("username={}", deleted_user));
|
shared.runtime_events.record(
|
||||||
|
"api.user.delete.ok",
|
||||||
|
format!("username={} cancelled_sessions={}", deleted_user, cancelled),
|
||||||
|
);
|
||||||
let runtime_cfg = config_rx.borrow().clone();
|
let runtime_cfg = config_rx.borrow().clone();
|
||||||
let in_runtime = runtime_cfg.access.users.contains_key(&deleted_user);
|
let in_runtime = runtime_cfg.access.users.contains_key(&deleted_user);
|
||||||
let response = DeleteUserResponse {
|
let response = DeleteUserResponse {
|
||||||
|
|||||||
+3
-28
@@ -17,7 +17,7 @@ use hyper::service::service_fn;
|
|||||||
use hyper::{Method, Request, Response, StatusCode};
|
use hyper::{Method, Request, Response, StatusCode};
|
||||||
use subtle::ConstantTimeEq;
|
use subtle::ConstantTimeEq;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio::sync::{Mutex, RwLock, Semaphore, oneshot, watch};
|
use tokio::sync::{Mutex, RwLock, Semaphore, watch};
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ mod web_runtime;
|
|||||||
mod web_status;
|
mod web_status;
|
||||||
|
|
||||||
use config_store::{
|
use config_store::{
|
||||||
current_revision, load_config_for_mutation, load_config_for_reload, load_config_from_disk,
|
current_revision, ensure_expected_revision, load_config_for_reload, load_config_from_disk,
|
||||||
parse_if_match,
|
parse_if_match,
|
||||||
};
|
};
|
||||||
use events::ApiEventStore;
|
use events::ApiEventStore;
|
||||||
@@ -70,6 +70,7 @@ use model::{
|
|||||||
PatchUserRequest, ResetUserQuotaResponse, RotateSecretRequest, SummaryData, UserActiveIps,
|
PatchUserRequest, ResetUserQuotaResponse, RotateSecretRequest, SummaryData, UserActiveIps,
|
||||||
is_valid_username,
|
is_valid_username,
|
||||||
};
|
};
|
||||||
|
use patch::Patch;
|
||||||
use runtime_edge::{
|
use runtime_edge::{
|
||||||
EdgeConnectionsCacheEntry, build_runtime_connections_summary_data,
|
EdgeConnectionsCacheEntry, build_runtime_connections_summary_data,
|
||||||
build_runtime_events_recent_data, build_runtime_tls_fingerprints_data,
|
build_runtime_events_recent_data, build_runtime_tls_fingerprints_data,
|
||||||
@@ -134,7 +135,6 @@ pub(super) struct ApiShared {
|
|||||||
pub(super) active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
pub(super) active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
||||||
pub(super) web_trace: Arc<WebTraceStore>,
|
pub(super) web_trace: Arc<WebTraceStore>,
|
||||||
pub(super) web_runtime_rx: watch::Receiver<WebRuntimePublication>,
|
pub(super) web_runtime_rx: watch::Receiver<WebRuntimePublication>,
|
||||||
pub(super) control_plane: ProcessControlPlane,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ApiShared {
|
impl ApiShared {
|
||||||
@@ -170,32 +170,8 @@ impl ApiShared {
|
|||||||
active_runtime: self.active_runtime.clone(),
|
active_runtime: self.active_runtime.clone(),
|
||||||
web_trace: self.web_trace.clone(),
|
web_trace: self.web_trace.clone(),
|
||||||
web_runtime_rx: self.web_runtime_rx.clone(),
|
web_runtime_rx: self.web_runtime_rx.clone(),
|
||||||
control_plane: self.control_plane.clone(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Keeps an accepted mutation alive until persistence and mandatory publication finish.
|
|
||||||
async fn run_mutation_completion<T, F>(&self, future: F) -> Result<T, ApiFailure>
|
|
||||||
where
|
|
||||||
T: Send + 'static,
|
|
||||||
F: std::future::Future<Output = Result<T, ApiFailure>> + Send + 'static,
|
|
||||||
{
|
|
||||||
let (result_tx, result_rx) = oneshot::channel();
|
|
||||||
self.control_plane
|
|
||||||
.spawn_completion(async move {
|
|
||||||
let _ = result_tx.send(future.await);
|
|
||||||
})
|
|
||||||
.map_err(|_| {
|
|
||||||
ApiFailure::new(
|
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
"control_plane_shutting_down",
|
|
||||||
"Control plane is shutting down",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
result_rx.await.map_err(|_| {
|
|
||||||
ApiFailure::internal("accepted config mutation did not report completion")
|
|
||||||
})?
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn auth_header_matches(actual: &str, expected: &str) -> bool {
|
fn auth_header_matches(actual: &str, expected: &str) -> bool {
|
||||||
@@ -389,7 +365,6 @@ pub(crate) async fn serve(
|
|||||||
active_runtime,
|
active_runtime,
|
||||||
web_trace,
|
web_trace,
|
||||||
web_runtime_rx,
|
web_runtime_rx,
|
||||||
control_plane: control_plane.clone(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
spawn_runtime_watchers(
|
spawn_runtime_watchers(
|
||||||
|
|||||||
@@ -124,10 +124,6 @@ pub(super) struct ZeroCoreData {
|
|||||||
pub(super) conntrack_pressure_active: bool,
|
pub(super) conntrack_pressure_active: bool,
|
||||||
pub(super) conntrack_event_queue_depth: u64,
|
pub(super) conntrack_event_queue_depth: u64,
|
||||||
pub(super) conntrack_rule_apply_ok: bool,
|
pub(super) conntrack_rule_apply_ok: bool,
|
||||||
pub(super) conntrack_rule_reconcile_success_total: u64,
|
|
||||||
pub(super) conntrack_rule_reconcile_error_total: u64,
|
|
||||||
pub(super) conntrack_rule_rollback_success_total: u64,
|
|
||||||
pub(super) conntrack_rule_rollback_error_total: u64,
|
|
||||||
pub(super) conntrack_delete_attempt_total: u64,
|
pub(super) conntrack_delete_attempt_total: u64,
|
||||||
pub(super) conntrack_delete_success_total: u64,
|
pub(super) conntrack_delete_success_total: u64,
|
||||||
pub(super) conntrack_delete_not_found_total: u64,
|
pub(super) conntrack_delete_not_found_total: u64,
|
||||||
|
|||||||
@@ -314,7 +314,9 @@ async fn recompute_connections_payload(
|
|||||||
let mut active_users = 0usize;
|
let mut active_users = 0usize;
|
||||||
for entry in shared.stats.iter_user_stats() {
|
for entry in shared.stats.iter_user_stats() {
|
||||||
let user_stats = entry.value();
|
let user_stats = entry.value();
|
||||||
let current_connections = shared.stats.get_process_user_curr_connects(entry.key());
|
let current_connections = user_stats
|
||||||
|
.curr_connects
|
||||||
|
.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
let total_octets = user_stats
|
let total_octets = user_stats
|
||||||
.octets_from_client
|
.octets_from_client
|
||||||
.load(std::sync::atomic::Ordering::Relaxed)
|
.load(std::sync::atomic::Ordering::Relaxed)
|
||||||
|
|||||||
+171
-3
@@ -1,3 +1,4 @@
|
|||||||
|
use std::collections::BTreeSet;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -16,6 +17,82 @@ pub(super) struct SecurityWhitelistData {
|
|||||||
pub(super) entries: Vec<String>,
|
pub(super) entries: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub(super) struct RuntimeMePoolStateGenerationData {
|
||||||
|
pub(super) active_generation: u64,
|
||||||
|
pub(super) warm_generation: u64,
|
||||||
|
pub(super) warm_generations: Vec<u64>,
|
||||||
|
pub(super) pending_hardswap_generation: u64,
|
||||||
|
pub(super) pending_hardswap_age_secs: Option<u64>,
|
||||||
|
pub(super) reinit_inflight: usize,
|
||||||
|
pub(super) reinit_max_concurrency_effective: usize,
|
||||||
|
pub(super) draining_generations: Vec<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub(super) struct RuntimeMePoolStateHardswapData {
|
||||||
|
pub(super) enabled: bool,
|
||||||
|
pub(super) pending: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub(super) struct RuntimeMePoolStateWriterContourData {
|
||||||
|
pub(super) warm: usize,
|
||||||
|
pub(super) active: usize,
|
||||||
|
pub(super) draining: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub(super) struct RuntimeMePoolStateWriterHealthData {
|
||||||
|
pub(super) healthy: usize,
|
||||||
|
pub(super) degraded: usize,
|
||||||
|
pub(super) draining: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub(super) struct RuntimeMePoolStateWriterData {
|
||||||
|
pub(super) total: usize,
|
||||||
|
pub(super) alive_non_draining: usize,
|
||||||
|
pub(super) draining: usize,
|
||||||
|
pub(super) degraded: usize,
|
||||||
|
pub(super) contour: RuntimeMePoolStateWriterContourData,
|
||||||
|
pub(super) health: RuntimeMePoolStateWriterHealthData,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub(super) struct RuntimeMePoolStateRefillDcData {
|
||||||
|
pub(super) dc: i16,
|
||||||
|
pub(super) family: &'static str,
|
||||||
|
pub(super) inflight: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub(super) struct RuntimeMePoolStateRefillData {
|
||||||
|
pub(super) inflight_endpoints_total: usize,
|
||||||
|
pub(super) inflight_dc_total: usize,
|
||||||
|
pub(super) running_dc_total: usize,
|
||||||
|
pub(super) pending_dc_total: usize,
|
||||||
|
pub(super) by_dc: Vec<RuntimeMePoolStateRefillDcData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub(super) struct RuntimeMePoolStatePayload {
|
||||||
|
pub(super) generations: RuntimeMePoolStateGenerationData,
|
||||||
|
pub(super) hardswap: RuntimeMePoolStateHardswapData,
|
||||||
|
pub(super) writers: RuntimeMePoolStateWriterData,
|
||||||
|
pub(super) refill: RuntimeMePoolStateRefillData,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub(super) struct RuntimeMePoolStateData {
|
||||||
|
pub(super) enabled: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(super) reason: Option<&'static str>,
|
||||||
|
pub(super) generated_at_epoch_secs: u64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(super) data: Option<RuntimeMePoolStatePayload>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub(super) struct RuntimeMeQualityCountersData {
|
pub(super) struct RuntimeMeQualityCountersData {
|
||||||
pub(super) idle_close_by_peer_total: u64,
|
pub(super) idle_close_by_peer_total: u64,
|
||||||
@@ -208,6 +285,100 @@ pub(super) fn build_security_whitelist_data(cfg: &ProxyConfig) -> SecurityWhitel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) async fn build_runtime_me_pool_state_data(shared: &ApiShared) -> RuntimeMePoolStateData {
|
||||||
|
let now_epoch_secs = now_epoch_secs();
|
||||||
|
let Some(pool) = shared.me_pool.read().await.clone() else {
|
||||||
|
return RuntimeMePoolStateData {
|
||||||
|
enabled: false,
|
||||||
|
reason: Some(SOURCE_UNAVAILABLE_REASON),
|
||||||
|
generated_at_epoch_secs: now_epoch_secs,
|
||||||
|
data: None,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
let (status, runtime) = pool.api_coherent_snapshots().await;
|
||||||
|
let refill = pool.api_refill_snapshot().await;
|
||||||
|
|
||||||
|
let mut draining_generations = BTreeSet::<u64>::new();
|
||||||
|
let mut contour_warm = 0usize;
|
||||||
|
let mut contour_active = 0usize;
|
||||||
|
let mut contour_draining = 0usize;
|
||||||
|
let mut draining = 0usize;
|
||||||
|
let mut degraded = 0usize;
|
||||||
|
let mut healthy = 0usize;
|
||||||
|
|
||||||
|
for writer in &status.writers {
|
||||||
|
if writer.draining {
|
||||||
|
draining_generations.insert(writer.generation);
|
||||||
|
draining += 1;
|
||||||
|
}
|
||||||
|
if writer.degraded && !writer.draining {
|
||||||
|
degraded += 1;
|
||||||
|
}
|
||||||
|
if !writer.degraded && !writer.draining {
|
||||||
|
healthy += 1;
|
||||||
|
}
|
||||||
|
match writer.state {
|
||||||
|
"warm" => contour_warm += 1,
|
||||||
|
"active" => contour_active += 1,
|
||||||
|
_ => contour_draining += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RuntimeMePoolStateData {
|
||||||
|
enabled: true,
|
||||||
|
reason: None,
|
||||||
|
generated_at_epoch_secs: status.generated_at_epoch_secs,
|
||||||
|
data: Some(RuntimeMePoolStatePayload {
|
||||||
|
generations: RuntimeMePoolStateGenerationData {
|
||||||
|
active_generation: runtime.active_generation,
|
||||||
|
warm_generation: runtime.warm_generation,
|
||||||
|
warm_generations: runtime.warm_generations,
|
||||||
|
pending_hardswap_generation: runtime.pending_hardswap_generation,
|
||||||
|
pending_hardswap_age_secs: runtime.pending_hardswap_age_secs,
|
||||||
|
reinit_inflight: runtime.reinit_inflight,
|
||||||
|
reinit_max_concurrency_effective: runtime.reinit_max_concurrency_effective,
|
||||||
|
draining_generations: draining_generations.into_iter().collect(),
|
||||||
|
},
|
||||||
|
hardswap: RuntimeMePoolStateHardswapData {
|
||||||
|
enabled: runtime.hardswap_enabled,
|
||||||
|
pending: runtime.pending_hardswap_generation != 0,
|
||||||
|
},
|
||||||
|
writers: RuntimeMePoolStateWriterData {
|
||||||
|
total: status.writers.len(),
|
||||||
|
alive_non_draining: status.writers.len().saturating_sub(draining),
|
||||||
|
draining,
|
||||||
|
degraded,
|
||||||
|
contour: RuntimeMePoolStateWriterContourData {
|
||||||
|
warm: contour_warm,
|
||||||
|
active: contour_active,
|
||||||
|
draining: contour_draining,
|
||||||
|
},
|
||||||
|
health: RuntimeMePoolStateWriterHealthData {
|
||||||
|
healthy,
|
||||||
|
degraded,
|
||||||
|
draining,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
refill: RuntimeMePoolStateRefillData {
|
||||||
|
inflight_endpoints_total: refill.inflight_endpoints_total,
|
||||||
|
inflight_dc_total: refill.inflight_dc_total,
|
||||||
|
running_dc_total: refill.running_dc_total,
|
||||||
|
pending_dc_total: refill.pending_dc_total,
|
||||||
|
by_dc: refill
|
||||||
|
.by_dc
|
||||||
|
.into_iter()
|
||||||
|
.map(|entry| RuntimeMePoolStateRefillDcData {
|
||||||
|
dc: entry.dc,
|
||||||
|
family: entry.family,
|
||||||
|
inflight: entry.inflight,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) async fn build_runtime_me_quality_data(shared: &ApiShared) -> RuntimeMeQualityData {
|
pub(super) async fn build_runtime_me_quality_data(shared: &ApiShared) -> RuntimeMeQualityData {
|
||||||
let now_epoch_secs = now_epoch_secs();
|
let now_epoch_secs = now_epoch_secs();
|
||||||
let Some(pool) = shared.me_pool.read().await.clone() else {
|
let Some(pool) = shared.me_pool.read().await.clone() else {
|
||||||
@@ -370,10 +541,7 @@ pub(super) async fn build_runtime_upstream_quality_data(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ME pool runtime-state projection.
|
|
||||||
mod me_pool;
|
|
||||||
// NAT/STUN runtime projection and timestamping.
|
// NAT/STUN runtime projection and timestamping.
|
||||||
mod nat;
|
mod nat;
|
||||||
pub(super) use me_pool::build_runtime_me_pool_state_data;
|
|
||||||
pub(super) use nat::build_runtime_nat_stun_data;
|
pub(super) use nat::build_runtime_nat_stun_data;
|
||||||
use nat::now_epoch_secs;
|
use nat::now_epoch_secs;
|
||||||
|
|||||||
@@ -1,192 +0,0 @@
|
|||||||
//! ME pool runtime-state projection.
|
|
||||||
|
|
||||||
use std::collections::BTreeSet;
|
|
||||||
|
|
||||||
use serde::Serialize;
|
|
||||||
|
|
||||||
use super::{ApiShared, SOURCE_UNAVAILABLE_REASON, now_epoch_secs};
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct RuntimeMePoolStateGenerationData {
|
|
||||||
active_generation: u64,
|
|
||||||
warm_generation: u64,
|
|
||||||
warm_generations: Vec<u64>,
|
|
||||||
pending_hardswap_generation: u64,
|
|
||||||
pending_hardswap_age_secs: Option<u64>,
|
|
||||||
reinit_inflight: usize,
|
|
||||||
reinit_max_concurrency_effective: usize,
|
|
||||||
draining_generations: Vec<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct RuntimeMePoolStateHardswapData {
|
|
||||||
enabled: bool,
|
|
||||||
pending: bool,
|
|
||||||
pending_writers_current: usize,
|
|
||||||
pending_writer_deficit: usize,
|
|
||||||
pending_missing_dc_groups: usize,
|
|
||||||
pending_map_current: Option<bool>,
|
|
||||||
orphan_warm_writers_current: usize,
|
|
||||||
replacement_preparing_current: usize,
|
|
||||||
replacement_retiring_current: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct RuntimeMePoolStateWriterContourData {
|
|
||||||
warm: usize,
|
|
||||||
active: usize,
|
|
||||||
draining: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct RuntimeMePoolStateWriterHealthData {
|
|
||||||
healthy: usize,
|
|
||||||
degraded: usize,
|
|
||||||
draining: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct RuntimeMePoolStateWriterData {
|
|
||||||
total: usize,
|
|
||||||
alive_non_draining: usize,
|
|
||||||
draining: usize,
|
|
||||||
degraded: usize,
|
|
||||||
contour: RuntimeMePoolStateWriterContourData,
|
|
||||||
health: RuntimeMePoolStateWriterHealthData,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct RuntimeMePoolStateRefillDcData {
|
|
||||||
dc: i16,
|
|
||||||
family: &'static str,
|
|
||||||
inflight: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct RuntimeMePoolStateRefillData {
|
|
||||||
inflight_endpoints_total: usize,
|
|
||||||
inflight_dc_total: usize,
|
|
||||||
running_dc_total: usize,
|
|
||||||
pending_dc_total: usize,
|
|
||||||
by_dc: Vec<RuntimeMePoolStateRefillDcData>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct RuntimeMePoolStatePayload {
|
|
||||||
generations: RuntimeMePoolStateGenerationData,
|
|
||||||
hardswap: RuntimeMePoolStateHardswapData,
|
|
||||||
writers: RuntimeMePoolStateWriterData,
|
|
||||||
refill: RuntimeMePoolStateRefillData,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct RuntimeMePoolStateData {
|
|
||||||
enabled: bool,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
reason: Option<&'static str>,
|
|
||||||
generated_at_epoch_secs: u64,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
data: Option<RuntimeMePoolStatePayload>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds the bounded runtime ME pool response projection.
|
|
||||||
pub(in crate::api) async fn build_runtime_me_pool_state_data(shared: &ApiShared) -> impl Serialize {
|
|
||||||
let now_epoch_secs = now_epoch_secs();
|
|
||||||
let Some(pool) = shared.me_pool.read().await.clone() else {
|
|
||||||
return RuntimeMePoolStateData {
|
|
||||||
enabled: false,
|
|
||||||
reason: Some(SOURCE_UNAVAILABLE_REASON),
|
|
||||||
generated_at_epoch_secs: now_epoch_secs,
|
|
||||||
data: None,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
let (status, runtime) = pool.api_coherent_snapshots().await;
|
|
||||||
let refill = pool.api_refill_snapshot().await;
|
|
||||||
|
|
||||||
let mut draining_generations = BTreeSet::<u64>::new();
|
|
||||||
let mut contour_warm = 0usize;
|
|
||||||
let mut contour_active = 0usize;
|
|
||||||
let mut contour_draining = 0usize;
|
|
||||||
let mut draining = 0usize;
|
|
||||||
let mut degraded = 0usize;
|
|
||||||
let mut healthy = 0usize;
|
|
||||||
|
|
||||||
for writer in &status.writers {
|
|
||||||
if writer.draining {
|
|
||||||
draining_generations.insert(writer.generation);
|
|
||||||
draining += 1;
|
|
||||||
}
|
|
||||||
if writer.degraded && !writer.draining {
|
|
||||||
degraded += 1;
|
|
||||||
}
|
|
||||||
if !writer.degraded && !writer.draining {
|
|
||||||
healthy += 1;
|
|
||||||
}
|
|
||||||
match writer.state {
|
|
||||||
"warm" => contour_warm += 1,
|
|
||||||
"active" => contour_active += 1,
|
|
||||||
_ => contour_draining += 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
RuntimeMePoolStateData {
|
|
||||||
enabled: true,
|
|
||||||
reason: None,
|
|
||||||
generated_at_epoch_secs: status.generated_at_epoch_secs,
|
|
||||||
data: Some(RuntimeMePoolStatePayload {
|
|
||||||
generations: RuntimeMePoolStateGenerationData {
|
|
||||||
active_generation: runtime.active_generation,
|
|
||||||
warm_generation: runtime.warm_generation,
|
|
||||||
warm_generations: runtime.warm_generations,
|
|
||||||
pending_hardswap_generation: runtime.pending_hardswap_generation,
|
|
||||||
pending_hardswap_age_secs: runtime.pending_hardswap_age_secs,
|
|
||||||
reinit_inflight: runtime.reinit_inflight,
|
|
||||||
reinit_max_concurrency_effective: runtime.reinit_max_concurrency_effective,
|
|
||||||
draining_generations: draining_generations.into_iter().collect(),
|
|
||||||
},
|
|
||||||
hardswap: RuntimeMePoolStateHardswapData {
|
|
||||||
enabled: runtime.hardswap_enabled,
|
|
||||||
pending: runtime.pending_hardswap_generation != 0,
|
|
||||||
pending_writers_current: runtime.pending_writers_current,
|
|
||||||
pending_writer_deficit: runtime.pending_writer_deficit,
|
|
||||||
pending_missing_dc_groups: runtime.pending_missing_dc_groups,
|
|
||||||
pending_map_current: runtime.pending_map_current,
|
|
||||||
orphan_warm_writers_current: runtime.orphan_warm_writers_current,
|
|
||||||
replacement_preparing_current: runtime.replacement_preparing_current,
|
|
||||||
replacement_retiring_current: runtime.replacement_retiring_current,
|
|
||||||
},
|
|
||||||
writers: RuntimeMePoolStateWriterData {
|
|
||||||
total: status.writers.len(),
|
|
||||||
alive_non_draining: status.writers.len().saturating_sub(draining),
|
|
||||||
draining,
|
|
||||||
degraded,
|
|
||||||
contour: RuntimeMePoolStateWriterContourData {
|
|
||||||
warm: contour_warm,
|
|
||||||
active: contour_active,
|
|
||||||
draining: contour_draining,
|
|
||||||
},
|
|
||||||
health: RuntimeMePoolStateWriterHealthData {
|
|
||||||
healthy,
|
|
||||||
degraded,
|
|
||||||
draining,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
refill: RuntimeMePoolStateRefillData {
|
|
||||||
inflight_endpoints_total: refill.inflight_endpoints_total,
|
|
||||||
inflight_dc_total: refill.inflight_dc_total,
|
|
||||||
running_dc_total: refill.running_dc_total,
|
|
||||||
pending_dc_total: refill.pending_dc_total,
|
|
||||||
by_dc: refill
|
|
||||||
.by_dc
|
|
||||||
.into_iter()
|
|
||||||
.map(|entry| RuntimeMePoolStateRefillDcData {
|
|
||||||
dc: entry.dc,
|
|
||||||
family: entry.family,
|
|
||||||
inflight: entry.inflight,
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -61,12 +61,6 @@ pub(super) fn build_zero_all_data(stats: &Stats, configured_users: usize) -> Zer
|
|||||||
conntrack_pressure_active: stats.get_conntrack_pressure_active(),
|
conntrack_pressure_active: stats.get_conntrack_pressure_active(),
|
||||||
conntrack_event_queue_depth: stats.get_conntrack_event_queue_depth(),
|
conntrack_event_queue_depth: stats.get_conntrack_event_queue_depth(),
|
||||||
conntrack_rule_apply_ok: stats.get_conntrack_rule_apply_ok(),
|
conntrack_rule_apply_ok: stats.get_conntrack_rule_apply_ok(),
|
||||||
conntrack_rule_reconcile_success_total: stats
|
|
||||||
.get_conntrack_rule_reconcile_success_total(),
|
|
||||||
conntrack_rule_reconcile_error_total: stats.get_conntrack_rule_reconcile_error_total(),
|
|
||||||
conntrack_rule_rollback_success_total: stats
|
|
||||||
.get_conntrack_rule_rollback_success_total(),
|
|
||||||
conntrack_rule_rollback_error_total: stats.get_conntrack_rule_rollback_error_total(),
|
|
||||||
conntrack_delete_attempt_total: stats.get_conntrack_delete_attempt_total(),
|
conntrack_delete_attempt_total: stats.get_conntrack_delete_attempt_total(),
|
||||||
conntrack_delete_success_total: stats.get_conntrack_delete_success_total(),
|
conntrack_delete_success_total: stats.get_conntrack_delete_success_total(),
|
||||||
conntrack_delete_not_found_total: stats.get_conntrack_delete_not_found_total(),
|
conntrack_delete_not_found_total: stats.get_conntrack_delete_not_found_total(),
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ pub(super) async fn build_runtime_gates_data(
|
|||||||
&& cfg.general.me2dc_fallback
|
&& cfg.general.me2dc_fallback
|
||||||
&& matches!(route_state.mode, RelayRouteMode::Direct);
|
&& matches!(route_state.mode, RelayRouteMode::Direct);
|
||||||
let reroute_to_direct_at_epoch_secs = if reroute_active {
|
let reroute_to_direct_at_epoch_secs = if reroute_active {
|
||||||
route_state.direct_since_epoch_secs
|
shared.route_runtime.direct_since_epoch_secs()
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|||||||
+2
-3
@@ -5,13 +5,12 @@ use hyper::StatusCode;
|
|||||||
use crate::config::ProxyConfig;
|
use crate::config::ProxyConfig;
|
||||||
use crate::config::RateLimitBps;
|
use crate::config::RateLimitBps;
|
||||||
use crate::ip_tracker::UserIpTracker;
|
use crate::ip_tracker::UserIpTracker;
|
||||||
use crate::proxy::user_admission::credential_id_from_hex;
|
|
||||||
use crate::stats::Stats;
|
use crate::stats::Stats;
|
||||||
|
|
||||||
use super::ApiShared;
|
use super::ApiShared;
|
||||||
use super::config_store::{
|
use super::config_store::{
|
||||||
AccessSection, current_revision, load_config_for_mutation,
|
AccessSection, current_revision, ensure_expected_revision, load_config_from_disk,
|
||||||
save_access_sections_to_disk_if_revision,
|
save_access_sections_to_disk,
|
||||||
};
|
};
|
||||||
use super::model::{
|
use super::model::{
|
||||||
ApiFailure, CreateUserRequest, CreateUserResponse, PatchUserRequest, RotateSecretRequest,
|
ApiFailure, CreateUserRequest, CreateUserResponse, PatchUserRequest, RotateSecretRequest,
|
||||||
|
|||||||
+5
-31
@@ -4,20 +4,6 @@ pub(in crate::api) async fn create_user(
|
|||||||
body: CreateUserRequest,
|
body: CreateUserRequest,
|
||||||
expected_revision: Option<String>,
|
expected_revision: Option<String>,
|
||||||
shared: &ApiShared,
|
shared: &ApiShared,
|
||||||
) -> Result<(CreateUserResponse, String), ApiFailure> {
|
|
||||||
let shared = shared.clone();
|
|
||||||
shared
|
|
||||||
.clone()
|
|
||||||
.run_mutation_completion(async move {
|
|
||||||
create_user_to_completion(body, expected_revision, &shared).await
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn create_user_to_completion(
|
|
||||||
body: CreateUserRequest,
|
|
||||||
expected_revision: Option<String>,
|
|
||||||
shared: &ApiShared,
|
|
||||||
) -> Result<(CreateUserResponse, String), ApiFailure> {
|
) -> Result<(CreateUserResponse, String), ApiFailure> {
|
||||||
let touches_user_ad_tags = body.user_ad_tag.is_some();
|
let touches_user_ad_tags = body.user_ad_tag.is_some();
|
||||||
let touches_user_max_tcp_conns = body.max_tcp_conns.is_some();
|
let touches_user_max_tcp_conns = body.max_tcp_conns.is_some();
|
||||||
@@ -55,11 +41,9 @@ async fn create_user_to_completion(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let expiration = parse_optional_expiration(body.expiration_rfc3339.as_deref())?;
|
let expiration = parse_optional_expiration(body.expiration_rfc3339.as_deref())?;
|
||||||
let credential_id = credential_id_from_hex(&secret)
|
|
||||||
.ok_or_else(|| ApiFailure::internal("validated user secret could not be decoded"))?;
|
|
||||||
let _guard = shared.mutation_lock.lock().await;
|
let _guard = shared.mutation_lock.lock().await;
|
||||||
let (mut cfg, base_revision) =
|
let mut cfg = load_config_from_disk(&shared.config_path).await?;
|
||||||
load_config_for_mutation(&shared.config_path, expected_revision.as_deref()).await?;
|
ensure_expected_revision(&shared.config_path, expected_revision.as_deref()).await?;
|
||||||
|
|
||||||
if cfg.access.users.contains_key(&body.username) {
|
if cfg.access.users.contains_key(&body.username) {
|
||||||
return Err(ApiFailure::new(
|
return Err(ApiFailure::new(
|
||||||
@@ -138,18 +122,9 @@ async fn create_user_to_completion(
|
|||||||
touched_sections.push(AccessSection::UserEnabled);
|
touched_sections.push(AccessSection::UserEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
let revision = save_access_sections_to_disk_if_revision(
|
let revision =
|
||||||
&shared.config_path,
|
save_access_sections_to_disk(&shared.config_path, &cfg, &touched_sections).await?;
|
||||||
&cfg,
|
drop(_guard);
|
||||||
&touched_sections,
|
|
||||||
Some(&base_revision),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
shared.proxy_shared.stage_user_credential(
|
|
||||||
&body.username,
|
|
||||||
credential_id,
|
|
||||||
cfg.access.is_user_enabled(&body.username),
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Some(limit) = updated_limit {
|
if let Some(limit) = updated_limit {
|
||||||
shared
|
shared
|
||||||
@@ -157,7 +132,6 @@ async fn create_user_to_completion(
|
|||||||
.set_user_limit(&body.username, limit)
|
.set_user_limit(&body.username, limit)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
drop(_guard);
|
|
||||||
let (detected_ip_v4, detected_ip_v6) = shared.detected_link_ips();
|
let (detected_ip_v4, detected_ip_v6) = shared.detected_link_ips();
|
||||||
|
|
||||||
let users = users_from_config(
|
let users = users_from_config(
|
||||||
|
|||||||
+10
-62
@@ -6,22 +6,6 @@ pub(in crate::api) async fn rotate_secret(
|
|||||||
body: RotateSecretRequest,
|
body: RotateSecretRequest,
|
||||||
expected_revision: Option<String>,
|
expected_revision: Option<String>,
|
||||||
shared: &ApiShared,
|
shared: &ApiShared,
|
||||||
) -> Result<(CreateUserResponse, String), ApiFailure> {
|
|
||||||
let shared = shared.clone();
|
|
||||||
let user = user.to_string();
|
|
||||||
shared
|
|
||||||
.clone()
|
|
||||||
.run_mutation_completion(async move {
|
|
||||||
rotate_secret_to_completion(&user, body, expected_revision, &shared).await
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn rotate_secret_to_completion(
|
|
||||||
user: &str,
|
|
||||||
body: RotateSecretRequest,
|
|
||||||
expected_revision: Option<String>,
|
|
||||||
shared: &ApiShared,
|
|
||||||
) -> Result<(CreateUserResponse, String), ApiFailure> {
|
) -> Result<(CreateUserResponse, String), ApiFailure> {
|
||||||
let secret = body.secret.unwrap_or_else(random_user_secret);
|
let secret = body.secret.unwrap_or_else(random_user_secret);
|
||||||
if !is_valid_user_secret(&secret) {
|
if !is_valid_user_secret(&secret) {
|
||||||
@@ -29,12 +13,10 @@ async fn rotate_secret_to_completion(
|
|||||||
"secret must be exactly 32 hex characters",
|
"secret must be exactly 32 hex characters",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let credential_id = credential_id_from_hex(&secret)
|
|
||||||
.ok_or_else(|| ApiFailure::internal("validated user secret could not be decoded"))?;
|
|
||||||
|
|
||||||
let _guard = shared.mutation_lock.lock().await;
|
let _guard = shared.mutation_lock.lock().await;
|
||||||
let (mut cfg, base_revision) =
|
let mut cfg = load_config_from_disk(&shared.config_path).await?;
|
||||||
load_config_for_mutation(&shared.config_path, expected_revision.as_deref()).await?;
|
ensure_expected_revision(&shared.config_path, expected_revision.as_deref()).await?;
|
||||||
|
|
||||||
if !cfg.access.users.contains_key(user) {
|
if !cfg.access.users.contains_key(user) {
|
||||||
return Err(ApiFailure::new(
|
return Err(ApiFailure::new(
|
||||||
@@ -47,18 +29,8 @@ async fn rotate_secret_to_completion(
|
|||||||
cfg.access.users.insert(user.to_string(), secret.clone());
|
cfg.access.users.insert(user.to_string(), secret.clone());
|
||||||
cfg.validate()
|
cfg.validate()
|
||||||
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
|
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
|
||||||
let revision = save_access_sections_to_disk_if_revision(
|
let revision =
|
||||||
&shared.config_path,
|
save_access_sections_to_disk(&shared.config_path, &cfg, &[AccessSection::Users]).await?;
|
||||||
&cfg,
|
|
||||||
&[AccessSection::Users],
|
|
||||||
Some(&base_revision),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
shared.proxy_shared.stage_user_credential(
|
|
||||||
user,
|
|
||||||
credential_id,
|
|
||||||
cfg.access.is_user_enabled(user),
|
|
||||||
);
|
|
||||||
drop(_guard);
|
drop(_guard);
|
||||||
|
|
||||||
let (detected_ip_v4, detected_ip_v6) = shared.detected_link_ips();
|
let (detected_ip_v4, detected_ip_v6) = shared.detected_link_ips();
|
||||||
@@ -89,25 +61,10 @@ pub(in crate::api) async fn delete_user(
|
|||||||
user: &str,
|
user: &str,
|
||||||
expected_revision: Option<String>,
|
expected_revision: Option<String>,
|
||||||
shared: &ApiShared,
|
shared: &ApiShared,
|
||||||
) -> Result<(String, String), ApiFailure> {
|
|
||||||
let shared = shared.clone();
|
|
||||||
let user = user.to_string();
|
|
||||||
shared
|
|
||||||
.clone()
|
|
||||||
.run_mutation_completion(async move {
|
|
||||||
delete_user_to_completion(&user, expected_revision, &shared).await
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete_user_to_completion(
|
|
||||||
user: &str,
|
|
||||||
expected_revision: Option<String>,
|
|
||||||
shared: &ApiShared,
|
|
||||||
) -> Result<(String, String), ApiFailure> {
|
) -> Result<(String, String), ApiFailure> {
|
||||||
let _guard = shared.mutation_lock.lock().await;
|
let _guard = shared.mutation_lock.lock().await;
|
||||||
let (mut cfg, base_revision) =
|
let mut cfg = load_config_from_disk(&shared.config_path).await?;
|
||||||
load_config_for_mutation(&shared.config_path, expected_revision.as_deref()).await?;
|
ensure_expected_revision(&shared.config_path, expected_revision.as_deref()).await?;
|
||||||
|
|
||||||
if !cfg.access.users.contains_key(user) {
|
if !cfg.access.users.contains_key(user) {
|
||||||
return Err(ApiFailure::new(
|
return Err(ApiFailure::new(
|
||||||
@@ -150,14 +107,8 @@ async fn delete_user_to_completion(
|
|||||||
|
|
||||||
cfg.validate()
|
cfg.validate()
|
||||||
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
|
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
|
||||||
let revision = save_access_sections_to_disk_if_revision(
|
let revision =
|
||||||
&shared.config_path,
|
save_access_sections_to_disk(&shared.config_path, &cfg, &touched_sections).await?;
|
||||||
&cfg,
|
|
||||||
&touched_sections,
|
|
||||||
Some(&base_revision),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
let deleted_incarnation = shared.proxy_shared.delete_user(user).incarnation;
|
|
||||||
let configured_users = cfg.access.users.keys().cloned().collect();
|
let configured_users = cfg.access.users.keys().cloned().collect();
|
||||||
if let Err(error) = shared
|
if let Err(error) = shared
|
||||||
.quota_state
|
.quota_state
|
||||||
@@ -170,12 +121,9 @@ async fn delete_user_to_completion(
|
|||||||
"Deleted user quota checkpoint cleanup will be reconciled on restart"
|
"Deleted user quota checkpoint cleanup will be reconciled on restart"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
shared.ip_tracker.remove_user_limit(user).await;
|
|
||||||
shared
|
|
||||||
.ip_tracker
|
|
||||||
.clear_user_ips_if_not_newer(user, deleted_incarnation)
|
|
||||||
.await;
|
|
||||||
drop(_guard);
|
drop(_guard);
|
||||||
|
shared.ip_tracker.remove_user_limit(user).await;
|
||||||
|
shared.ip_tracker.clear_user_ips(user).await;
|
||||||
|
|
||||||
Ok((user.to_string(), revision))
|
Ok((user.to_string(), revision))
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-79
@@ -5,22 +5,6 @@ pub(in crate::api) async fn patch_user(
|
|||||||
body: PatchUserRequest,
|
body: PatchUserRequest,
|
||||||
expected_revision: Option<String>,
|
expected_revision: Option<String>,
|
||||||
shared: &ApiShared,
|
shared: &ApiShared,
|
||||||
) -> Result<(UserInfo, String), ApiFailure> {
|
|
||||||
let shared = shared.clone();
|
|
||||||
let user = user.to_string();
|
|
||||||
shared
|
|
||||||
.clone()
|
|
||||||
.run_mutation_completion(async move {
|
|
||||||
patch_user_to_completion(&user, body, expected_revision, &shared).await
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn patch_user_to_completion(
|
|
||||||
user: &str,
|
|
||||||
body: PatchUserRequest,
|
|
||||||
expected_revision: Option<String>,
|
|
||||||
shared: &ApiShared,
|
|
||||||
) -> Result<(UserInfo, String), ApiFailure> {
|
) -> Result<(UserInfo, String), ApiFailure> {
|
||||||
let touches_users = body.secret.is_some();
|
let touches_users = body.secret.is_some();
|
||||||
let touches_user_ad_tags = !matches!(&body.user_ad_tag, Patch::Unchanged);
|
let touches_user_ad_tags = !matches!(&body.user_ad_tag, Patch::Unchanged);
|
||||||
@@ -48,8 +32,8 @@ async fn patch_user_to_completion(
|
|||||||
}
|
}
|
||||||
let expiration = parse_patch_expiration(&body.expiration_rfc3339)?;
|
let expiration = parse_patch_expiration(&body.expiration_rfc3339)?;
|
||||||
let _guard = shared.mutation_lock.lock().await;
|
let _guard = shared.mutation_lock.lock().await;
|
||||||
let (mut cfg, base_revision) =
|
let mut cfg = load_config_from_disk(&shared.config_path).await?;
|
||||||
load_config_for_mutation(&shared.config_path, expected_revision.as_deref()).await?;
|
ensure_expected_revision(&shared.config_path, expected_revision.as_deref()).await?;
|
||||||
|
|
||||||
if !cfg.access.users.contains_key(user) {
|
if !cfg.access.users.contains_key(user) {
|
||||||
return Err(ApiFailure::new(
|
return Err(ApiFailure::new(
|
||||||
@@ -154,19 +138,6 @@ async fn patch_user_to_completion(
|
|||||||
|
|
||||||
cfg.validate()
|
cfg.validate()
|
||||||
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
|
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
|
||||||
let staged_credential =
|
|
||||||
if touches_users || touches_user_enabled {
|
|
||||||
let secret = cfg
|
|
||||||
.access
|
|
||||||
.users
|
|
||||||
.get(user)
|
|
||||||
.ok_or_else(|| ApiFailure::internal("updated user secret is missing"))?;
|
|
||||||
Some(credential_id_from_hex(secret).ok_or_else(|| {
|
|
||||||
ApiFailure::internal("validated user secret could not be decoded")
|
|
||||||
})?)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut touched_sections = Vec::new();
|
let mut touched_sections = Vec::new();
|
||||||
if touches_users {
|
if touches_users {
|
||||||
@@ -197,27 +168,14 @@ async fn patch_user_to_completion(
|
|||||||
let revision = if touched_sections.is_empty() {
|
let revision = if touched_sections.is_empty() {
|
||||||
current_revision(&shared.config_path).await?
|
current_revision(&shared.config_path).await?
|
||||||
} else {
|
} else {
|
||||||
save_access_sections_to_disk_if_revision(
|
save_access_sections_to_disk(&shared.config_path, &cfg, &touched_sections).await?
|
||||||
&shared.config_path,
|
|
||||||
&cfg,
|
|
||||||
&touched_sections,
|
|
||||||
Some(&base_revision),
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
};
|
};
|
||||||
if let Some(credential_id) = staged_credential {
|
drop(_guard);
|
||||||
shared.proxy_shared.stage_user_credential(
|
|
||||||
user,
|
|
||||||
credential_id,
|
|
||||||
cfg.access.is_user_enabled(user),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
match max_unique_ips_change {
|
match max_unique_ips_change {
|
||||||
Some(Some(limit)) => shared.ip_tracker.set_user_limit(user, limit).await,
|
Some(Some(limit)) => shared.ip_tracker.set_user_limit(user, limit).await,
|
||||||
Some(None) => shared.ip_tracker.remove_user_limit(user).await,
|
Some(None) => shared.ip_tracker.remove_user_limit(user).await,
|
||||||
None => {}
|
None => {}
|
||||||
}
|
}
|
||||||
drop(_guard);
|
|
||||||
let (detected_ip_v4, detected_ip_v6) = shared.detected_link_ips();
|
let (detected_ip_v4, detected_ip_v6) = shared.detected_link_ips();
|
||||||
let users = users_from_config(
|
let users = users_from_config(
|
||||||
&cfg,
|
&cfg,
|
||||||
@@ -241,26 +199,10 @@ pub(in crate::api) async fn set_user_enabled(
|
|||||||
enabled: bool,
|
enabled: bool,
|
||||||
expected_revision: Option<String>,
|
expected_revision: Option<String>,
|
||||||
shared: &ApiShared,
|
shared: &ApiShared,
|
||||||
) -> Result<(UserInfo, String), ApiFailure> {
|
|
||||||
let shared = shared.clone();
|
|
||||||
let user = user.to_string();
|
|
||||||
shared
|
|
||||||
.clone()
|
|
||||||
.run_mutation_completion(async move {
|
|
||||||
set_user_enabled_to_completion(&user, enabled, expected_revision, &shared).await
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn set_user_enabled_to_completion(
|
|
||||||
user: &str,
|
|
||||||
enabled: bool,
|
|
||||||
expected_revision: Option<String>,
|
|
||||||
shared: &ApiShared,
|
|
||||||
) -> Result<(UserInfo, String), ApiFailure> {
|
) -> Result<(UserInfo, String), ApiFailure> {
|
||||||
let _guard = shared.mutation_lock.lock().await;
|
let _guard = shared.mutation_lock.lock().await;
|
||||||
let (mut cfg, base_revision) =
|
let mut cfg = load_config_from_disk(&shared.config_path).await?;
|
||||||
load_config_for_mutation(&shared.config_path, expected_revision.as_deref()).await?;
|
ensure_expected_revision(&shared.config_path, expected_revision.as_deref()).await?;
|
||||||
|
|
||||||
if !cfg.access.users.contains_key(user) {
|
if !cfg.access.users.contains_key(user) {
|
||||||
return Err(ApiFailure::new(
|
return Err(ApiFailure::new(
|
||||||
@@ -278,22 +220,9 @@ async fn set_user_enabled_to_completion(
|
|||||||
|
|
||||||
cfg.validate()
|
cfg.validate()
|
||||||
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
|
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
|
||||||
let credential_id = cfg
|
let revision =
|
||||||
.access
|
save_access_sections_to_disk(&shared.config_path, &cfg, &[AccessSection::UserEnabled])
|
||||||
.users
|
|
||||||
.get(user)
|
|
||||||
.and_then(|secret| credential_id_from_hex(secret))
|
|
||||||
.ok_or_else(|| ApiFailure::internal("validated user secret could not be decoded"))?;
|
|
||||||
let revision = save_access_sections_to_disk_if_revision(
|
|
||||||
&shared.config_path,
|
|
||||||
&cfg,
|
|
||||||
&[AccessSection::UserEnabled],
|
|
||||||
Some(&base_revision),
|
|
||||||
)
|
|
||||||
.await?;
|
.await?;
|
||||||
shared
|
|
||||||
.proxy_shared
|
|
||||||
.stage_user_credential(user, credential_id, enabled);
|
|
||||||
drop(_guard);
|
drop(_guard);
|
||||||
|
|
||||||
let (detected_ip_v4, detected_ip_v6) = shared.detected_link_ips();
|
let (detected_ip_v4, detected_ip_v6) = shared.detected_link_ips();
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ pub(in crate::api) async fn users_from_config(
|
|||||||
.filter(|limit| *limit > 0)
|
.filter(|limit| *limit > 0)
|
||||||
.or((cfg.access.user_max_unique_ips_global_each > 0)
|
.or((cfg.access.user_max_unique_ips_global_each > 0)
|
||||||
.then_some(cfg.access.user_max_unique_ips_global_each)),
|
.then_some(cfg.access.user_max_unique_ips_global_each)),
|
||||||
current_connections: stats.get_process_user_curr_connects(&username),
|
current_connections: stats.get_user_curr_connects(&username),
|
||||||
active_unique_ips: active_ip_list.len(),
|
active_unique_ips: active_ip_list.len(),
|
||||||
active_unique_ips_list: active_ip_list,
|
active_unique_ips_list: active_ip_list,
|
||||||
recent_unique_ips: recent_ip_list.len(),
|
recent_unique_ips: recent_ip_list.len(),
|
||||||
|
|||||||
@@ -67,11 +67,6 @@ pub(super) async fn render(
|
|||||||
push_filter_form(&mut html, &query);
|
push_filter_form(&mut html, &query);
|
||||||
html.push_str("<section><h2>Store</h2><table><tbody>");
|
html.push_str("<section><h2>Store</h2><table><tbody>");
|
||||||
summary_row(&mut html, "debug enabled", yes_no(status.policy.enabled));
|
summary_row(&mut html, "debug enabled", yes_no(status.policy.enabled));
|
||||||
summary_row(
|
|
||||||
&mut html,
|
|
||||||
"sideband",
|
|
||||||
yes_no(status.policy.bridge_diagnostics_enabled()),
|
|
||||||
);
|
|
||||||
summary_row(&mut html, "body capture", body_mode(&status.policy));
|
summary_row(&mut html, "body capture", body_mode(&status.policy));
|
||||||
summary_row(&mut html, "window seconds", &query.window_secs.to_string());
|
summary_row(&mut html, "window seconds", &query.window_secs.to_string());
|
||||||
summary_row(
|
summary_row(
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ fn html_escaping_covers_active_markup_characters() {
|
|||||||
async fn renderer_filters_groups_and_sets_control_plane_security_headers() {
|
async fn renderer_filters_groups_and_sets_control_plane_security_headers() {
|
||||||
let policy = WebDebugConfig {
|
let policy = WebDebugConfig {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
sideband: true,
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let limits = crate::config::WebLimitsConfig {
|
let limits = crate::config::WebLimitsConfig {
|
||||||
@@ -59,7 +58,6 @@ async fn renderer_filters_groups_and_sets_control_plane_security_headers() {
|
|||||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
let body = std::str::from_utf8(&body).unwrap();
|
let body = std::str::from_utf8(&body).unwrap();
|
||||||
assert!(body.contains("session_created"));
|
assert!(body.contains("session_created"));
|
||||||
assert!(body.contains("<th>sideband</th><td>yes</td>"));
|
|
||||||
assert!(body.contains("0123456789abcdef"));
|
assert!(body.contains("0123456789abcdef"));
|
||||||
assert!(body.contains("192.0.2.40"));
|
assert!(body.contains("192.0.2.40"));
|
||||||
}
|
}
|
||||||
|
|||||||
+496
-23
@@ -8,22 +8,15 @@
|
|||||||
//! - `run [OPTIONS] [config.toml]` - Run in foreground (default behavior)
|
//! - `run [OPTIONS] [config.toml]` - Run in foreground (default behavior)
|
||||||
//! - `healthcheck [OPTIONS] [config.toml]` - Run control-plane health probe
|
//! - `healthcheck [OPTIONS] [config.toml]` - Run control-plane health probe
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use rand::RngExt;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
use crate::healthcheck::{self, HealthcheckMode};
|
use crate::healthcheck::{self, HealthcheckMode};
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use crate::daemon::{DEFAULT_PID_FILE, DaemonOptions};
|
use crate::daemon::{self, DEFAULT_PID_FILE, DaemonOptions};
|
||||||
|
|
||||||
// Unix daemon control and argument parsing.
|
|
||||||
#[cfg(unix)]
|
|
||||||
mod daemon_commands;
|
|
||||||
// Fire-and-forget installation workflow.
|
|
||||||
mod init;
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
pub use daemon_commands::parse_daemon_args;
|
|
||||||
pub use init::{InitOptions, parse_init_args, run_init};
|
|
||||||
|
|
||||||
/// CLI subcommand to execute.
|
/// CLI subcommand to execute.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -47,20 +40,13 @@ pub enum Subcommand {
|
|||||||
/// Parsed subcommand with its options.
|
/// Parsed subcommand with its options.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ParsedCommand {
|
pub struct ParsedCommand {
|
||||||
/// Selected command mode.
|
|
||||||
pub subcommand: Subcommand,
|
pub subcommand: Subcommand,
|
||||||
/// PID file used by daemon-control commands.
|
|
||||||
pub pid_file: PathBuf,
|
pub pid_file: PathBuf,
|
||||||
/// Configuration file passed to runtime or healthcheck.
|
|
||||||
pub config_path: String,
|
pub config_path: String,
|
||||||
/// Requested healthcheck mode.
|
|
||||||
pub healthcheck_mode: HealthcheckMode,
|
pub healthcheck_mode: HealthcheckMode,
|
||||||
/// Invalid healthcheck mode retained for command diagnostics.
|
|
||||||
pub healthcheck_mode_invalid: Option<String>,
|
pub healthcheck_mode_invalid: Option<String>,
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
/// Unix daemon lifecycle options.
|
|
||||||
pub daemon_opts: DaemonOptions,
|
pub daemon_opts: DaemonOptions,
|
||||||
/// Fire-and-forget initialization options.
|
|
||||||
pub init_opts: Option<InitOptions>,
|
pub init_opts: Option<InitOptions>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +79,7 @@ pub fn parse_command(args: &[String]) -> ParsedCommand {
|
|||||||
return cmd;
|
return cmd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for subcommand as first argument
|
||||||
if let Some(first) = args.first() {
|
if let Some(first) = args.first() {
|
||||||
match first.as_str() {
|
match first.as_str() {
|
||||||
"start" => {
|
"start" => {
|
||||||
@@ -133,9 +120,11 @@ pub fn parse_command(args: &[String]) -> ParsedCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse remaining options
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
while i < args.len() {
|
while i < args.len() {
|
||||||
match args[i].as_str() {
|
match args[i].as_str() {
|
||||||
|
// Skip subcommand names
|
||||||
"start" | "stop" | "reload" | "status" | "run" | "healthcheck" => {}
|
"start" | "stop" | "reload" | "status" | "run" | "healthcheck" => {}
|
||||||
"--mode" => {
|
"--mode" => {
|
||||||
i += 1;
|
i += 1;
|
||||||
@@ -165,6 +154,7 @@ pub fn parse_command(args: &[String]) -> ParsedCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// PID file option (for stop/reload/status)
|
||||||
"--pid-file" => {
|
"--pid-file" => {
|
||||||
i += 1;
|
i += 1;
|
||||||
if i < args.len() {
|
if i < args.len() {
|
||||||
@@ -199,9 +189,9 @@ pub fn parse_command(args: &[String]) -> ParsedCommand {
|
|||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub fn execute_subcommand(cmd: &ParsedCommand) -> Option<i32> {
|
pub fn execute_subcommand(cmd: &ParsedCommand) -> Option<i32> {
|
||||||
match cmd.subcommand {
|
match cmd.subcommand {
|
||||||
Subcommand::Stop => Some(daemon_commands::stop(&cmd.pid_file)),
|
Subcommand::Stop => Some(cmd_stop(&cmd.pid_file)),
|
||||||
Subcommand::Reload => Some(daemon_commands::reload(&cmd.pid_file)),
|
Subcommand::Reload => Some(cmd_reload(&cmd.pid_file)),
|
||||||
Subcommand::Status => Some(daemon_commands::status(&cmd.pid_file)),
|
Subcommand::Status => Some(cmd_status(&cmd.pid_file)),
|
||||||
Subcommand::Healthcheck => {
|
Subcommand::Healthcheck => {
|
||||||
if let Some(invalid_mode) = cmd.healthcheck_mode_invalid.as_ref() {
|
if let Some(invalid_mode) = cmd.healthcheck_mode_invalid.as_ref() {
|
||||||
if invalid_mode.is_empty() {
|
if invalid_mode.is_empty() {
|
||||||
@@ -234,7 +224,6 @@ pub fn execute_subcommand(cmd: &ParsedCommand) -> Option<i32> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Executes a non-server subcommand on platforms without daemon support.
|
|
||||||
#[cfg(not(unix))]
|
#[cfg(not(unix))]
|
||||||
pub fn execute_subcommand(cmd: &ParsedCommand) -> Option<i32> {
|
pub fn execute_subcommand(cmd: &ParsedCommand) -> Option<i32> {
|
||||||
match cmd.subcommand {
|
match cmd.subcommand {
|
||||||
@@ -272,3 +261,487 @@ pub fn execute_subcommand(cmd: &ParsedCommand) -> Option<i32> {
|
|||||||
Subcommand::Run | Subcommand::Start => None,
|
Subcommand::Run | Subcommand::Start => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stop command: send SIGTERM to the running daemon.
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn cmd_stop(pid_file: &Path) -> i32 {
|
||||||
|
use nix::sys::signal::Signal;
|
||||||
|
|
||||||
|
println!("Stopping telemt daemon...");
|
||||||
|
|
||||||
|
match daemon::signal_pid_file(pid_file, Signal::SIGTERM) {
|
||||||
|
Ok(()) => {
|
||||||
|
println!("Stop signal sent successfully");
|
||||||
|
|
||||||
|
// Wait for process to exit (up to 10 seconds)
|
||||||
|
for _ in 0..20 {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||||
|
if let daemon::DaemonStatus::NotRunning = daemon::check_status(pid_file) {
|
||||||
|
println!("Daemon stopped");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("Daemon may still be shutting down");
|
||||||
|
0
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to stop daemon: {}", e);
|
||||||
|
1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reload command: send SIGHUP to trigger config reload.
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn cmd_reload(pid_file: &Path) -> i32 {
|
||||||
|
use nix::sys::signal::Signal;
|
||||||
|
|
||||||
|
println!("Reloading telemt configuration...");
|
||||||
|
|
||||||
|
match daemon::signal_pid_file(pid_file, Signal::SIGHUP) {
|
||||||
|
Ok(()) => {
|
||||||
|
println!("Reload signal sent successfully");
|
||||||
|
0
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to reload daemon: {}", e);
|
||||||
|
1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Status command: check if daemon is running.
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn cmd_status(pid_file: &Path) -> i32 {
|
||||||
|
match daemon::check_status(pid_file) {
|
||||||
|
daemon::DaemonStatus::Running(pid) => {
|
||||||
|
println!("telemt is running (pid {})", pid);
|
||||||
|
0
|
||||||
|
}
|
||||||
|
daemon::DaemonStatus::Stale(pid) => {
|
||||||
|
println!("telemt is not running (stale pid file, was pid {})", pid);
|
||||||
|
// Clean up stale PID file
|
||||||
|
let _ = std::fs::remove_file(pid_file);
|
||||||
|
1
|
||||||
|
}
|
||||||
|
daemon::DaemonStatus::NotRunning => {
|
||||||
|
println!("telemt is not running");
|
||||||
|
1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Options for the init command
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct InitOptions {
|
||||||
|
pub port: u16,
|
||||||
|
pub domain: String,
|
||||||
|
pub secret: Option<String>,
|
||||||
|
pub username: String,
|
||||||
|
pub config_dir: PathBuf,
|
||||||
|
pub no_start: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse daemon-related options from CLI args.
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub fn parse_daemon_args(args: &[String]) -> DaemonOptions {
|
||||||
|
let mut opts = DaemonOptions::default();
|
||||||
|
let mut i = 0;
|
||||||
|
|
||||||
|
while i < args.len() {
|
||||||
|
match args[i].as_str() {
|
||||||
|
"--daemon" | "-d" => {
|
||||||
|
opts.daemonize = true;
|
||||||
|
}
|
||||||
|
"--foreground" | "-f" => {
|
||||||
|
opts.foreground = true;
|
||||||
|
}
|
||||||
|
"--pid-file" => {
|
||||||
|
i += 1;
|
||||||
|
if i < args.len() {
|
||||||
|
opts.pid_file = Some(PathBuf::from(&args[i]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s if s.starts_with("--pid-file=") => {
|
||||||
|
opts.pid_file = Some(PathBuf::from(s.trim_start_matches("--pid-file=")));
|
||||||
|
}
|
||||||
|
"--run-as-user" => {
|
||||||
|
i += 1;
|
||||||
|
if i < args.len() {
|
||||||
|
opts.user = Some(args[i].clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s if s.starts_with("--run-as-user=") => {
|
||||||
|
opts.user = Some(s.trim_start_matches("--run-as-user=").to_string());
|
||||||
|
}
|
||||||
|
"--run-as-group" => {
|
||||||
|
i += 1;
|
||||||
|
if i < args.len() {
|
||||||
|
opts.group = Some(args[i].clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s if s.starts_with("--run-as-group=") => {
|
||||||
|
opts.group = Some(s.trim_start_matches("--run-as-group=").to_string());
|
||||||
|
}
|
||||||
|
"--working-dir" => {
|
||||||
|
i += 1;
|
||||||
|
if i < args.len() {
|
||||||
|
opts.working_dir = Some(PathBuf::from(&args[i]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s if s.starts_with("--working-dir=") => {
|
||||||
|
opts.working_dir = Some(PathBuf::from(s.trim_start_matches("--working-dir=")));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
opts
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for InitOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
port: 443,
|
||||||
|
domain: "www.google.com".to_string(),
|
||||||
|
secret: None,
|
||||||
|
username: "user".to_string(),
|
||||||
|
config_dir: PathBuf::from("/etc/telemt"),
|
||||||
|
no_start: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse --init subcommand options from CLI args.
|
||||||
|
///
|
||||||
|
/// Returns `Some(InitOptions)` if `--init` was found, `None` otherwise.
|
||||||
|
pub fn parse_init_args(args: &[String]) -> Option<InitOptions> {
|
||||||
|
if !args.iter().any(|a| a == "--init") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut opts = InitOptions::default();
|
||||||
|
let mut i = 0;
|
||||||
|
|
||||||
|
while i < args.len() {
|
||||||
|
match args[i].as_str() {
|
||||||
|
"--port" => {
|
||||||
|
i += 1;
|
||||||
|
if i < args.len() {
|
||||||
|
opts.port = args[i].parse().unwrap_or(443);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"--domain" => {
|
||||||
|
i += 1;
|
||||||
|
if i < args.len() {
|
||||||
|
opts.domain = args[i].clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"--secret" => {
|
||||||
|
i += 1;
|
||||||
|
if i < args.len() {
|
||||||
|
opts.secret = Some(args[i].clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"--user" => {
|
||||||
|
i += 1;
|
||||||
|
if i < args.len() {
|
||||||
|
opts.username = args[i].clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"--config-dir" => {
|
||||||
|
i += 1;
|
||||||
|
if i < args.len() {
|
||||||
|
opts.config_dir = PathBuf::from(&args[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"--no-start" => {
|
||||||
|
opts.no_start = true;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the fire-and-forget setup.
|
||||||
|
pub fn run_init(opts: InitOptions) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
use crate::service::{self, InitSystem, ServiceOptions};
|
||||||
|
|
||||||
|
eprintln!("[telemt] Fire-and-forget setup");
|
||||||
|
eprintln!();
|
||||||
|
|
||||||
|
// 1. Detect init system
|
||||||
|
let init_system = service::detect_init_system();
|
||||||
|
eprintln!("[+] Detected init system: {}", init_system);
|
||||||
|
|
||||||
|
// 2. Generate or validate secret
|
||||||
|
let secret = match opts.secret {
|
||||||
|
Some(s) => {
|
||||||
|
if s.len() != 32 || !s.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||||
|
eprintln!("[error] Secret must be exactly 32 hex characters");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
None => generate_secret(),
|
||||||
|
};
|
||||||
|
|
||||||
|
eprintln!("[+] Secret: {}", secret);
|
||||||
|
eprintln!("[+] User: {}", opts.username);
|
||||||
|
eprintln!("[+] Port: {}", opts.port);
|
||||||
|
eprintln!("[+] Domain: {}", opts.domain);
|
||||||
|
|
||||||
|
// 3. Create config directory
|
||||||
|
fs::create_dir_all(&opts.config_dir)?;
|
||||||
|
let config_path = opts.config_dir.join("config.toml");
|
||||||
|
|
||||||
|
// 4. Write config
|
||||||
|
let config_content = generate_config(&opts.username, &secret, opts.port, &opts.domain);
|
||||||
|
fs::write(&config_path, &config_content)?;
|
||||||
|
eprintln!("[+] Config written to {}", config_path.display());
|
||||||
|
|
||||||
|
// 5. Generate and write service file
|
||||||
|
let exe_path =
|
||||||
|
std::env::current_exe().unwrap_or_else(|_| PathBuf::from("/usr/local/bin/telemt"));
|
||||||
|
|
||||||
|
let service_opts = ServiceOptions {
|
||||||
|
exe_path: &exe_path,
|
||||||
|
config_path: &config_path,
|
||||||
|
user: None, // Let systemd/init handle user
|
||||||
|
group: None,
|
||||||
|
pid_file: "/var/run/telemt.pid",
|
||||||
|
working_dir: Some("/var/lib/telemt"),
|
||||||
|
description: "Telemt MTProxy - Telegram MTProto Proxy",
|
||||||
|
};
|
||||||
|
|
||||||
|
let service_path = service::service_file_path(init_system);
|
||||||
|
let service_content = service::generate_service_file(init_system, &service_opts);
|
||||||
|
|
||||||
|
// Ensure parent directory exists
|
||||||
|
if let Some(parent) = Path::new(service_path).parent() {
|
||||||
|
let _ = fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
match fs::write(service_path, &service_content) {
|
||||||
|
Ok(()) => {
|
||||||
|
eprintln!("[+] Service file written to {}", service_path);
|
||||||
|
|
||||||
|
// Make script executable for OpenRC/FreeBSD
|
||||||
|
#[cfg(unix)]
|
||||||
|
if init_system == InitSystem::OpenRC || init_system == InitSystem::FreeBSDRc {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mut perms = fs::metadata(service_path)?.permissions();
|
||||||
|
perms.set_mode(0o755);
|
||||||
|
fs::set_permissions(service_path, perms)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[!] Cannot write service file (run as root?): {}", e);
|
||||||
|
eprintln!("[!] Manual service file content:");
|
||||||
|
eprintln!("{}", service_content);
|
||||||
|
|
||||||
|
// Still print links and installation instructions
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("{}", service::installation_instructions(init_system));
|
||||||
|
print_links(&opts.username, &secret, opts.port, &opts.domain);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Install and enable service based on init system
|
||||||
|
match init_system {
|
||||||
|
InitSystem::Systemd => {
|
||||||
|
run_cmd("systemctl", &["daemon-reload"]);
|
||||||
|
run_cmd("systemctl", &["enable", "telemt.service"]);
|
||||||
|
eprintln!("[+] Service enabled");
|
||||||
|
|
||||||
|
if !opts.no_start {
|
||||||
|
run_cmd("systemctl", &["start", "telemt.service"]);
|
||||||
|
eprintln!("[+] Service started");
|
||||||
|
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||||
|
let status = Command::new("systemctl")
|
||||||
|
.args(["is-active", "telemt.service"])
|
||||||
|
.output();
|
||||||
|
|
||||||
|
match status {
|
||||||
|
Ok(out) if out.status.success() => {
|
||||||
|
eprintln!("[+] Service is running");
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
eprintln!("[!] Service may not have started correctly");
|
||||||
|
eprintln!("[!] Check: journalctl -u telemt.service -n 20");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
eprintln!("[+] Service not started (--no-start)");
|
||||||
|
eprintln!("[+] Start manually: systemctl start telemt.service");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InitSystem::OpenRC => {
|
||||||
|
run_cmd("rc-update", &["add", "telemt", "default"]);
|
||||||
|
eprintln!("[+] Service enabled");
|
||||||
|
|
||||||
|
if !opts.no_start {
|
||||||
|
run_cmd("rc-service", &["telemt", "start"]);
|
||||||
|
eprintln!("[+] Service started");
|
||||||
|
} else {
|
||||||
|
eprintln!("[+] Service not started (--no-start)");
|
||||||
|
eprintln!("[+] Start manually: rc-service telemt start");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InitSystem::FreeBSDRc => {
|
||||||
|
run_cmd("sysrc", &["telemt_enable=YES"]);
|
||||||
|
eprintln!("[+] Service enabled");
|
||||||
|
|
||||||
|
if !opts.no_start {
|
||||||
|
run_cmd("service", &["telemt", "start"]);
|
||||||
|
eprintln!("[+] Service started");
|
||||||
|
} else {
|
||||||
|
eprintln!("[+] Service not started (--no-start)");
|
||||||
|
eprintln!("[+] Start manually: service telemt start");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InitSystem::Unknown => {
|
||||||
|
eprintln!("[!] Unknown init system - service file written but not installed");
|
||||||
|
eprintln!("[!] You may need to install it manually");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!();
|
||||||
|
|
||||||
|
// 7. Print links
|
||||||
|
print_links(&opts.username, &secret, opts.port, &opts.domain);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_secret() -> String {
|
||||||
|
let mut rng = rand::rng();
|
||||||
|
let bytes: Vec<u8> = (0..16).map(|_| rng.random::<u8>()).collect();
|
||||||
|
hex::encode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_config(username: &str, secret: &str, port: u16, domain: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"# Telemt MTProxy — auto-generated config
|
||||||
|
# Re-run `telemt --init` to regenerate
|
||||||
|
|
||||||
|
show_link = ["{username}"]
|
||||||
|
|
||||||
|
[general]
|
||||||
|
# prefer_ipv6 is deprecated; use [network].prefer
|
||||||
|
prefer_ipv6 = false
|
||||||
|
fast_mode = true
|
||||||
|
use_middle_proxy = false
|
||||||
|
log_level = "normal"
|
||||||
|
desync_all_full = false
|
||||||
|
update_every = 43200
|
||||||
|
hardswap = false
|
||||||
|
me_pool_drain_ttl_secs = 90
|
||||||
|
me_instadrain = false
|
||||||
|
me_pool_drain_threshold = 32
|
||||||
|
me_pool_drain_soft_evict_grace_secs = 10
|
||||||
|
me_pool_drain_soft_evict_per_writer = 2
|
||||||
|
me_pool_drain_soft_evict_budget_per_core = 16
|
||||||
|
me_pool_drain_soft_evict_cooldown_ms = 1000
|
||||||
|
me_bind_stale_mode = "never"
|
||||||
|
me_pool_min_fresh_ratio = 0.8
|
||||||
|
me_reinit_drain_timeout_secs = 90
|
||||||
|
tg_connect = 10
|
||||||
|
|
||||||
|
[network]
|
||||||
|
ipv4 = true
|
||||||
|
ipv6 = true
|
||||||
|
prefer = 4
|
||||||
|
multipath = false
|
||||||
|
|
||||||
|
[general.modes]
|
||||||
|
classic = false
|
||||||
|
secure = false
|
||||||
|
tls = true
|
||||||
|
|
||||||
|
[server]
|
||||||
|
listen_addr_ipv4 = "0.0.0.0"
|
||||||
|
listen_addr_ipv6 = "::"
|
||||||
|
|
||||||
|
[[server.listeners]]
|
||||||
|
ip = "0.0.0.0"
|
||||||
|
port = {port}
|
||||||
|
# reuse_allow = false # Set true only when intentionally running multiple telemt instances on same port
|
||||||
|
|
||||||
|
[[server.listeners]]
|
||||||
|
ip = "::"
|
||||||
|
port = {port}
|
||||||
|
|
||||||
|
[timeouts]
|
||||||
|
client_first_byte_idle_secs = 300
|
||||||
|
client_handshake = 60
|
||||||
|
client_keepalive = 60
|
||||||
|
client_ack = 300
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "{domain}"
|
||||||
|
mask = true
|
||||||
|
mask_port = 443
|
||||||
|
fake_cert_len = 2048
|
||||||
|
serverhello_compact = false
|
||||||
|
tls_full_cert_ttl_secs = 90
|
||||||
|
|
||||||
|
[access]
|
||||||
|
user_max_tcp_conns_global_each = 0
|
||||||
|
replay_check_len = 65536
|
||||||
|
replay_window_secs = 120
|
||||||
|
ignore_time_skew = false
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
{username} = "{secret}"
|
||||||
|
|
||||||
|
[[upstreams]]
|
||||||
|
type = "direct"
|
||||||
|
enabled = true
|
||||||
|
weight = 10
|
||||||
|
# Optional per-upstream DC family policy:
|
||||||
|
# ipv6 = true
|
||||||
|
# prefer = 6
|
||||||
|
"#,
|
||||||
|
username = username,
|
||||||
|
secret = secret,
|
||||||
|
port = port,
|
||||||
|
domain = domain,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_cmd(cmd: &str, args: &[&str]) {
|
||||||
|
match Command::new(cmd).args(args).output() {
|
||||||
|
Ok(output) => {
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
eprintln!("[!] {} {} failed: {}", cmd, args.join(" "), stderr.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[!] Failed to run {} {}: {}", cmd, args.join(" "), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_links(username: &str, secret: &str, port: u16, domain: &str) {
|
||||||
|
let domain_hex = hex::encode(domain);
|
||||||
|
|
||||||
|
println!("=== Proxy Links ===");
|
||||||
|
println!("[{}]", username);
|
||||||
|
println!(
|
||||||
|
" EE-TLS: tg://proxy?server=YOUR_SERVER_IP&port={}&secret=ee{}{}",
|
||||||
|
port, secret, domain_hex
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
println!("Replace YOUR_SERVER_IP with your server's public IP.");
|
||||||
|
println!("The proxy will auto-detect and display the correct link on startup.");
|
||||||
|
println!("Check: journalctl -u telemt.service | head -30");
|
||||||
|
println!("===================");
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use crate::daemon::{self, DaemonOptions};
|
|
||||||
|
|
||||||
/// Parses daemon-related options from CLI arguments.
|
|
||||||
pub fn parse_daemon_args(args: &[String]) -> DaemonOptions {
|
|
||||||
let mut opts = DaemonOptions::default();
|
|
||||||
let mut i = 0;
|
|
||||||
|
|
||||||
while i < args.len() {
|
|
||||||
match args[i].as_str() {
|
|
||||||
"--daemon" | "-d" => {
|
|
||||||
opts.daemonize = true;
|
|
||||||
}
|
|
||||||
"--foreground" | "-f" => {
|
|
||||||
opts.foreground = true;
|
|
||||||
}
|
|
||||||
"--pid-file" => {
|
|
||||||
i += 1;
|
|
||||||
if i < args.len() {
|
|
||||||
opts.pid_file = Some(PathBuf::from(&args[i]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s if s.starts_with("--pid-file=") => {
|
|
||||||
opts.pid_file = Some(PathBuf::from(s.trim_start_matches("--pid-file=")));
|
|
||||||
}
|
|
||||||
"--run-as-user" => {
|
|
||||||
i += 1;
|
|
||||||
if i < args.len() {
|
|
||||||
opts.user = Some(args[i].clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s if s.starts_with("--run-as-user=") => {
|
|
||||||
opts.user = Some(s.trim_start_matches("--run-as-user=").to_string());
|
|
||||||
}
|
|
||||||
"--run-as-group" => {
|
|
||||||
i += 1;
|
|
||||||
if i < args.len() {
|
|
||||||
opts.group = Some(args[i].clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s if s.starts_with("--run-as-group=") => {
|
|
||||||
opts.group = Some(s.trim_start_matches("--run-as-group=").to_string());
|
|
||||||
}
|
|
||||||
"--working-dir" => {
|
|
||||||
i += 1;
|
|
||||||
if i < args.len() {
|
|
||||||
opts.working_dir = Some(PathBuf::from(&args[i]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s if s.starts_with("--working-dir=") => {
|
|
||||||
opts.working_dir = Some(PathBuf::from(s.trim_start_matches("--working-dir=")));
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
opts
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sends SIGTERM and waits briefly for graceful PID-file cleanup.
|
|
||||||
pub(super) fn stop(pid_file: &Path) -> i32 {
|
|
||||||
use nix::sys::signal::Signal;
|
|
||||||
|
|
||||||
println!("Stopping telemt daemon...");
|
|
||||||
|
|
||||||
match daemon::signal_pid_file(pid_file, Signal::SIGTERM) {
|
|
||||||
Ok(()) => {
|
|
||||||
println!("Stop signal sent successfully");
|
|
||||||
|
|
||||||
// Wait for process to exit for up to ten seconds.
|
|
||||||
for _ in 0..20 {
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
|
||||||
if let daemon::DaemonStatus::NotRunning = daemon::check_status(pid_file) {
|
|
||||||
println!("Daemon stopped");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
println!("Daemon may still be shutting down");
|
|
||||||
0
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Failed to stop daemon: {}", e);
|
|
||||||
1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sends SIGHUP to trigger configuration reload.
|
|
||||||
pub(super) fn reload(pid_file: &Path) -> i32 {
|
|
||||||
use nix::sys::signal::Signal;
|
|
||||||
|
|
||||||
println!("Reloading telemt configuration...");
|
|
||||||
|
|
||||||
match daemon::signal_pid_file(pid_file, Signal::SIGHUP) {
|
|
||||||
Ok(()) => {
|
|
||||||
println!("Reload signal sent successfully");
|
|
||||||
0
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Failed to reload daemon: {}", e);
|
|
||||||
1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reports daemon status without mutating PID lifecycle state.
|
|
||||||
pub(super) fn status(pid_file: &Path) -> i32 {
|
|
||||||
match daemon::check_status(pid_file) {
|
|
||||||
daemon::DaemonStatus::Running(pid) => {
|
|
||||||
println!("telemt is running (pid {})", pid);
|
|
||||||
0
|
|
||||||
}
|
|
||||||
daemon::DaemonStatus::Stale(pid) => {
|
|
||||||
println!("telemt is not running (stale pid file, was pid {})", pid);
|
|
||||||
1
|
|
||||||
}
|
|
||||||
daemon::DaemonStatus::NotRunning => {
|
|
||||||
println!("telemt is not running");
|
|
||||||
1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use std::fs;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn status_does_not_remove_stale_pid_file() {
|
|
||||||
let directory = tempfile::tempdir().unwrap();
|
|
||||||
let pid_file = directory.path().join("telemt.pid");
|
|
||||||
fs::write(&pid_file, b"2000000000\n").unwrap();
|
|
||||||
|
|
||||||
assert_eq!(status(&pid_file), 1);
|
|
||||||
assert!(pid_file.exists());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-368
@@ -1,368 +0,0 @@
|
|||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
use rand::RngExt;
|
|
||||||
|
|
||||||
use crate::util::trusted_command::resolve_trusted_helper;
|
|
||||||
|
|
||||||
/// Options for the fire-and-forget init command.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct InitOptions {
|
|
||||||
/// Public listener port.
|
|
||||||
pub port: u16,
|
|
||||||
/// TLS camouflage domain.
|
|
||||||
pub domain: String,
|
|
||||||
/// Optional pre-generated proxy secret.
|
|
||||||
pub secret: Option<String>,
|
|
||||||
/// Initial access username.
|
|
||||||
pub username: String,
|
|
||||||
/// Destination directory for generated configuration.
|
|
||||||
pub config_dir: PathBuf,
|
|
||||||
/// Generate service files without starting the service.
|
|
||||||
pub no_start: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for InitOptions {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
port: 443,
|
|
||||||
domain: "www.google.com".to_string(),
|
|
||||||
secret: None,
|
|
||||||
username: "user".to_string(),
|
|
||||||
config_dir: PathBuf::from("/etc/telemt"),
|
|
||||||
no_start: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse --init subcommand options from CLI args.
|
|
||||||
///
|
|
||||||
/// Returns `Some(InitOptions)` if `--init` was found, `None` otherwise.
|
|
||||||
pub fn parse_init_args(args: &[String]) -> Option<InitOptions> {
|
|
||||||
if !args.iter().any(|a| a == "--init") {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut opts = InitOptions::default();
|
|
||||||
let mut i = 0;
|
|
||||||
|
|
||||||
while i < args.len() {
|
|
||||||
match args[i].as_str() {
|
|
||||||
"--port" => {
|
|
||||||
i += 1;
|
|
||||||
if i < args.len() {
|
|
||||||
opts.port = args[i].parse().unwrap_or(443);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"--domain" => {
|
|
||||||
i += 1;
|
|
||||||
if i < args.len() {
|
|
||||||
opts.domain = args[i].clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"--secret" => {
|
|
||||||
i += 1;
|
|
||||||
if i < args.len() {
|
|
||||||
opts.secret = Some(args[i].clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"--user" => {
|
|
||||||
i += 1;
|
|
||||||
if i < args.len() {
|
|
||||||
opts.username = args[i].clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"--config-dir" => {
|
|
||||||
i += 1;
|
|
||||||
if i < args.len() {
|
|
||||||
opts.config_dir = PathBuf::from(&args[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"--no-start" => {
|
|
||||||
opts.no_start = true;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run the fire-and-forget setup.
|
|
||||||
pub fn run_init(opts: InitOptions) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
use crate::service::{self, InitSystem, ServiceOptions};
|
|
||||||
|
|
||||||
eprintln!("[telemt] Fire-and-forget setup");
|
|
||||||
eprintln!();
|
|
||||||
|
|
||||||
let init_system = service::detect_init_system();
|
|
||||||
eprintln!("[+] Detected init system: {}", init_system);
|
|
||||||
|
|
||||||
let secret = match opts.secret {
|
|
||||||
Some(s) => {
|
|
||||||
if s.len() != 32 || !s.chars().all(|c| c.is_ascii_hexdigit()) {
|
|
||||||
eprintln!("[error] Secret must be exactly 32 hex characters");
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
s
|
|
||||||
}
|
|
||||||
None => generate_secret(),
|
|
||||||
};
|
|
||||||
|
|
||||||
eprintln!("[+] Secret: {}", secret);
|
|
||||||
eprintln!("[+] User: {}", opts.username);
|
|
||||||
eprintln!("[+] Port: {}", opts.port);
|
|
||||||
eprintln!("[+] Domain: {}", opts.domain);
|
|
||||||
|
|
||||||
let config_path = opts.config_dir.join("config.toml");
|
|
||||||
let config_content = generate_config(&opts.username, &secret, opts.port, &opts.domain);
|
|
||||||
write_init_file(&config_path, &config_content, 0o600)?;
|
|
||||||
eprintln!("[+] Config written to {}", config_path.display());
|
|
||||||
|
|
||||||
let exe_path =
|
|
||||||
std::env::current_exe().unwrap_or_else(|_| PathBuf::from("/usr/local/bin/telemt"));
|
|
||||||
let service_opts = ServiceOptions {
|
|
||||||
exe_path: &exe_path,
|
|
||||||
config_path: &config_path,
|
|
||||||
// Let the selected init system manage process identity.
|
|
||||||
user: None,
|
|
||||||
group: None,
|
|
||||||
pid_file: "/var/run/telemt.pid",
|
|
||||||
working_dir: Some("/var/lib/telemt"),
|
|
||||||
description: "Telemt MTProxy - Telegram MTProto Proxy",
|
|
||||||
};
|
|
||||||
|
|
||||||
let service_path = service::service_file_path(init_system);
|
|
||||||
let service_content = service::generate_service_file(init_system, &service_opts);
|
|
||||||
let service_mode = if init_system == InitSystem::OpenRC || init_system == InitSystem::FreeBSDRc
|
|
||||||
{
|
|
||||||
0o755
|
|
||||||
} else {
|
|
||||||
0o644
|
|
||||||
};
|
|
||||||
match write_init_file(Path::new(service_path), &service_content, service_mode) {
|
|
||||||
Ok(()) => {
|
|
||||||
eprintln!("[+] Service file written to {}", service_path);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("[!] Cannot write service file (run as root?): {}", e);
|
|
||||||
eprintln!("[!] Manual service file content:");
|
|
||||||
eprintln!("{}", service_content);
|
|
||||||
eprintln!();
|
|
||||||
eprintln!("{}", service::installation_instructions(init_system));
|
|
||||||
print_links(&opts.username, &secret, opts.port, &opts.domain);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match init_system {
|
|
||||||
InitSystem::Systemd => {
|
|
||||||
run_cmd("systemctl", &["daemon-reload"]);
|
|
||||||
run_cmd("systemctl", &["enable", "telemt.service"]);
|
|
||||||
eprintln!("[+] Service enabled");
|
|
||||||
|
|
||||||
if !opts.no_start {
|
|
||||||
run_cmd("systemctl", &["start", "telemt.service"]);
|
|
||||||
eprintln!("[+] Service started");
|
|
||||||
|
|
||||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
|
||||||
let status = resolve_trusted_helper("systemctl").and_then(|command_path| {
|
|
||||||
Command::new(command_path)
|
|
||||||
.args(["is-active", "telemt.service"])
|
|
||||||
.output()
|
|
||||||
.ok()
|
|
||||||
});
|
|
||||||
match status {
|
|
||||||
Some(out) if out.status.success() => {
|
|
||||||
eprintln!("[+] Service is running");
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
eprintln!("[!] Service may not have started correctly");
|
|
||||||
eprintln!("[!] Check: journalctl -u telemt.service -n 20");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
eprintln!("[+] Service not started (--no-start)");
|
|
||||||
eprintln!("[+] Start manually: systemctl start telemt.service");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
InitSystem::OpenRC => {
|
|
||||||
run_cmd("rc-update", &["add", "telemt", "default"]);
|
|
||||||
eprintln!("[+] Service enabled");
|
|
||||||
|
|
||||||
if !opts.no_start {
|
|
||||||
run_cmd("rc-service", &["telemt", "start"]);
|
|
||||||
eprintln!("[+] Service started");
|
|
||||||
} else {
|
|
||||||
eprintln!("[+] Service not started (--no-start)");
|
|
||||||
eprintln!("[+] Start manually: rc-service telemt start");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
InitSystem::FreeBSDRc => {
|
|
||||||
run_cmd("sysrc", &["telemt_enable=YES"]);
|
|
||||||
eprintln!("[+] Service enabled");
|
|
||||||
|
|
||||||
if !opts.no_start {
|
|
||||||
run_cmd("service", &["telemt", "start"]);
|
|
||||||
eprintln!("[+] Service started");
|
|
||||||
} else {
|
|
||||||
eprintln!("[+] Service not started (--no-start)");
|
|
||||||
eprintln!("[+] Start manually: service telemt start");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
InitSystem::Unknown => {
|
|
||||||
eprintln!("[!] Unknown init system - service file written but not installed");
|
|
||||||
eprintln!("[!] You may need to install it manually");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
eprintln!();
|
|
||||||
print_links(&opts.username, &secret, opts.port, &opts.domain);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_init_file(path: &Path, contents: &str, mode: u32) -> std::io::Result<()> {
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
crate::util::secure_fs::atomic_replace(path, contents.as_bytes(), mode)
|
|
||||||
}
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
{
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
std::fs::create_dir_all(parent)?;
|
|
||||||
}
|
|
||||||
let _ = mode;
|
|
||||||
std::fs::write(path, contents)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generate_secret() -> String {
|
|
||||||
let mut rng = rand::rng();
|
|
||||||
let bytes: Vec<u8> = (0..16).map(|_| rng.random::<u8>()).collect();
|
|
||||||
hex::encode(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generate_config(username: &str, secret: &str, port: u16, domain: &str) -> String {
|
|
||||||
format!(
|
|
||||||
r#"# Telemt MTProxy — auto-generated config
|
|
||||||
# Re-run `telemt --init` to regenerate
|
|
||||||
|
|
||||||
show_link = ["{username}"]
|
|
||||||
|
|
||||||
[general]
|
|
||||||
# prefer_ipv6 is deprecated; use [network].prefer
|
|
||||||
prefer_ipv6 = false
|
|
||||||
fast_mode = true
|
|
||||||
use_middle_proxy = false
|
|
||||||
log_level = "normal"
|
|
||||||
desync_all_full = false
|
|
||||||
update_every = 43200
|
|
||||||
hardswap = false
|
|
||||||
me_pool_drain_ttl_secs = 90
|
|
||||||
me_instadrain = false
|
|
||||||
me_pool_drain_threshold = 32
|
|
||||||
me_pool_drain_soft_evict_grace_secs = 10
|
|
||||||
me_pool_drain_soft_evict_per_writer = 2
|
|
||||||
me_pool_drain_soft_evict_budget_per_core = 16
|
|
||||||
me_pool_drain_soft_evict_cooldown_ms = 1000
|
|
||||||
me_bind_stale_mode = "never"
|
|
||||||
me_pool_min_fresh_ratio = 0.8
|
|
||||||
me_reinit_drain_timeout_secs = 90
|
|
||||||
tg_connect = 10
|
|
||||||
|
|
||||||
[network]
|
|
||||||
ipv4 = true
|
|
||||||
ipv6 = true
|
|
||||||
prefer = 4
|
|
||||||
multipath = false
|
|
||||||
|
|
||||||
[general.modes]
|
|
||||||
classic = false
|
|
||||||
secure = false
|
|
||||||
tls = true
|
|
||||||
|
|
||||||
[server]
|
|
||||||
listen_addr_ipv4 = "0.0.0.0"
|
|
||||||
listen_addr_ipv6 = "::"
|
|
||||||
|
|
||||||
[[server.listeners]]
|
|
||||||
ip = "0.0.0.0"
|
|
||||||
port = {port}
|
|
||||||
# reuse_allow = false # Set true only when intentionally running multiple telemt instances on same port
|
|
||||||
|
|
||||||
[[server.listeners]]
|
|
||||||
ip = "::"
|
|
||||||
port = {port}
|
|
||||||
|
|
||||||
[timeouts]
|
|
||||||
client_first_byte_idle_secs = 300
|
|
||||||
client_handshake = 60
|
|
||||||
client_keepalive = 60
|
|
||||||
client_ack = 300
|
|
||||||
|
|
||||||
[censorship]
|
|
||||||
tls_domain = "{domain}"
|
|
||||||
mask = true
|
|
||||||
mask_port = 443
|
|
||||||
fake_cert_len = 2048
|
|
||||||
serverhello_compact = false
|
|
||||||
tls_full_cert_ttl_secs = 90
|
|
||||||
|
|
||||||
[access]
|
|
||||||
user_max_tcp_conns_global_each = 0
|
|
||||||
replay_check_len = 65536
|
|
||||||
replay_window_secs = 120
|
|
||||||
ignore_time_skew = false
|
|
||||||
|
|
||||||
[access.users]
|
|
||||||
{username} = "{secret}"
|
|
||||||
|
|
||||||
[[upstreams]]
|
|
||||||
type = "direct"
|
|
||||||
enabled = true
|
|
||||||
weight = 10
|
|
||||||
# Optional per-upstream DC family policy:
|
|
||||||
# ipv6 = true
|
|
||||||
# prefer = 6
|
|
||||||
"#,
|
|
||||||
username = username,
|
|
||||||
secret = secret,
|
|
||||||
port = port,
|
|
||||||
domain = domain,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_cmd(cmd: &str, args: &[&str]) {
|
|
||||||
let Some(command_path) = resolve_trusted_helper(cmd) else {
|
|
||||||
eprintln!("[!] Refusing unavailable or untrusted command: {}", cmd);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
match Command::new(command_path).args(args).output() {
|
|
||||||
Ok(output) => {
|
|
||||||
if !output.status.success() {
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
||||||
eprintln!("[!] {} {} failed: {}", cmd, args.join(" "), stderr.trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("[!] Failed to run {} {}: {}", cmd, args.join(" "), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_links(username: &str, secret: &str, port: u16, domain: &str) {
|
|
||||||
let domain_hex = hex::encode(domain);
|
|
||||||
|
|
||||||
println!("=== Proxy Links ===");
|
|
||||||
println!("[{}]", username);
|
|
||||||
println!(
|
|
||||||
" EE-TLS: tg://proxy?server=YOUR_SERVER_IP&port={}&secret=ee{}{}",
|
|
||||||
port, secret, domain_hex
|
|
||||||
);
|
|
||||||
println!();
|
|
||||||
println!("Replace YOUR_SERVER_IP with your server's public IP.");
|
|
||||||
println!("The proxy will auto-detect and display the correct link on startup.");
|
|
||||||
println!("Check: journalctl -u telemt.service | head -30");
|
|
||||||
println!("===================");
|
|
||||||
}
|
|
||||||
@@ -62,8 +62,5 @@ use reporting::log_changes;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use watcher::{ReloadState, reload_config};
|
use watcher::{ReloadState, reload_config};
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[path = "hot_reload/base_path_tests.rs"]
|
|
||||||
mod base_path_tests;
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
use base64::Engine as _;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn write_base_path_config(path: &Path, base_path: &str) {
|
|
||||||
let base_path = if base_path.is_empty() {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
format!("base_path = \"{base_path}\"\n")
|
|
||||||
};
|
|
||||||
let config = format!(
|
|
||||||
r#"
|
|
||||||
[access.users]
|
|
||||||
alice = "000102030405060708090a0b0c0d0e0f"
|
|
||||||
|
|
||||||
[[server.listeners]]
|
|
||||||
ip = "127.0.0.1"
|
|
||||||
port = 18080
|
|
||||||
transport = "web"
|
|
||||||
proxy_protocol = false
|
|
||||||
web_client_ip_source = "x_forwarded_for"
|
|
||||||
web_trusted_proxy_cidrs = ["127.0.0.1/32"]
|
|
||||||
|
|
||||||
[web]
|
|
||||||
enabled = true
|
|
||||||
|
|
||||||
[[web.vhosts]]
|
|
||||||
host = "proxy.example.com"
|
|
||||||
{base_path}public_addr = "203.0.113.10:443"
|
|
||||||
|
|
||||||
[web.vhosts.decoy]
|
|
||||||
mode = "http_upstream"
|
|
||||||
upstream = "http://127.0.0.1:18081"
|
|
||||||
|
|
||||||
[[web.vhosts.profiles]]
|
|
||||||
user = "alice"
|
|
||||||
secret_mode = "plain"
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
std::fs::write(path, config).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reload_rejects_invalid_base_then_publishes_route_identity_together() {
|
|
||||||
let directory = tempfile::tempdir().unwrap();
|
|
||||||
let path = directory.path().join("config.toml");
|
|
||||||
write_base_path_config(&path, "");
|
|
||||||
let initial = Arc::new(ProxyConfig::load(&path).unwrap());
|
|
||||||
let initial_hash = ProxyConfig::load_with_metadata(&path)
|
|
||||||
.unwrap()
|
|
||||||
.rendered_hash;
|
|
||||||
let initial_capability = initial.web.runtime.as_ref().unwrap().capabilities[0];
|
|
||||||
let (config_tx, _config_rx) = watch::channel(Arc::clone(&initial));
|
|
||||||
let (log_tx, _log_rx) = watch::channel(initial.general.log_level.clone());
|
|
||||||
let mut reload_state = ReloadState::new(Some(initial_hash));
|
|
||||||
|
|
||||||
write_base_path_config(&path, "/invalid");
|
|
||||||
reload_config(&path, &config_tx, &log_tx, None, None, &mut reload_state);
|
|
||||||
let unchanged = config_tx.borrow().clone();
|
|
||||||
assert!(Arc::ptr_eq(&unchanged, &initial));
|
|
||||||
assert_eq!(unchanged.web.vhosts[0].base_path, "");
|
|
||||||
assert_eq!(
|
|
||||||
unchanged.web.runtime.as_ref().unwrap().capabilities[0],
|
|
||||||
initial_capability
|
|
||||||
);
|
|
||||||
|
|
||||||
write_base_path_config(&path, "dobry-cola-super-app");
|
|
||||||
reload_config(&path, &config_tx, &log_tx, None, None, &mut reload_state);
|
|
||||||
let applied = config_tx.borrow().clone();
|
|
||||||
let runtime = applied.web.runtime.as_ref().unwrap();
|
|
||||||
let vhost = &runtime.vhosts["proxy.example.com"];
|
|
||||||
assert_eq!(applied.web.vhosts[0].base_path, "dobry-cola-super-app");
|
|
||||||
assert_eq!(vhost.base, "/dobry-cola-super-app/");
|
|
||||||
assert_eq!(vhost.capabilities[0], vhost.profiles[0].capability);
|
|
||||||
assert_eq!(runtime.capabilities.as_ref(), vhost.capabilities.as_ref());
|
|
||||||
assert!(!runtime.capabilities.contains(&initial_capability));
|
|
||||||
assert_eq!(
|
|
||||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(vhost.capabilities[0]),
|
|
||||||
"hHz99Xs93EN1j91G9gpNepXwGNNt5YdAFkEVk_LlqdQ"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -144,13 +144,11 @@ fn web_debug_policy_is_hot_while_debug_capacity_is_process_owned() {
|
|||||||
let old = sample_config();
|
let old = sample_config();
|
||||||
let mut new = old.clone();
|
let mut new = old.clone();
|
||||||
new.web.debug.enabled = true;
|
new.web.debug.enabled = true;
|
||||||
new.web.debug.sideband = true;
|
|
||||||
new.web.debug.default_window_secs = 60;
|
new.web.debug.default_window_secs = 60;
|
||||||
new.web.limits.debug_records_capacity += 1;
|
new.web.limits.debug_records_capacity += 1;
|
||||||
|
|
||||||
let applied = overlay_hot_fields(&old, &new);
|
let applied = overlay_hot_fields(&old, &new);
|
||||||
assert!(applied.web.debug.enabled);
|
assert!(applied.web.debug.enabled);
|
||||||
assert!(applied.web.debug.sideband);
|
|
||||||
assert_eq!(applied.web.debug.default_window_secs, 60);
|
assert_eq!(applied.web.debug.default_window_secs, 60);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
applied.web.limits.debug_records_capacity,
|
applied.web.limits.debug_records_capacity,
|
||||||
|
|||||||
@@ -52,24 +52,15 @@ impl ReloadState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_watch_path(path: &Path) -> PathBuf {
|
fn normalize_watch_path(path: &Path) -> PathBuf {
|
||||||
let absolute = if path.is_absolute() {
|
path.canonicalize().unwrap_or_else(|_| {
|
||||||
|
if path.is_absolute() {
|
||||||
path.to_path_buf()
|
path.to_path_buf()
|
||||||
} else {
|
} else {
|
||||||
std::env::current_dir()
|
std::env::current_dir()
|
||||||
.map(|cwd| cwd.join(path))
|
.map(|cwd| cwd.join(path))
|
||||||
.unwrap_or_else(|_| path.to_path_buf())
|
.unwrap_or_else(|_| path.to_path_buf())
|
||||||
};
|
|
||||||
let mut normalized = PathBuf::new();
|
|
||||||
for component in absolute.components() {
|
|
||||||
match component {
|
|
||||||
std::path::Component::CurDir => {}
|
|
||||||
std::path::Component::ParentDir => {
|
|
||||||
normalized.pop();
|
|
||||||
}
|
}
|
||||||
component => normalized.push(component.as_os_str()),
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
normalized
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sync_watch_paths<W: Watcher>(
|
fn sync_watch_paths<W: Watcher>(
|
||||||
@@ -442,21 +433,3 @@ pub fn spawn_config_watcher(
|
|||||||
|
|
||||||
(config_rx, log_rx, task)
|
(config_rx, log_rx, task)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(test, unix))]
|
|
||||||
mod path_tests {
|
|
||||||
use std::os::unix::fs::symlink;
|
|
||||||
|
|
||||||
use super::normalize_watch_path;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn watch_path_normalization_preserves_symlinks() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let real = dir.path().join("real.toml");
|
|
||||||
let linked = dir.path().join("linked.toml");
|
|
||||||
std::fs::write(&real, "").unwrap();
|
|
||||||
symlink(&real, &linked).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(normalize_watch_path(&linked), linked);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+10
-53
@@ -9,7 +9,6 @@ use rand::RngExt;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
use crate::crypto::sha256;
|
|
||||||
use crate::error::{ProxyError, Result};
|
use crate::error::{ProxyError, Result};
|
||||||
|
|
||||||
use super::defaults::*;
|
use super::defaults::*;
|
||||||
@@ -36,9 +35,7 @@ mod validate_server;
|
|||||||
mod validate_web;
|
mod validate_web;
|
||||||
mod validation;
|
mod validation;
|
||||||
|
|
||||||
use self::includes::{
|
use self::includes::{hash_rendered_snapshot, normalize_config_path, preprocess_includes};
|
||||||
hash_rendered_snapshot, normalize_config_path, preprocess_includes, read_config_source,
|
|
||||||
};
|
|
||||||
use self::normalize::{
|
use self::normalize::{
|
||||||
is_valid_ad_tag, is_valid_tls_domain_name, normalize_domain_to_ascii,
|
is_valid_ad_tag, is_valid_tls_domain_name, normalize_domain_to_ascii,
|
||||||
normalize_exclusive_mask_target, normalize_mask_host_to_ascii, parse_exclusive_mask_target,
|
normalize_exclusive_mask_target, normalize_mask_host_to_ascii, parse_exclusive_mask_target,
|
||||||
@@ -66,9 +63,9 @@ const MAX_API_REQUEST_BODY_LIMIT_BYTES: usize = 1024 * 1024;
|
|||||||
pub(crate) struct LoadedConfig {
|
pub(crate) struct LoadedConfig {
|
||||||
/// Validated and normalized effective configuration.
|
/// Validated and normalized effective configuration.
|
||||||
pub(crate) config: ProxyConfig,
|
pub(crate) config: ProxyConfig,
|
||||||
/// Normalized absolute paths participating in the recursive include graph.
|
/// Canonical paths participating in the recursive include graph.
|
||||||
pub(crate) source_files: Vec<PathBuf>,
|
pub(crate) source_files: Vec<PathBuf>,
|
||||||
/// Raw source bytes keyed by normalized absolute source path.
|
/// Raw source bytes keyed by canonical source path.
|
||||||
pub(crate) source_contents: BTreeMap<PathBuf, String>,
|
pub(crate) source_contents: BTreeMap<PathBuf, String>,
|
||||||
/// Legacy hash of the include-expanded rendered snapshot.
|
/// Legacy hash of the include-expanded rendered snapshot.
|
||||||
pub(crate) rendered_hash: u64,
|
pub(crate) rendered_hash: u64,
|
||||||
@@ -77,7 +74,7 @@ pub(crate) struct LoadedConfig {
|
|||||||
/// Raw recursive source graph captured before typed deserialization.
|
/// Raw recursive source graph captured before typed deserialization.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct ConfigSourceGraph {
|
pub(crate) struct ConfigSourceGraph {
|
||||||
/// Raw source bytes keyed by normalized absolute source path.
|
/// Raw source bytes keyed by canonical source path.
|
||||||
pub(crate) source_contents: BTreeMap<PathBuf, String>,
|
pub(crate) source_contents: BTreeMap<PathBuf, String>,
|
||||||
/// Include-expanded TOML used for typed deserialization.
|
/// Include-expanded TOML used for typed deserialization.
|
||||||
pub(crate) rendered: String,
|
pub(crate) rendered: String,
|
||||||
@@ -177,42 +174,21 @@ impl ProxyConfig {
|
|||||||
source_overrides: &BTreeMap<PathBuf, String>,
|
source_overrides: &BTreeMap<PathBuf, String>,
|
||||||
) -> Result<ConfigSourceGraph> {
|
) -> Result<ConfigSourceGraph> {
|
||||||
let path = path.as_ref();
|
let path = path.as_ref();
|
||||||
let mut previous = Self::capture_source_graph(path, source_overrides)?;
|
let normalized_path = normalize_config_path(path);
|
||||||
for _ in 0..2 {
|
|
||||||
let current = Self::capture_source_graph(path, source_overrides)?;
|
|
||||||
if current.source_contents == previous.source_contents
|
|
||||||
&& current.rendered == previous.rendered
|
|
||||||
{
|
|
||||||
return Ok(current);
|
|
||||||
}
|
|
||||||
previous = current;
|
|
||||||
}
|
|
||||||
Err(ProxyError::Config(
|
|
||||||
"config source graph changed repeatedly while it was read".to_string(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn capture_source_graph(
|
|
||||||
path: &Path,
|
|
||||||
source_overrides: &BTreeMap<PathBuf, String>,
|
|
||||||
) -> Result<ConfigSourceGraph> {
|
|
||||||
let path = path.as_ref();
|
|
||||||
let (normalized_path, disk_content) = read_config_source(path)?;
|
|
||||||
let content = source_overrides
|
let content = source_overrides
|
||||||
.get(&normalized_path)
|
.get(&normalized_path)
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or(disk_content);
|
.map(Ok)
|
||||||
let base_dir = normalized_path
|
.unwrap_or_else(|| std::fs::read_to_string(path))
|
||||||
.parent()
|
.map_err(|e| ProxyError::Config(e.to_string()))?;
|
||||||
.unwrap_or(Path::new("."))
|
let base_dir = path.parent().unwrap_or(Path::new("."));
|
||||||
.to_path_buf();
|
|
||||||
let mut source_files = BTreeSet::new();
|
let mut source_files = BTreeSet::new();
|
||||||
source_files.insert(normalized_path.clone());
|
source_files.insert(normalized_path.clone());
|
||||||
let mut source_contents = BTreeMap::new();
|
let mut source_contents = BTreeMap::new();
|
||||||
source_contents.insert(normalized_path, content.clone());
|
source_contents.insert(normalized_path, content.clone());
|
||||||
let processed = preprocess_includes(
|
let processed = preprocess_includes(
|
||||||
&content,
|
&content,
|
||||||
&base_dir,
|
base_dir,
|
||||||
0,
|
0,
|
||||||
&mut source_files,
|
&mut source_files,
|
||||||
&mut source_contents,
|
&mut source_contents,
|
||||||
@@ -254,25 +230,6 @@ impl ProxyConfig {
|
|||||||
self.runtime_user_auth.as_deref()
|
self.runtime_user_auth.as_deref()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the credential identity frozen into this runtime snapshot.
|
|
||||||
pub(crate) fn runtime_user_credential_id(&self, user: &str) -> Option<[u8; 16]> {
|
|
||||||
self.runtime_user_auth()
|
|
||||||
.and_then(|snapshot| snapshot.credential_id_by_name(user))
|
|
||||||
.or_else(|| {
|
|
||||||
self.access
|
|
||||||
.users
|
|
||||||
.get(user)
|
|
||||||
.and_then(|secret| hex::decode(secret).ok())
|
|
||||||
.and_then(|secret| <[u8; 16]>::try_from(secret).ok())
|
|
||||||
.map(|secret| {
|
|
||||||
let digest = sha256(&secret);
|
|
||||||
let mut credential_id = [0; 16];
|
|
||||||
credential_id.copy_from_slice(&digest[..16]);
|
|
||||||
credential_id
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validates cross-field configuration invariants after deserialization.
|
/// Validates cross-field configuration invariants after deserialization.
|
||||||
pub fn validate(&self) -> Result<()> {
|
pub fn validate(&self) -> Result<()> {
|
||||||
if self.access.users.is_empty() {
|
if self.access.users.is_empty() {
|
||||||
|
|||||||
+10
-45
@@ -4,27 +4,16 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use crate::error::{ProxyError, Result};
|
use crate::error::{ProxyError, Result};
|
||||||
|
|
||||||
const MAX_CONFIG_SOURCE_BYTES: usize = 8 * 1024 * 1024;
|
|
||||||
|
|
||||||
pub(super) fn normalize_config_path(path: &Path) -> PathBuf {
|
pub(super) fn normalize_config_path(path: &Path) -> PathBuf {
|
||||||
let absolute = if path.is_absolute() {
|
path.canonicalize().unwrap_or_else(|_| {
|
||||||
|
if path.is_absolute() {
|
||||||
path.to_path_buf()
|
path.to_path_buf()
|
||||||
} else {
|
} else {
|
||||||
std::env::current_dir()
|
std::env::current_dir()
|
||||||
.map(|cwd| cwd.join(path))
|
.map(|cwd| cwd.join(path))
|
||||||
.unwrap_or_else(|_| path.to_path_buf())
|
.unwrap_or_else(|_| path.to_path_buf())
|
||||||
};
|
|
||||||
let mut normalized = PathBuf::new();
|
|
||||||
for component in absolute.components() {
|
|
||||||
match component {
|
|
||||||
std::path::Component::CurDir => {}
|
|
||||||
std::path::Component::ParentDir => {
|
|
||||||
normalized.pop();
|
|
||||||
}
|
}
|
||||||
component => normalized.push(component.as_os_str()),
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
normalized
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn hash_rendered_snapshot(rendered: &str) -> u64 {
|
pub(super) fn hash_rendered_snapshot(rendered: &str) -> u64 {
|
||||||
@@ -33,29 +22,6 @@ pub(super) fn hash_rendered_snapshot(rendered: &str) -> u64 {
|
|||||||
hasher.finish()
|
hasher.finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn read_config_source(path: &Path) -> Result<(PathBuf, String)> {
|
|
||||||
#[cfg(unix)]
|
|
||||||
let bytes = crate::util::secure_fs::read_regular_limited(path, MAX_CONFIG_SOURCE_BYTES)
|
|
||||||
.map_err(|error| ProxyError::Config(error.to_string()))?;
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
let bytes = std::fs::read(path).map_err(|error| ProxyError::Config(error.to_string()))?;
|
|
||||||
if bytes.len() > MAX_CONFIG_SOURCE_BYTES {
|
|
||||||
return Err(ProxyError::Config(format!(
|
|
||||||
"config source `{}` exceeds {} bytes",
|
|
||||||
path.display(),
|
|
||||||
MAX_CONFIG_SOURCE_BYTES
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let contents = String::from_utf8(bytes).map_err(|error| {
|
|
||||||
ProxyError::Config(format!(
|
|
||||||
"config source `{}` is not valid UTF-8: {error}",
|
|
||||||
path.display()
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
let normalized = normalize_config_path(path);
|
|
||||||
Ok((normalized, contents))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn preprocess_includes(
|
pub(super) fn preprocess_includes(
|
||||||
content: &str,
|
content: &str,
|
||||||
base_dir: &Path,
|
base_dir: &Path,
|
||||||
@@ -75,17 +41,16 @@ pub(super) fn preprocess_includes(
|
|||||||
if let Some(rest) = rest.strip_prefix('=') {
|
if let Some(rest) = rest.strip_prefix('=') {
|
||||||
let path_str = rest.trim().trim_matches('"');
|
let path_str = rest.trim().trim_matches('"');
|
||||||
let resolved = base_dir.join(path_str);
|
let resolved = base_dir.join(path_str);
|
||||||
let (normalized, disk_contents) = read_config_source(&resolved)?;
|
let normalized = normalize_config_path(&resolved);
|
||||||
|
source_files.insert(normalized.clone());
|
||||||
let included = source_overrides
|
let included = source_overrides
|
||||||
.get(&normalized)
|
.get(&normalized)
|
||||||
.cloned()
|
.cloned()
|
||||||
.or_else(|| source_contents.get(&normalized).cloned())
|
.map(Ok)
|
||||||
.unwrap_or(disk_contents);
|
.unwrap_or_else(|| std::fs::read_to_string(&resolved))
|
||||||
source_files.insert(normalized.clone());
|
.map_err(|e| ProxyError::Config(e.to_string()))?;
|
||||||
source_contents
|
source_contents.insert(normalized, included.clone());
|
||||||
.entry(normalized.clone())
|
let included_dir = resolved.parent().unwrap_or(base_dir);
|
||||||
.or_insert_with(|| included.clone());
|
|
||||||
let included_dir = normalized.parent().unwrap_or(base_dir);
|
|
||||||
output.push_str(&preprocess_includes(
|
output.push_str(&preprocess_includes(
|
||||||
&included,
|
&included,
|
||||||
included_dir,
|
included_dir,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ use std::collections::HashMap;
|
|||||||
use std::collections::hash_map::DefaultHasher;
|
use std::collections::hash_map::DefaultHasher;
|
||||||
use std::hash::Hasher;
|
use std::hash::Hasher;
|
||||||
|
|
||||||
use crate::crypto::sha256;
|
|
||||||
use crate::error::{ProxyError, Result};
|
use crate::error::{ProxyError, Result};
|
||||||
|
|
||||||
const ACCESS_SECRET_BYTES: usize = 16;
|
const ACCESS_SECRET_BYTES: usize = 16;
|
||||||
@@ -12,7 +11,6 @@ const ACCESS_SECRET_BYTES: usize = 16;
|
|||||||
pub(crate) struct UserAuthSnapshot {
|
pub(crate) struct UserAuthSnapshot {
|
||||||
entries: Vec<UserAuthEntry>,
|
entries: Vec<UserAuthEntry>,
|
||||||
by_name: HashMap<String, u32>,
|
by_name: HashMap<String, u32>,
|
||||||
by_hint_key: HashMap<u64, Vec<u32>>,
|
|
||||||
sni_index: HashMap<u64, Vec<u32>>,
|
sni_index: HashMap<u64, Vec<u32>>,
|
||||||
sni_initial_index: HashMap<u8, Vec<u32>>,
|
sni_initial_index: HashMap<u8, Vec<u32>>,
|
||||||
}
|
}
|
||||||
@@ -21,23 +19,16 @@ pub(crate) struct UserAuthSnapshot {
|
|||||||
pub(crate) struct UserAuthEntry {
|
pub(crate) struct UserAuthEntry {
|
||||||
pub(crate) user: String,
|
pub(crate) user: String,
|
||||||
pub(crate) secret: [u8; ACCESS_SECRET_BYTES],
|
pub(crate) secret: [u8; ACCESS_SECRET_BYTES],
|
||||||
/// Stable secret identity used by process-wide admission fencing.
|
|
||||||
pub(crate) credential_id: [u8; 16],
|
|
||||||
/// Stable compact key used only to resolve bounded authentication hints.
|
|
||||||
pub(crate) hint_key: u64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UserAuthSnapshot {
|
impl UserAuthSnapshot {
|
||||||
pub(super) fn from_users(users: &HashMap<String, String>) -> Result<Self> {
|
pub(super) fn from_users(users: &HashMap<String, String>) -> Result<Self> {
|
||||||
let mut entries = Vec::with_capacity(users.len());
|
let mut entries = Vec::with_capacity(users.len());
|
||||||
let mut by_name = HashMap::with_capacity(users.len());
|
let mut by_name = HashMap::with_capacity(users.len());
|
||||||
let mut by_hint_key = HashMap::with_capacity(users.len());
|
|
||||||
let mut sni_index = HashMap::with_capacity(users.len());
|
let mut sni_index = HashMap::with_capacity(users.len());
|
||||||
let mut sni_initial_index = HashMap::with_capacity(users.len());
|
let mut sni_initial_index = HashMap::with_capacity(users.len());
|
||||||
|
|
||||||
let mut ordered_users = users.iter().collect::<Vec<_>>();
|
for (user, secret_hex) in users {
|
||||||
ordered_users.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
|
|
||||||
for (user, secret_hex) in ordered_users {
|
|
||||||
let decoded = hex::decode(secret_hex).map_err(|_| ProxyError::InvalidSecret {
|
let decoded = hex::decode(secret_hex).map_err(|_| ProxyError::InvalidSecret {
|
||||||
user: user.clone(),
|
user: user.clone(),
|
||||||
reason: "Must be 32 hex characters".to_string(),
|
reason: "Must be 32 hex characters".to_string(),
|
||||||
@@ -55,30 +46,11 @@ impl UserAuthSnapshot {
|
|||||||
|
|
||||||
let mut secret = [0u8; ACCESS_SECRET_BYTES];
|
let mut secret = [0u8; ACCESS_SECRET_BYTES];
|
||||||
secret.copy_from_slice(&decoded);
|
secret.copy_from_slice(&decoded);
|
||||||
let digest = sha256(&secret);
|
|
||||||
let mut credential_id = [0; 16];
|
|
||||||
credential_id.copy_from_slice(&digest[..16]);
|
|
||||||
let hint_key = u64::from_le_bytes([
|
|
||||||
credential_id[0],
|
|
||||||
credential_id[1],
|
|
||||||
credential_id[2],
|
|
||||||
credential_id[3],
|
|
||||||
credential_id[4],
|
|
||||||
credential_id[5],
|
|
||||||
credential_id[6],
|
|
||||||
credential_id[7],
|
|
||||||
]) | 1;
|
|
||||||
entries.push(UserAuthEntry {
|
entries.push(UserAuthEntry {
|
||||||
user: user.clone(),
|
user: user.clone(),
|
||||||
secret,
|
secret,
|
||||||
credential_id,
|
|
||||||
hint_key,
|
|
||||||
});
|
});
|
||||||
by_name.insert(user.clone(), user_id);
|
by_name.insert(user.clone(), user_id);
|
||||||
by_hint_key
|
|
||||||
.entry(hint_key)
|
|
||||||
.or_insert_with(Vec::new)
|
|
||||||
.push(user_id);
|
|
||||||
sni_index
|
sni_index
|
||||||
.entry(Self::sni_lookup_hash(user))
|
.entry(Self::sni_lookup_hash(user))
|
||||||
.or_insert_with(Vec::new)
|
.or_insert_with(Vec::new)
|
||||||
@@ -98,7 +70,6 @@ impl UserAuthSnapshot {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
entries,
|
entries,
|
||||||
by_name,
|
by_name,
|
||||||
by_hint_key,
|
|
||||||
sni_index,
|
sni_index,
|
||||||
sni_initial_index,
|
sni_initial_index,
|
||||||
})
|
})
|
||||||
@@ -117,18 +88,6 @@ impl UserAuthSnapshot {
|
|||||||
self.entries.get(idx)
|
self.entries.get(idx)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the stable credential identity for an exact configured username.
|
|
||||||
pub(crate) fn credential_id_by_name(&self, user: &str) -> Option<[u8; 16]> {
|
|
||||||
self.user_id_by_name(user)
|
|
||||||
.and_then(|user_id| self.entry_by_id(user_id))
|
|
||||||
.map(|entry| entry.credential_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns every bounded authentication candidate sharing a stable hint key.
|
|
||||||
pub(crate) fn candidate_ids_by_hint_key(&self, hint_key: u64) -> Option<&[u32]> {
|
|
||||||
self.by_hint_key.get(&hint_key).map(Vec::as_slice)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn sni_candidates(&self, sni: &str) -> Option<&[u32]> {
|
pub(crate) fn sni_candidates(&self, sni: &str) -> Option<&[u32]> {
|
||||||
self.sni_index
|
self.sni_index
|
||||||
.get(&Self::sni_lookup_hash(sni))
|
.get(&Self::sni_lookup_hash(sni))
|
||||||
@@ -151,48 +110,3 @@ impl UserAuthSnapshot {
|
|||||||
hasher.finish()
|
hasher.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn credential_hint_survives_positional_id_shift() {
|
|
||||||
let mut initial = HashMap::new();
|
|
||||||
initial.insert(
|
|
||||||
"alice".to_string(),
|
|
||||||
"11111111111111111111111111111111".to_string(),
|
|
||||||
);
|
|
||||||
initial.insert(
|
|
||||||
"bob".to_string(),
|
|
||||||
"22222222222222222222222222222222".to_string(),
|
|
||||||
);
|
|
||||||
let initial = UserAuthSnapshot::from_users(&initial).unwrap();
|
|
||||||
let initial_id = initial.user_id_by_name("alice").unwrap();
|
|
||||||
let hint_key = initial.entry_by_id(initial_id).unwrap().hint_key;
|
|
||||||
|
|
||||||
let mut reloaded = HashMap::new();
|
|
||||||
reloaded.insert(
|
|
||||||
"aaron".to_string(),
|
|
||||||
"33333333333333333333333333333333".to_string(),
|
|
||||||
);
|
|
||||||
reloaded.insert(
|
|
||||||
"alice".to_string(),
|
|
||||||
"11111111111111111111111111111111".to_string(),
|
|
||||||
);
|
|
||||||
reloaded.insert(
|
|
||||||
"bob".to_string(),
|
|
||||||
"22222222222222222222222222222222".to_string(),
|
|
||||||
);
|
|
||||||
let reloaded = UserAuthSnapshot::from_users(&reloaded).unwrap();
|
|
||||||
let reloaded_id = reloaded.user_id_by_name("alice").unwrap();
|
|
||||||
|
|
||||||
assert_ne!(initial_id, reloaded_id);
|
|
||||||
assert!(
|
|
||||||
reloaded
|
|
||||||
.candidate_ids_by_hint_key(hint_key)
|
|
||||||
.unwrap()
|
|
||||||
.contains(&reloaded_id)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+102
-186
@@ -5,33 +5,15 @@ use std::path::Path;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use std::ffi::OsString;
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
#[cfg(unix)]
|
|
||||||
use std::os::unix::ffi::OsStringExt;
|
|
||||||
#[cfg(unix)]
|
|
||||||
use std::os::unix::fs::MetadataExt;
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
use nix::dir::Dir;
|
|
||||||
#[cfg(unix)]
|
|
||||||
use nix::fcntl::{OFlag, openat};
|
|
||||||
#[cfg(unix)]
|
|
||||||
use nix::sys::stat::Mode;
|
|
||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use hmac::{Hmac, Mac};
|
use hmac::{Hmac, Mac};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
#[cfg(unix)]
|
|
||||||
use crate::util::secure_fs::open_dir_nofollow;
|
|
||||||
|
|
||||||
// Path-based static snapshot fallback for platforms without directory descriptors.
|
const WEB_CAPABILITY_CONTEXT: &[u8] = b"tdesktop-web-proxy-bridge-v1\n";
|
||||||
#[cfg(not(unix))]
|
|
||||||
mod static_site_fallback;
|
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
@@ -42,7 +24,6 @@ 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;
|
||||||
|
|
||||||
@@ -69,11 +50,8 @@ 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 = derive_web_capability(
|
let capability =
|
||||||
&client_secret[..client_secret_len],
|
derive_web_capability(&client_secret[..client_secret_len], vhost.host.as_bytes())?;
|
||||||
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!(
|
||||||
@@ -85,7 +63,6 @@ pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> {
|
|||||||
host: vhost.host.clone(),
|
host: vhost.host.clone(),
|
||||||
public_addr: vhost.public_addr,
|
public_addr: vhost.public_addr,
|
||||||
user: profile.user.clone(),
|
user: profile.user.clone(),
|
||||||
credential_id: auth_entry.credential_id,
|
|
||||||
secret_mode: profile.secret_mode,
|
secret_mode: profile.secret_mode,
|
||||||
carrier: config.web.carrier,
|
carrier: config.web.carrier,
|
||||||
carrier_negotiation_enabled: config.web.carrier_negotiation_enabled(),
|
carrier_negotiation_enabled: config.web.carrier_negotiation_enabled(),
|
||||||
@@ -109,7 +86,6 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -117,11 +93,6 @@ 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,
|
||||||
@@ -134,7 +105,6 @@ 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(())
|
||||||
}
|
}
|
||||||
@@ -146,23 +116,12 @@ 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, host, and base path.
|
/// Derives the Telegram Desktop WEB capability for one exact secret and host.
|
||||||
pub(crate) fn derive_web_capability(
|
pub(crate) fn derive_web_capability(secret: &[u8], host: &[u8]) -> Result<[u8; 32]> {
|
||||||
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()))?;
|
||||||
if base_path.is_empty() {
|
mac.update(WEB_CAPABILITY_CONTEXT);
|
||||||
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())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,31 +192,34 @@ fn load_static_site(
|
|||||||
total_files: &mut usize,
|
total_files: &mut usize,
|
||||||
total_bytes: &mut usize,
|
total_bytes: &mut usize,
|
||||||
) -> Result<WebStaticSite> {
|
) -> Result<WebStaticSite> {
|
||||||
|
let root_metadata = fs::symlink_metadata(root).map_err(|error| {
|
||||||
|
ProxyError::Config(format!(
|
||||||
|
"failed to inspect WEB static directory `{}`: {error}",
|
||||||
|
root.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"WEB static directory `{}` must be a real directory, not a symlink",
|
||||||
|
root.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let canonical_root = fs::canonicalize(root).map_err(|error| {
|
||||||
|
ProxyError::Config(format!(
|
||||||
|
"failed to canonicalize WEB static directory `{}`: {error}",
|
||||||
|
root.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
let mut assets = BTreeMap::new();
|
let mut assets = BTreeMap::new();
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
let directory = open_static_root(root)?;
|
|
||||||
load_static_directory(
|
load_static_directory(
|
||||||
directory,
|
&canonical_root,
|
||||||
Path::new(""),
|
&canonical_root,
|
||||||
root,
|
|
||||||
&mut assets,
|
&mut assets,
|
||||||
total_files,
|
total_files,
|
||||||
total_bytes,
|
total_bytes,
|
||||||
limits,
|
limits,
|
||||||
0,
|
0,
|
||||||
)?;
|
)?;
|
||||||
}
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
{
|
|
||||||
static_site_fallback::load_static_site_by_path(
|
|
||||||
root,
|
|
||||||
limits,
|
|
||||||
&mut assets,
|
|
||||||
total_files,
|
|
||||||
total_bytes,
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
if !assets.contains_key(&format!("/{index}")) {
|
if !assets.contains_key(&format!("/{index}")) {
|
||||||
return Err(ProxyError::Config(format!(
|
return Err(ProxyError::Config(format!(
|
||||||
"WEB static directory `{}` does not contain index `{index}`",
|
"WEB static directory `{}` does not contain index `{index}`",
|
||||||
@@ -270,95 +232,54 @@ fn load_static_site(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn open_static_root(root: &Path) -> Result<Dir> {
|
|
||||||
let descriptor = open_dir_nofollow(root).map_err(|error| {
|
|
||||||
ProxyError::Config(format!(
|
|
||||||
"WEB static directory `{}` must be a real directory, not a symlink: {error}",
|
|
||||||
root.display()
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
Dir::from_fd(descriptor).map_err(|error| {
|
|
||||||
ProxyError::Config(format!(
|
|
||||||
"failed to read WEB static directory `{}`: {error}",
|
|
||||||
root.display()
|
|
||||||
))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn load_static_directory(
|
fn load_static_directory(
|
||||||
mut directory: Dir,
|
|
||||||
relative: &Path,
|
|
||||||
root: &Path,
|
root: &Path,
|
||||||
|
directory: &Path,
|
||||||
assets: &mut BTreeMap<String, WebStaticAsset>,
|
assets: &mut BTreeMap<String, WebStaticAsset>,
|
||||||
total_files: &mut usize,
|
total_files: &mut usize,
|
||||||
total_bytes: &mut usize,
|
total_bytes: &mut usize,
|
||||||
limits: &WebLimitsConfig,
|
limits: &WebLimitsConfig,
|
||||||
depth: usize,
|
depth: usize,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut entries = Vec::new();
|
let entries = fs::read_dir(directory).map_err(|error| {
|
||||||
for entry in directory.iter() {
|
|
||||||
let entry = entry.map_err(|error| {
|
|
||||||
ProxyError::Config(format!(
|
ProxyError::Config(format!(
|
||||||
"failed to read WEB static directory `{}`: {error}",
|
"failed to read WEB static directory `{}`: {error}",
|
||||||
root.join(relative).display()
|
directory.display()
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
let name = entry.file_name().to_bytes();
|
for entry in entries {
|
||||||
if name == b"." || name == b".." {
|
let entry = entry.map_err(|error| {
|
||||||
continue;
|
ProxyError::Config(format!("failed to read WEB static entry: {error}"))
|
||||||
}
|
})?;
|
||||||
if *total_files >= limits.max_static_files {
|
if *total_files >= limits.max_static_files {
|
||||||
return Err(ProxyError::Config(
|
return Err(ProxyError::Config(
|
||||||
"WEB static entries exceed process-wide web.limits.max_static_files".to_string(),
|
"WEB static entries exceed process-wide web.limits.max_static_files".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
*total_files += 1;
|
*total_files += 1;
|
||||||
entries.push(OsString::from_vec(name.to_vec()));
|
let path = entry.path();
|
||||||
}
|
let file_type = entry.file_type().map_err(|error| {
|
||||||
entries.sort_unstable();
|
|
||||||
|
|
||||||
for name in entries {
|
|
||||||
let relative_path = relative.join(&name);
|
|
||||||
let display_path = root.join(&relative_path);
|
|
||||||
let descriptor = openat(
|
|
||||||
&directory,
|
|
||||||
name.as_os_str(),
|
|
||||||
OFlag::O_RDONLY | OFlag::O_NOFOLLOW | OFlag::O_CLOEXEC,
|
|
||||||
Mode::empty(),
|
|
||||||
)
|
|
||||||
.map_err(|error| {
|
|
||||||
ProxyError::Config(format!(
|
|
||||||
"failed to open WEB static entry `{}` without following symlinks: {error}",
|
|
||||||
display_path.display()
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
let file = fs::File::from(descriptor);
|
|
||||||
let metadata = file.metadata().map_err(|error| {
|
|
||||||
ProxyError::Config(format!(
|
ProxyError::Config(format!(
|
||||||
"failed to inspect WEB static entry `{}`: {error}",
|
"failed to inspect WEB static entry `{}`: {error}",
|
||||||
display_path.display()
|
path.display()
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
if metadata.is_dir() {
|
if file_type.is_symlink() {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"WEB static entry `{}` must not be a symlink",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if file_type.is_dir() {
|
||||||
if depth >= MAX_WEB_STATIC_DEPTH {
|
if depth >= MAX_WEB_STATIC_DEPTH {
|
||||||
return Err(ProxyError::Config(format!(
|
return Err(ProxyError::Config(format!(
|
||||||
"WEB static directory `{}` exceeds the maximum nesting depth",
|
"WEB static directory `{}` exceeds the maximum nesting depth",
|
||||||
display_path.display()
|
path.display()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
let descriptor = file.into();
|
|
||||||
let child = Dir::from_fd(descriptor).map_err(|error| {
|
|
||||||
ProxyError::Config(format!(
|
|
||||||
"failed to open WEB static directory `{}`: {error}",
|
|
||||||
display_path.display()
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
load_static_directory(
|
load_static_directory(
|
||||||
child,
|
|
||||||
&relative_path,
|
|
||||||
root,
|
root,
|
||||||
|
&path,
|
||||||
assets,
|
assets,
|
||||||
total_files,
|
total_files,
|
||||||
total_bytes,
|
total_bytes,
|
||||||
@@ -367,44 +288,41 @@ fn load_static_directory(
|
|||||||
)?;
|
)?;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if !metadata.is_file() {
|
if !file_type.is_file() {
|
||||||
return Err(ProxyError::Config(format!(
|
return Err(ProxyError::Config(format!(
|
||||||
"WEB static entry `{}` must be a regular file",
|
"WEB static entry `{}` must be a regular file",
|
||||||
display_path.display()
|
path.display()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
load_static_file(
|
let mut options = fs::OpenOptions::new();
|
||||||
file,
|
options.read(true);
|
||||||
&metadata,
|
#[cfg(unix)]
|
||||||
&relative_path,
|
options.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW);
|
||||||
&display_path,
|
let file = options.open(&path).map_err(|error| {
|
||||||
assets,
|
|
||||||
total_bytes,
|
|
||||||
limits,
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_static_file(
|
|
||||||
mut file: fs::File,
|
|
||||||
metadata: &fs::Metadata,
|
|
||||||
relative: &Path,
|
|
||||||
display_path: &Path,
|
|
||||||
assets: &mut BTreeMap<String, WebStaticAsset>,
|
|
||||||
total_bytes: &mut usize,
|
|
||||||
limits: &WebLimitsConfig,
|
|
||||||
) -> Result<()> {
|
|
||||||
let file_len = usize::try_from(metadata.len()).map_err(|_| {
|
|
||||||
ProxyError::Config(format!(
|
ProxyError::Config(format!(
|
||||||
"WEB static file `{}` is too large",
|
"failed to open WEB static file `{}`: {error}",
|
||||||
display_path.display()
|
path.display()
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
let metadata = file.metadata().map_err(|error| {
|
||||||
|
ProxyError::Config(format!(
|
||||||
|
"failed to inspect WEB static file `{}`: {error}",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if !metadata.is_file() {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"WEB static entry `{}` changed before it was opened",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let file_len = usize::try_from(metadata.len()).map_err(|_| {
|
||||||
|
ProxyError::Config(format!("WEB static file `{}` is too large", path.display()))
|
||||||
|
})?;
|
||||||
if file_len > limits.max_static_file_bytes {
|
if file_len > limits.max_static_file_bytes {
|
||||||
return Err(ProxyError::Config(format!(
|
return Err(ProxyError::Config(format!(
|
||||||
"WEB static file `{}` exceeds web.limits.max_static_file_bytes",
|
"WEB static file `{}` exceeds web.limits.max_static_file_bytes",
|
||||||
display_path.display()
|
path.display()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
*total_bytes = total_bytes.checked_add(file_len).ok_or_else(|| {
|
*total_bytes = total_bytes.checked_add(file_len).ok_or_else(|| {
|
||||||
@@ -415,27 +333,23 @@ fn load_static_file(
|
|||||||
"WEB static snapshots exceed process-wide web.limits.max_static_bytes".to_string(),
|
"WEB static snapshots exceed process-wide web.limits.max_static_bytes".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
let relative = path.strip_prefix(root).map_err(|_| {
|
||||||
|
ProxyError::Config("WEB static path escaped its configured root".to_string())
|
||||||
|
})?;
|
||||||
let route = static_route(relative)?;
|
let route = static_route(relative)?;
|
||||||
let mut body = Vec::with_capacity(file_len);
|
let mut body = Vec::with_capacity(file_len);
|
||||||
file.by_ref()
|
file.take(limits.max_static_file_bytes as u64 + 1)
|
||||||
.take(limits.max_static_file_bytes as u64 + 1)
|
|
||||||
.read_to_end(&mut body)
|
.read_to_end(&mut body)
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
ProxyError::Config(format!(
|
ProxyError::Config(format!(
|
||||||
"failed to read WEB static file `{}`: {error}",
|
"failed to read WEB static file `{}`: {error}",
|
||||||
display_path.display()
|
path.display()
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
let final_metadata = file.metadata().map_err(|error| {
|
if body.len() != file_len {
|
||||||
ProxyError::Config(format!(
|
|
||||||
"failed to recheck WEB static file `{}`: {error}",
|
|
||||||
display_path.display()
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
if body.len() != file_len || !static_file_version_matches(metadata, &final_metadata) {
|
|
||||||
return Err(ProxyError::Config(format!(
|
return Err(ProxyError::Config(format!(
|
||||||
"WEB static file `{}` changed while its snapshot was built",
|
"WEB static file `{}` changed while its snapshot was built",
|
||||||
display_path.display()
|
path.display()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
let etag = format!("\"{}\"", hex::encode(Sha256::digest(&body)));
|
let etag = format!("\"{}\"", hex::encode(Sha256::digest(&body)));
|
||||||
@@ -443,31 +357,14 @@ fn load_static_file(
|
|||||||
route,
|
route,
|
||||||
WebStaticAsset {
|
WebStaticAsset {
|
||||||
body: Bytes::from(body),
|
body: Bytes::from(body),
|
||||||
content_type: static_content_type(relative),
|
content_type: static_content_type(&path),
|
||||||
etag,
|
etag,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn static_file_version_matches(before: &fs::Metadata, after: &fs::Metadata) -> bool {
|
|
||||||
before.dev() == after.dev()
|
|
||||||
&& before.ino() == after.ino()
|
|
||||||
&& before.len() == after.len()
|
|
||||||
&& before.mtime() == after.mtime()
|
|
||||||
&& before.mtime_nsec() == after.mtime_nsec()
|
|
||||||
&& before.ctime() == after.ctime()
|
|
||||||
&& before.ctime_nsec() == after.ctime_nsec()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
fn static_file_version_matches(before: &fs::Metadata, after: &fs::Metadata) -> bool {
|
|
||||||
before.len() == after.len()
|
|
||||||
&& before.modified().ok() == after.modified().ok()
|
|
||||||
&& before.created().ok() == after.created().ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn static_route(relative: &Path) -> Result<String> {
|
fn static_route(relative: &Path) -> Result<String> {
|
||||||
let mut route = String::new();
|
let mut route = String::new();
|
||||||
for component in relative.components() {
|
for component in relative.components() {
|
||||||
@@ -505,7 +402,26 @@ fn static_content_type(path: &Path) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runtime WEB construction tests remain separate from the production loader.
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "runtime_web/tests.rs"]
|
mod tests {
|
||||||
mod tests;
|
use base64::Engine as _;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capability_matches_reference_vectors() {
|
||||||
|
let secret = hex::decode("000102030405060708090a0b0c0d0e0f").unwrap();
|
||||||
|
let plain = derive_web_capability(&secret, b"proxy.example.com").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(plain),
|
||||||
|
"MHLEY5PmW1GWqJkSrlmJpvJUiLhBH_QKy6yKg8a0JPk"
|
||||||
|
);
|
||||||
|
let mut dd_secret = vec![0xdd];
|
||||||
|
dd_secret.extend_from_slice(&secret);
|
||||||
|
let dd = derive_web_capability(&dd_secret, b"proxy.example.com").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(dd),
|
||||||
|
"IpJrt3e7sKtzPyoXy6w-Zj6GGEvsvclN66JzQEfPYLA"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
use std::collections::BTreeMap;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// Builds a bounded static-site snapshot on platforms without directory descriptors.
|
|
||||||
pub(super) fn load_static_site_by_path(
|
|
||||||
root: &Path,
|
|
||||||
limits: &WebLimitsConfig,
|
|
||||||
assets: &mut BTreeMap<String, WebStaticAsset>,
|
|
||||||
total_files: &mut usize,
|
|
||||||
total_bytes: &mut usize,
|
|
||||||
) -> Result<()> {
|
|
||||||
let root_metadata = fs::symlink_metadata(root).map_err(|error| {
|
|
||||||
ProxyError::Config(format!(
|
|
||||||
"failed to inspect WEB static directory `{}`: {error}",
|
|
||||||
root.display()
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() {
|
|
||||||
return Err(ProxyError::Config(format!(
|
|
||||||
"WEB static directory `{}` must be a real directory, not a symlink",
|
|
||||||
root.display()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let canonical_root = fs::canonicalize(root).map_err(|error| {
|
|
||||||
ProxyError::Config(format!(
|
|
||||||
"failed to canonicalize WEB static directory `{}`: {error}",
|
|
||||||
root.display()
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
load_static_directory(
|
|
||||||
&canonical_root,
|
|
||||||
&canonical_root,
|
|
||||||
assets,
|
|
||||||
total_files,
|
|
||||||
total_bytes,
|
|
||||||
limits,
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_static_directory(
|
|
||||||
root: &Path,
|
|
||||||
directory: &Path,
|
|
||||||
assets: &mut BTreeMap<String, WebStaticAsset>,
|
|
||||||
total_files: &mut usize,
|
|
||||||
total_bytes: &mut usize,
|
|
||||||
limits: &WebLimitsConfig,
|
|
||||||
depth: usize,
|
|
||||||
) -> Result<()> {
|
|
||||||
let entries = fs::read_dir(directory).map_err(|error| {
|
|
||||||
ProxyError::Config(format!(
|
|
||||||
"failed to read WEB static directory `{}`: {error}",
|
|
||||||
directory.display()
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
for entry in entries {
|
|
||||||
let entry = entry.map_err(|error| {
|
|
||||||
ProxyError::Config(format!("failed to read WEB static entry: {error}"))
|
|
||||||
})?;
|
|
||||||
if *total_files >= limits.max_static_files {
|
|
||||||
return Err(ProxyError::Config(
|
|
||||||
"WEB static entries exceed process-wide web.limits.max_static_files".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
*total_files += 1;
|
|
||||||
let path = entry.path();
|
|
||||||
let file_type = entry.file_type().map_err(|error| {
|
|
||||||
ProxyError::Config(format!(
|
|
||||||
"failed to inspect WEB static entry `{}`: {error}",
|
|
||||||
path.display()
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
if file_type.is_symlink() {
|
|
||||||
return Err(ProxyError::Config(format!(
|
|
||||||
"WEB static entry `{}` must not be a symlink",
|
|
||||||
path.display()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
if file_type.is_dir() {
|
|
||||||
if depth >= MAX_WEB_STATIC_DEPTH {
|
|
||||||
return Err(ProxyError::Config(format!(
|
|
||||||
"WEB static directory `{}` exceeds the maximum nesting depth",
|
|
||||||
path.display()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
load_static_directory(
|
|
||||||
root,
|
|
||||||
&path,
|
|
||||||
assets,
|
|
||||||
total_files,
|
|
||||||
total_bytes,
|
|
||||||
limits,
|
|
||||||
depth + 1,
|
|
||||||
)?;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if !file_type.is_file() {
|
|
||||||
return Err(ProxyError::Config(format!(
|
|
||||||
"WEB static entry `{}` must be a regular file",
|
|
||||||
path.display()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let file = fs::File::open(&path).map_err(|error| {
|
|
||||||
ProxyError::Config(format!(
|
|
||||||
"failed to open WEB static file `{}`: {error}",
|
|
||||||
path.display()
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
let metadata = file.metadata().map_err(|error| {
|
|
||||||
ProxyError::Config(format!(
|
|
||||||
"failed to inspect WEB static file `{}`: {error}",
|
|
||||||
path.display()
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
if !metadata.is_file() {
|
|
||||||
return Err(ProxyError::Config(format!(
|
|
||||||
"WEB static entry `{}` changed before it was opened",
|
|
||||||
path.display()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let relative = path.strip_prefix(root).map_err(|_| {
|
|
||||||
ProxyError::Config("WEB static path escaped its configured root".to_string())
|
|
||||||
})?;
|
|
||||||
load_static_file(
|
|
||||||
file,
|
|
||||||
&metadata,
|
|
||||||
relative,
|
|
||||||
&path,
|
|
||||||
assets,
|
|
||||||
total_bytes,
|
|
||||||
limits,
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn capability_binds_the_exact_host_and_base_path_identity() {
|
|
||||||
let secret = hex::decode("000102030405060708090a0b0c0d0e0f").unwrap();
|
|
||||||
let root = derive_web_capability(&secret, b"proxy.example.com", b"").unwrap();
|
|
||||||
let mixed = derive_web_capability(&secret, b"proxy.example.com", b"MixedCase/path").unwrap();
|
|
||||||
let lower = derive_web_capability(&secret, b"proxy.example.com", b"mixedcase/path").unwrap();
|
|
||||||
let other_path =
|
|
||||||
derive_web_capability(&secret, b"proxy.example.com", b"MixedCase/other").unwrap();
|
|
||||||
let other_host =
|
|
||||||
derive_web_capability(&secret, b"other.example.com", b"MixedCase/path").unwrap();
|
|
||||||
|
|
||||||
let identities = [root, mixed, lower, other_path, other_host]
|
|
||||||
.into_iter()
|
|
||||||
.collect::<std::collections::HashSet<_>>();
|
|
||||||
assert_eq!(identities.len(), 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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");
|
|
||||||
}
|
|
||||||
@@ -326,7 +326,6 @@ const WEB_LIMITS_CONFIG_KEYS: &[&str] = &[
|
|||||||
|
|
||||||
const WEB_DEBUG_CONFIG_KEYS: &[&str] = &[
|
const WEB_DEBUG_CONFIG_KEYS: &[&str] = &[
|
||||||
"enabled",
|
"enabled",
|
||||||
"sideband",
|
|
||||||
"capture_lifecycle",
|
"capture_lifecycle",
|
||||||
"capture_headers",
|
"capture_headers",
|
||||||
"capture_timings",
|
"capture_timings",
|
||||||
@@ -365,7 +364,7 @@ const WEB_TIMEOUTS_CONFIG_KEYS: &[&str] = &[
|
|||||||
"decoy_header_secs",
|
"decoy_header_secs",
|
||||||
];
|
];
|
||||||
|
|
||||||
const WEB_VHOST_CONFIG_KEYS: &[&str] = &["host", "base_path", "public_addr", "decoy", "profiles"];
|
const WEB_VHOST_CONFIG_KEYS: &[&str] = &["host", "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",
|
||||||
|
|||||||
@@ -196,13 +196,6 @@ pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> {
|
|||||||
"access.user_rate_limits.{user} must set at least one non-zero direction"
|
"access.user_rate_limits.{user} must set at least one non-zero direction"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
for (direction, value) in [("up_bps", limit.up_bps), ("down_bps", limit.down_bps)] {
|
|
||||||
if value > MAX_RATE_LIMIT_BPS {
|
|
||||||
return Err(ProxyError::Config(format!(
|
|
||||||
"access.user_rate_limits.{user}.{direction} must be within [0, {MAX_RATE_LIMIT_BPS}]"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (cidr, limit) in &config.access.cidr_rate_limits {
|
for (cidr, limit) in &config.access.cidr_rate_limits {
|
||||||
@@ -211,13 +204,6 @@ pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> {
|
|||||||
"access.cidr_rate_limits.{cidr} must set at least one non-zero direction"
|
"access.cidr_rate_limits.{cidr} must set at least one non-zero direction"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
for (direction, value) in [("up_bps", limit.up_bps), ("down_bps", limit.down_bps)] {
|
|
||||||
if value > MAX_RATE_LIMIT_BPS {
|
|
||||||
return Err(ProxyError::Config(format!(
|
|
||||||
"access.cidr_rate_limits.{cidr}.{direction} must be within [0, {MAX_RATE_LIMIT_BPS}]"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let mut cidr_auto_templates = HashSet::new();
|
let mut cidr_auto_templates = HashSet::new();
|
||||||
for cidr in config.access.cidr_rate_limits.keys() {
|
for cidr in config.access.cidr_rate_limits.keys() {
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ 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;
|
||||||
// Each profile capability is stored in its vhost and in the global containment table.
|
const WEB_CAPABILITY_INDEX_ENTRY_BYTES: usize = 32;
|
||||||
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,10 +9,6 @@ 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));
|
||||||
}
|
}
|
||||||
@@ -79,25 +75,6 @@ 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()
|
||||||
|
|||||||
@@ -46,8 +46,6 @@ mod legacy_policy_tests;
|
|||||||
mod me_route_tests;
|
mod me_route_tests;
|
||||||
#[path = "load_basic_tests/me_startup_tests.rs"]
|
#[path = "load_basic_tests/me_startup_tests.rs"]
|
||||||
mod me_startup_tests;
|
mod me_startup_tests;
|
||||||
#[path = "load_basic_tests/source_security_tests.rs"]
|
|
||||||
mod source_security_tests;
|
|
||||||
#[path = "load_basic_tests/synlimit_mss_tests.rs"]
|
#[path = "load_basic_tests/synlimit_mss_tests.rs"]
|
||||||
mod synlimit_mss_tests;
|
mod synlimit_mss_tests;
|
||||||
#[path = "load_basic_tests/tls_fetch_tests.rs"]
|
#[path = "load_basic_tests/tls_fetch_tests.rs"]
|
||||||
|
|||||||
@@ -288,68 +288,6 @@ fn cidr_rate_limits_reject_duplicate_normalized_auto_templates() {
|
|||||||
assert!(error.contains("duplicates normalized auto-template *6/128"));
|
assert!(error.contains("duplicates normalized auto-template *6/128"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rate_limits_accept_the_packed_counter_maximum() {
|
|
||||||
let cfg = load_config_from_temp_toml(
|
|
||||||
r#"
|
|
||||||
[censorship]
|
|
||||||
tls_domain = "example.com"
|
|
||||||
|
|
||||||
[access.users]
|
|
||||||
user = "00000000000000000000000000000000"
|
|
||||||
|
|
||||||
[access.user_rate_limits]
|
|
||||||
user = { up_bps = 100000000000, down_bps = 0 }
|
|
||||||
|
|
||||||
[access.cidr_rate_limits]
|
|
||||||
"203.0.113.0/24" = { up_bps = 0, down_bps = 100000000000 }
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(cfg.access.user_rate_limits["user"].up_bps, 100_000_000_000);
|
|
||||||
assert_eq!(
|
|
||||||
cfg.access.cidr_rate_limits[&CidrRateLimitKey::Network("203.0.113.0/24".parse().unwrap())]
|
|
||||||
.down_bps,
|
|
||||||
100_000_000_000
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn user_rate_limits_reject_values_above_the_packed_counter_maximum() {
|
|
||||||
let error = load_config_error_from_temp_toml(
|
|
||||||
r#"
|
|
||||||
[censorship]
|
|
||||||
tls_domain = "example.com"
|
|
||||||
|
|
||||||
[access.users]
|
|
||||||
user = "00000000000000000000000000000000"
|
|
||||||
|
|
||||||
[access.user_rate_limits]
|
|
||||||
user = { up_bps = 100000000001, down_bps = 0 }
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(error.contains("access.user_rate_limits.user.up_bps must be within"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cidr_rate_limits_reject_values_above_the_packed_counter_maximum() {
|
|
||||||
let error = load_config_error_from_temp_toml(
|
|
||||||
r#"
|
|
||||||
[censorship]
|
|
||||||
tls_domain = "example.com"
|
|
||||||
|
|
||||||
[access.users]
|
|
||||||
user = "00000000000000000000000000000000"
|
|
||||||
|
|
||||||
[access.cidr_rate_limits]
|
|
||||||
"203.0.113.0/24" = { up_bps = 0, down_bps = 100000000001 }
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(error.contains("access.cidr_rate_limits.203.0.113.0/24.down_bps must be within"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn file_logging_requires_path() {
|
fn file_logging_requires_path() {
|
||||||
let error = load_config_error_from_temp_toml(
|
let error = load_config_error_from_temp_toml(
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
#[cfg(unix)]
|
|
||||||
use std::os::unix::fs::symlink;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[test]
|
|
||||||
fn config_loader_rejects_final_and_intermediate_symlinks() {
|
|
||||||
let directory = tempfile::tempdir().unwrap();
|
|
||||||
let real_directory = directory.path().join("real");
|
|
||||||
let linked_directory = directory.path().join("linked");
|
|
||||||
std::fs::create_dir(&real_directory).unwrap();
|
|
||||||
let real_config = real_directory.join("config.toml");
|
|
||||||
let final_link = directory.path().join("config.toml");
|
|
||||||
std::fs::write(&real_config, "[general]\n").unwrap();
|
|
||||||
symlink(&real_config, &final_link).unwrap();
|
|
||||||
symlink(&real_directory, &linked_directory).unwrap();
|
|
||||||
|
|
||||||
assert!(ProxyConfig::load(&final_link).is_err());
|
|
||||||
assert!(ProxyConfig::load(linked_directory.join("config.toml")).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn config_loader_rejects_oversized_source() {
|
|
||||||
let directory = tempfile::tempdir().unwrap();
|
|
||||||
let path = directory.path().join("config.toml");
|
|
||||||
std::fs::write(&path, vec![b' '; 8 * 1024 * 1024 + 1]).unwrap();
|
|
||||||
|
|
||||||
let error = ProxyConfig::load(&path).unwrap_err().to_string();
|
|
||||||
|
|
||||||
assert!(error.contains("size limit") || error.contains("exceeds"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[test]
|
|
||||||
fn config_loader_rejects_fifo_without_blocking() {
|
|
||||||
let directory = tempfile::tempdir().unwrap();
|
|
||||||
let path = directory.path().join("config.toml");
|
|
||||||
nix::unistd::mkfifo(&path, nix::sys::stat::Mode::S_IRUSR).unwrap();
|
|
||||||
|
|
||||||
assert!(ProxyConfig::load(&path).is_err());
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[path = "web_tests/base_path_tests.rs"]
|
|
||||||
mod base_path_tests;
|
|
||||||
|
|
||||||
const WEB_CONFIG: &str = r#"
|
const WEB_CONFIG: &str = r#"
|
||||||
[access.users]
|
[access.users]
|
||||||
alice = "000102030405060708090a0b0c0d0e0f"
|
alice = "000102030405060708090a0b0c0d0e0f"
|
||||||
@@ -43,11 +40,9 @@ 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);
|
||||||
@@ -64,66 +59,6 @@ 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 ["a", "a/b/c9_x-y", "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",
|
|
||||||
"a/-lead",
|
|
||||||
"a/_lead",
|
|
||||||
"dot.ted",
|
|
||||||
"..",
|
|
||||||
"/",
|
|
||||||
"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();
|
||||||
@@ -366,31 +301,17 @@ fn web_carrier_learning_capacity_must_remain_nonzero() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn web_debug_table_uses_debug_name_and_bounded_defaults() {
|
fn web_debug_table_uses_debug_name_and_bounded_defaults() {
|
||||||
assert!(!crate::config::WebDebugConfig::default().sideband);
|
|
||||||
let mut ineffective = crate::config::WebDebugConfig {
|
|
||||||
enabled: true,
|
|
||||||
sideband: true,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
ineffective.capture_lifecycle = false;
|
|
||||||
assert!(!ineffective.bridge_diagnostics_enabled());
|
|
||||||
|
|
||||||
let configured = WEB_CONFIG.replace(
|
let configured = WEB_CONFIG.replace(
|
||||||
"[[web.vhosts]]",
|
"[[web.vhosts]]",
|
||||||
"[web.debug]\nenabled = true\nsideband = true\nbody_capture = \"prefix\"\nbody_prefix_bytes = 2048\ndefault_window_secs = 180\nmax_window_secs = 900\n\n[[web.vhosts]]",
|
"[web.debug]\nenabled = true\nbody_capture = \"prefix\"\nbody_prefix_bytes = 2048\ndefault_window_secs = 180\nmax_window_secs = 900\n\n[[web.vhosts]]",
|
||||||
);
|
);
|
||||||
let config = load_config_from_temp_toml(&configured);
|
let config = load_config_from_temp_toml(&configured);
|
||||||
assert!(config.web.debug.enabled);
|
assert!(config.web.debug.enabled);
|
||||||
assert!(config.web.debug.sideband);
|
|
||||||
assert!(config.web.debug.bridge_diagnostics_enabled());
|
|
||||||
assert_eq!(config.web.debug.body_capture, WebDebugBodyCapture::Prefix);
|
assert_eq!(config.web.debug.body_capture, WebDebugBodyCapture::Prefix);
|
||||||
assert_eq!(config.web.debug.body_prefix_bytes, 2048);
|
assert_eq!(config.web.debug.body_prefix_bytes, 2048);
|
||||||
assert_eq!(config.web.debug.default_window_secs, 180);
|
assert_eq!(config.web.debug.default_window_secs, 180);
|
||||||
assert_eq!(config.web.debug.max_window_secs, 900);
|
assert_eq!(config.web.debug.max_window_secs, 900);
|
||||||
|
|
||||||
let strict = format!("[general]\nconfig_strict = true\n{configured}");
|
|
||||||
assert!(load_config_from_temp_toml(&strict).web.debug.sideband);
|
|
||||||
|
|
||||||
let old_name = format!(
|
let old_name = format!(
|
||||||
"[general]\nconfig_strict = true\n{}",
|
"[general]\nconfig_strict = true\n{}",
|
||||||
WEB_CONFIG.replace(
|
WEB_CONFIG.replace(
|
||||||
@@ -400,16 +321,6 @@ fn web_debug_table_uses_debug_name_and_bounded_defaults() {
|
|||||||
);
|
);
|
||||||
let error = load_config_error_from_temp_toml(&old_name);
|
let error = load_config_error_from_temp_toml(&old_name);
|
||||||
assert!(error.contains("web.trace"));
|
assert!(error.contains("web.trace"));
|
||||||
|
|
||||||
let old_parameter = format!(
|
|
||||||
"[general]\nconfig_strict = true\n{}",
|
|
||||||
WEB_CONFIG.replace(
|
|
||||||
"[[web.vhosts]]",
|
|
||||||
"[web.debug]\nbridge_diagnostics = true\n\n[[web.vhosts]]",
|
|
||||||
)
|
|
||||||
);
|
|
||||||
let error = load_config_error_from_temp_toml(&old_parameter);
|
|
||||||
assert!(error.contains("web.debug.bridge_diagnostics"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn web_runtime_collects_every_vhost_capability() {
|
|
||||||
let configured = format!(
|
|
||||||
"{WEB_CONFIG}\n{}",
|
|
||||||
r#"
|
|
||||||
[[web.vhosts]]
|
|
||||||
host = "Other.Example.COM"
|
|
||||||
base_path = "other/path"
|
|
||||||
public_addr = "203.0.113.11:443"
|
|
||||||
|
|
||||||
[web.vhosts.decoy]
|
|
||||||
mode = "http_upstream"
|
|
||||||
upstream = "http://127.0.0.1:18082"
|
|
||||||
|
|
||||||
[[web.vhosts.profiles]]
|
|
||||||
user = "alice"
|
|
||||||
secret_mode = "dd"
|
|
||||||
"#
|
|
||||||
);
|
|
||||||
let config = load_config_from_temp_toml(&configured);
|
|
||||||
let runtime = config.web.runtime.as_ref().unwrap();
|
|
||||||
let first = &runtime.vhosts["proxy.example.com"];
|
|
||||||
let second = &runtime.vhosts["other.example.com"];
|
|
||||||
|
|
||||||
assert_eq!(runtime.capabilities.len(), 2);
|
|
||||||
assert_eq!(first.capabilities[0], first.profiles[0].capability);
|
|
||||||
assert_eq!(second.capabilities[0], second.profiles[0].capability);
|
|
||||||
assert_ne!(first.capabilities[0], second.capabilities[0]);
|
|
||||||
assert!(runtime.capabilities.contains(&first.capabilities[0]));
|
|
||||||
assert!(runtime.capabilities.contains(&second.capabilities[0]));
|
|
||||||
}
|
|
||||||
+1
-1
@@ -31,7 +31,7 @@ mod web_debug;
|
|||||||
|
|
||||||
pub use access::{AccessConfig, CidrRateLimitKey, RateLimitBps};
|
pub use access::{AccessConfig, CidrRateLimitKey, RateLimitBps};
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub(crate) use access::{CidrAutoTemplate, CidrAutoTemplateFamily, MAX_RATE_LIMIT_BPS};
|
pub(crate) use access::{CidrAutoTemplate, CidrAutoTemplateFamily};
|
||||||
pub use api::{ApiConfig, ApiGrayAction};
|
pub use api::{ApiConfig, ApiGrayAction};
|
||||||
pub use censorship::{
|
pub use censorship::{
|
||||||
AntiCensorshipConfig, ExclusiveMaskTarget, TlsFetchConfig, TlsFetchProfile, UnknownSniAction,
|
AntiCensorshipConfig, ExclusiveMaskTarget, TlsFetchConfig, TlsFetchProfile, UnknownSniAction,
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// Highest rate that fits one packed 20 ms shaping epoch.
|
|
||||||
pub(crate) const MAX_RATE_LIMIT_BPS: u64 = 100_000_000_000;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct AccessConfig {
|
pub struct AccessConfig {
|
||||||
#[serde(default = "default_access_users")]
|
#[serde(default = "default_access_users")]
|
||||||
@@ -263,10 +260,10 @@ fn parse_cidr_auto_prefix(
|
|||||||
/// Transport rate limit in bits-per-second.
|
/// Transport rate limit in bits-per-second.
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct RateLimitBps {
|
pub struct RateLimitBps {
|
||||||
/// Upload limit in bits-per-second within `0..=100_000_000_000`; `0` means unlimited.
|
/// Upload direction limit in bits-per-second; `0` means unlimited.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub up_bps: u64,
|
pub up_bps: u64,
|
||||||
/// Download limit in bits-per-second within `0..=100_000_000_000`; `0` means unlimited.
|
/// Download direction limit in bits-per-second; `0` means unlimited.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub down_bps: u64,
|
pub down_bps: u64,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,9 +71,6 @@ 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,8 +7,6 @@ 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.
|
||||||
@@ -16,8 +14,6 @@ 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.
|
||||||
@@ -39,8 +35,6 @@ pub(crate) struct WebRuntimeProfile {
|
|||||||
pub(crate) public_addr: SocketAddr,
|
pub(crate) public_addr: SocketAddr,
|
||||||
/// Exact access user authenticated by logical streams.
|
/// Exact access user authenticated by logical streams.
|
||||||
pub(crate) user: String,
|
pub(crate) user: String,
|
||||||
/// Stable credential identity used by process-wide admission fencing.
|
|
||||||
pub(crate) credential_id: [u8; 16],
|
|
||||||
/// Client secret representation and inner protocol policy.
|
/// Client secret representation and inner protocol policy.
|
||||||
pub(crate) secret_mode: WebSecretMode,
|
pub(crate) secret_mode: WebSecretMode,
|
||||||
/// Sole carrier or final fallback frozen into the issued bridge policy.
|
/// Sole carrier or final fallback frozen into the issued bridge policy.
|
||||||
|
|||||||
@@ -23,9 +23,6 @@ pub struct WebDebugConfig {
|
|||||||
/// Enables process-owned WEB debug collection.
|
/// Enables process-owned WEB debug collection.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
/// Enables generated-bridge diagnostic reports over the HTTPS sideband.
|
|
||||||
#[serde(default)]
|
|
||||||
pub sideband: bool,
|
|
||||||
/// Records typed bridge, session, stream, handshake, and relay events.
|
/// Records typed bridge, session, stream, handshake, and relay events.
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub capture_lifecycle: bool,
|
pub capture_lifecycle: bool,
|
||||||
@@ -59,7 +56,6 @@ impl Default for WebDebugConfig {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
sideband: false,
|
|
||||||
capture_lifecycle: true,
|
capture_lifecycle: true,
|
||||||
capture_headers: true,
|
capture_headers: true,
|
||||||
capture_timings: true,
|
capture_timings: true,
|
||||||
@@ -73,13 +69,6 @@ impl Default for WebDebugConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WebDebugConfig {
|
|
||||||
/// Returns whether newly issued bridges may report diagnostic lifecycle events.
|
|
||||||
pub(crate) const fn bridge_diagnostics_enabled(&self) -> bool {
|
|
||||||
self.enabled && self.sideband && self.capture_lifecycle
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|||||||
+409
-10
@@ -1,23 +1,20 @@
|
|||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
use tokio::process::Command;
|
||||||
use tokio::sync::{mpsc, watch};
|
use tokio::sync::{mpsc, watch};
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
use tracing::{info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use crate::config::ProxyConfig;
|
use crate::config::{ConntrackBackend, ConntrackMode, ProxyConfig};
|
||||||
use crate::proxy::middle_relay::note_global_relay_pressure;
|
use crate::proxy::middle_relay::note_global_relay_pressure;
|
||||||
use crate::proxy::shared_state::{ConntrackCloseEvent, ConntrackCloseReason, ProxySharedState};
|
use crate::proxy::shared_state::{ConntrackCloseEvent, ConntrackCloseReason, ProxySharedState};
|
||||||
use crate::stats::Stats;
|
use crate::stats::Stats;
|
||||||
|
|
||||||
// Privileged netfilter rule and conntrack helper execution.
|
|
||||||
mod firewall;
|
|
||||||
|
|
||||||
pub(crate) use firewall::FirewallAuthority;
|
|
||||||
use firewall::{
|
|
||||||
DeleteOutcome, delete_conntrack_entry, effective_conntrack_enabled, probe_runtime_support,
|
|
||||||
};
|
|
||||||
|
|
||||||
const CONNTRACK_EVENT_QUEUE_CAPACITY: usize = 32_768;
|
const CONNTRACK_EVENT_QUEUE_CAPACITY: usize = 32_768;
|
||||||
const PRESSURE_RELEASE_TICKS: u8 = 3;
|
const PRESSURE_RELEASE_TICKS: u8 = 3;
|
||||||
const PRESSURE_SAMPLE_INTERVAL: Duration = Duration::from_secs(1);
|
const PRESSURE_SAMPLE_INTERVAL: Duration = Duration::from_secs(1);
|
||||||
@@ -115,6 +112,7 @@ async fn run_conntrack_controller_worker(
|
|||||||
runtime_support,
|
runtime_support,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
reconcile_rules(&cfg, runtime_support, stats.as_ref()).await;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -128,6 +126,7 @@ async fn run_conntrack_controller_worker(
|
|||||||
effective_enabled = effective_conntrack_enabled(&cfg, runtime_support);
|
effective_enabled = effective_conntrack_enabled(&cfg, runtime_support);
|
||||||
delete_budget_tokens = cfg.server.conntrack_control.delete_budget_per_sec;
|
delete_budget_tokens = cfg.server.conntrack_control.delete_budget_per_sec;
|
||||||
apply_runtime_state(stats.as_ref(), shared.as_ref(), &cfg, runtime_support, pressure_state.active);
|
apply_runtime_state(stats.as_ref(), shared.as_ref(), &cfg, runtime_support, pressure_state.active);
|
||||||
|
reconcile_rules(&cfg, runtime_support, stats.as_ref()).await;
|
||||||
}
|
}
|
||||||
event = close_rx.recv() => {
|
event = close_rx.recv() => {
|
||||||
let Some(event) = event else {
|
let Some(event) = event else {
|
||||||
@@ -311,6 +310,383 @@ fn update_pressure_state(
|
|||||||
state.low_streak = 0;
|
state.low_streak = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn reconcile_rules(
|
||||||
|
cfg: &ProxyConfig,
|
||||||
|
runtime_support: ConntrackRuntimeSupport,
|
||||||
|
stats: &Stats,
|
||||||
|
) {
|
||||||
|
if !cfg.server.conntrack_control.inline_conntrack_control {
|
||||||
|
clear_notrack_rules_all_backends().await;
|
||||||
|
stats.set_conntrack_rule_apply_ok(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !effective_conntrack_enabled(cfg, runtime_support) {
|
||||||
|
clear_notrack_rules_all_backends().await;
|
||||||
|
stats.set_conntrack_rule_apply_ok(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let backend = runtime_support
|
||||||
|
.netfilter_backend
|
||||||
|
.expect("netfilter backend must be available for effective conntrack control");
|
||||||
|
|
||||||
|
let apply_result = match backend {
|
||||||
|
NetfilterBackend::Nftables => apply_nft_rules(cfg).await,
|
||||||
|
NetfilterBackend::Iptables => apply_iptables_rules(cfg).await,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(error) = apply_result {
|
||||||
|
warn!(error = %error, "Failed to reconcile conntrack/notrack rules");
|
||||||
|
stats.set_conntrack_rule_apply_ok(false);
|
||||||
|
} else {
|
||||||
|
stats.set_conntrack_rule_apply_ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn probe_runtime_support(configured_backend: ConntrackBackend) -> ConntrackRuntimeSupport {
|
||||||
|
ConntrackRuntimeSupport {
|
||||||
|
netfilter_backend: pick_backend(configured_backend),
|
||||||
|
has_cap_net_admin: has_cap_net_admin(),
|
||||||
|
has_conntrack_binary: command_exists("conntrack"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn effective_conntrack_enabled(
|
||||||
|
cfg: &ProxyConfig,
|
||||||
|
runtime_support: ConntrackRuntimeSupport,
|
||||||
|
) -> bool {
|
||||||
|
cfg.server.conntrack_control.inline_conntrack_control
|
||||||
|
&& runtime_support.has_cap_net_admin
|
||||||
|
&& runtime_support.netfilter_backend.is_some()
|
||||||
|
&& runtime_support.has_conntrack_binary
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pick_backend(configured: ConntrackBackend) -> Option<NetfilterBackend> {
|
||||||
|
match configured {
|
||||||
|
ConntrackBackend::Auto => {
|
||||||
|
if command_exists("nft") {
|
||||||
|
Some(NetfilterBackend::Nftables)
|
||||||
|
} else if command_exists("iptables") {
|
||||||
|
Some(NetfilterBackend::Iptables)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ConntrackBackend::Nftables => command_exists("nft").then_some(NetfilterBackend::Nftables),
|
||||||
|
ConntrackBackend::Iptables => {
|
||||||
|
command_exists("iptables").then_some(NetfilterBackend::Iptables)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_exists(binary: &str) -> bool {
|
||||||
|
let Some(path_var) = std::env::var_os("PATH") else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
std::env::split_paths(&path_var).any(|dir| {
|
||||||
|
let candidate: PathBuf = dir.join(binary);
|
||||||
|
candidate.exists() && candidate.is_file()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn listener_port_set(cfg: &ProxyConfig) -> Vec<u16> {
|
||||||
|
let mut ports: BTreeSet<u16> = BTreeSet::new();
|
||||||
|
if cfg.server.listeners.is_empty() {
|
||||||
|
ports.insert(cfg.server.port);
|
||||||
|
} else {
|
||||||
|
for listener in &cfg.server.listeners {
|
||||||
|
ports.insert(listener.port.unwrap_or(cfg.server.port));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ports.into_iter().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn notrack_targets(cfg: &ProxyConfig) -> (Vec<(Option<IpAddr>, u16)>, Vec<(Option<IpAddr>, u16)>) {
|
||||||
|
let mode = cfg.server.conntrack_control.mode;
|
||||||
|
let mut v4_targets: BTreeSet<(Option<IpAddr>, u16)> = BTreeSet::new();
|
||||||
|
let mut v6_targets: BTreeSet<(Option<IpAddr>, u16)> = BTreeSet::new();
|
||||||
|
|
||||||
|
match mode {
|
||||||
|
ConntrackMode::Tracked => {}
|
||||||
|
ConntrackMode::Notrack => {
|
||||||
|
if cfg.server.listeners.is_empty() {
|
||||||
|
let port = cfg.server.port;
|
||||||
|
if let Some(ipv4) = cfg
|
||||||
|
.server
|
||||||
|
.listen_addr_ipv4
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|s| s.parse::<IpAddr>().ok())
|
||||||
|
{
|
||||||
|
if ipv4.is_unspecified() {
|
||||||
|
v4_targets.insert((None, port));
|
||||||
|
} else {
|
||||||
|
v4_targets.insert((Some(ipv4), port));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ipv6) = cfg
|
||||||
|
.server
|
||||||
|
.listen_addr_ipv6
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|s| s.parse::<IpAddr>().ok())
|
||||||
|
{
|
||||||
|
if ipv6.is_unspecified() {
|
||||||
|
v6_targets.insert((None, port));
|
||||||
|
} else {
|
||||||
|
v6_targets.insert((Some(ipv6), port));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for listener in &cfg.server.listeners {
|
||||||
|
let port = listener.port.unwrap_or(cfg.server.port);
|
||||||
|
if listener.ip.is_ipv4() {
|
||||||
|
if listener.ip.is_unspecified() {
|
||||||
|
v4_targets.insert((None, port));
|
||||||
|
} else {
|
||||||
|
v4_targets.insert((Some(listener.ip), port));
|
||||||
|
}
|
||||||
|
} else if listener.ip.is_unspecified() {
|
||||||
|
v6_targets.insert((None, port));
|
||||||
|
} else {
|
||||||
|
v6_targets.insert((Some(listener.ip), port));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ConntrackMode::Hybrid => {
|
||||||
|
let ports = listener_port_set(cfg);
|
||||||
|
for ip in &cfg.server.conntrack_control.hybrid_listener_ips {
|
||||||
|
if ip.is_ipv4() {
|
||||||
|
for port in &ports {
|
||||||
|
v4_targets.insert((Some(*ip), *port));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for port in &ports {
|
||||||
|
v6_targets.insert((Some(*ip), *port));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(
|
||||||
|
v4_targets.into_iter().collect(),
|
||||||
|
v6_targets.into_iter().collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn apply_nft_rules(cfg: &ProxyConfig) -> Result<(), String> {
|
||||||
|
let _ = run_command(
|
||||||
|
"nft",
|
||||||
|
&["delete", "table", "inet", "telemt_conntrack"],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if matches!(cfg.server.conntrack_control.mode, ConntrackMode::Tracked) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let (v4_targets, v6_targets) = notrack_targets(cfg);
|
||||||
|
let mut rules = Vec::new();
|
||||||
|
for (ip, port) in v4_targets {
|
||||||
|
let rule = if let Some(ip) = ip {
|
||||||
|
format!("tcp dport {} ip daddr {} notrack", port, ip)
|
||||||
|
} else {
|
||||||
|
format!("tcp dport {} notrack", port)
|
||||||
|
};
|
||||||
|
rules.push(rule);
|
||||||
|
}
|
||||||
|
for (ip, port) in v6_targets {
|
||||||
|
let rule = if let Some(ip) = ip {
|
||||||
|
format!("tcp dport {} ip6 daddr {} notrack", port, ip)
|
||||||
|
} else {
|
||||||
|
format!("tcp dport {} notrack", port)
|
||||||
|
};
|
||||||
|
rules.push(rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rule_blob = if rules.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" {}\n", rules.join("\n "))
|
||||||
|
};
|
||||||
|
let script = format!(
|
||||||
|
"table inet telemt_conntrack {{\n chain preraw {{\n type filter hook prerouting priority raw; policy accept;\n{rule_blob} }}\n}}\n"
|
||||||
|
);
|
||||||
|
run_command("nft", &["-f", "-"], Some(script)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn apply_iptables_rules(cfg: &ProxyConfig) -> Result<(), String> {
|
||||||
|
apply_iptables_rules_for_binary("iptables", cfg, true).await?;
|
||||||
|
apply_iptables_rules_for_binary("ip6tables", cfg, false).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn apply_iptables_rules_for_binary(
|
||||||
|
binary: &str,
|
||||||
|
cfg: &ProxyConfig,
|
||||||
|
ipv4: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if !command_exists(binary) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let chain = "TELEMT_NOTRACK";
|
||||||
|
let _ = run_command(
|
||||||
|
binary,
|
||||||
|
&["-t", "raw", "-D", "PREROUTING", "-j", chain],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let _ = run_command(binary, &["-t", "raw", "-F", chain], None).await;
|
||||||
|
let _ = run_command(binary, &["-t", "raw", "-X", chain], None).await;
|
||||||
|
|
||||||
|
if matches!(cfg.server.conntrack_control.mode, ConntrackMode::Tracked) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
run_command(binary, &["-t", "raw", "-N", chain], None).await?;
|
||||||
|
run_command(binary, &["-t", "raw", "-F", chain], None).await?;
|
||||||
|
if run_command(
|
||||||
|
binary,
|
||||||
|
&["-t", "raw", "-C", "PREROUTING", "-j", chain],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
run_command(
|
||||||
|
binary,
|
||||||
|
&["-t", "raw", "-I", "PREROUTING", "1", "-j", chain],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (v4_targets, v6_targets) = notrack_targets(cfg);
|
||||||
|
let selected = if ipv4 { v4_targets } else { v6_targets };
|
||||||
|
for (ip, port) in selected {
|
||||||
|
let mut args = vec![
|
||||||
|
"-t".to_string(),
|
||||||
|
"raw".to_string(),
|
||||||
|
"-A".to_string(),
|
||||||
|
chain.to_string(),
|
||||||
|
"-p".to_string(),
|
||||||
|
"tcp".to_string(),
|
||||||
|
"--dport".to_string(),
|
||||||
|
port.to_string(),
|
||||||
|
];
|
||||||
|
if let Some(ip) = ip {
|
||||||
|
args.push("-d".to_string());
|
||||||
|
args.push(ip.to_string());
|
||||||
|
}
|
||||||
|
args.push("-j".to_string());
|
||||||
|
args.push("CT".to_string());
|
||||||
|
args.push("--notrack".to_string());
|
||||||
|
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||||
|
run_command(binary, &arg_refs, None).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn clear_notrack_rules_all_backends() {
|
||||||
|
let _ = run_command(
|
||||||
|
"nft",
|
||||||
|
&["delete", "table", "inet", "telemt_conntrack"],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let _ = run_command(
|
||||||
|
"iptables",
|
||||||
|
&["-t", "raw", "-D", "PREROUTING", "-j", "TELEMT_NOTRACK"],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let _ = run_command("iptables", &["-t", "raw", "-F", "TELEMT_NOTRACK"], None).await;
|
||||||
|
let _ = run_command("iptables", &["-t", "raw", "-X", "TELEMT_NOTRACK"], None).await;
|
||||||
|
let _ = run_command(
|
||||||
|
"ip6tables",
|
||||||
|
&["-t", "raw", "-D", "PREROUTING", "-j", "TELEMT_NOTRACK"],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let _ = run_command("ip6tables", &["-t", "raw", "-F", "TELEMT_NOTRACK"], None).await;
|
||||||
|
let _ = run_command("ip6tables", &["-t", "raw", "-X", "TELEMT_NOTRACK"], None).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DeleteOutcome {
|
||||||
|
Deleted,
|
||||||
|
NotFound,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_conntrack_entry(event: ConntrackCloseEvent) -> DeleteOutcome {
|
||||||
|
if !command_exists("conntrack") {
|
||||||
|
return DeleteOutcome::Error;
|
||||||
|
}
|
||||||
|
let args = vec![
|
||||||
|
"-D".to_string(),
|
||||||
|
"-p".to_string(),
|
||||||
|
"tcp".to_string(),
|
||||||
|
"-s".to_string(),
|
||||||
|
event.src.ip().to_string(),
|
||||||
|
"--sport".to_string(),
|
||||||
|
event.src.port().to_string(),
|
||||||
|
"-d".to_string(),
|
||||||
|
event.dst.ip().to_string(),
|
||||||
|
"--dport".to_string(),
|
||||||
|
event.dst.port().to_string(),
|
||||||
|
];
|
||||||
|
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||||
|
match run_command("conntrack", &arg_refs, None).await {
|
||||||
|
Ok(()) => DeleteOutcome::Deleted,
|
||||||
|
Err(error) => {
|
||||||
|
if error.contains("0 flow entries have been deleted") {
|
||||||
|
DeleteOutcome::NotFound
|
||||||
|
} else {
|
||||||
|
debug!(error = %error, "conntrack delete failed");
|
||||||
|
DeleteOutcome::Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_command(binary: &str, args: &[&str], stdin: Option<String>) -> Result<(), String> {
|
||||||
|
if !command_exists(binary) {
|
||||||
|
return Err(format!("{binary} is not available"));
|
||||||
|
}
|
||||||
|
let mut command = Command::new(binary);
|
||||||
|
command.args(args);
|
||||||
|
if stdin.is_some() {
|
||||||
|
command.stdin(std::process::Stdio::piped());
|
||||||
|
}
|
||||||
|
command.stdout(std::process::Stdio::null());
|
||||||
|
command.stderr(std::process::Stdio::piped());
|
||||||
|
let mut child = command
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("spawn {binary} failed: {e}"))?;
|
||||||
|
if let Some(blob) = stdin
|
||||||
|
&& let Some(mut writer) = child.stdin.take()
|
||||||
|
{
|
||||||
|
writer
|
||||||
|
.write_all(blob.as_bytes())
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("stdin write {binary} failed: {e}"))?;
|
||||||
|
}
|
||||||
|
let output = child
|
||||||
|
.wait_with_output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("wait {binary} failed: {e}"))?;
|
||||||
|
if output.status.success() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
|
Err(if stderr.is_empty() {
|
||||||
|
format!("{binary} exited with status {}", output.status)
|
||||||
|
} else {
|
||||||
|
stderr
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn fd_usage_pct() -> Option<u8> {
|
fn fd_usage_pct() -> Option<u8> {
|
||||||
let soft_limit = nofile_soft_limit()?;
|
let soft_limit = nofile_soft_limit()?;
|
||||||
if soft_limit == 0 {
|
if soft_limit == 0 {
|
||||||
@@ -339,6 +715,29 @@ fn nofile_soft_limit() -> Option<u64> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn has_cap_net_admin() -> bool {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
let Ok(status) = std::fs::read_to_string("/proc/self/status") else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
for line in status.lines() {
|
||||||
|
if let Some(raw) = line.strip_prefix("CapEff:") {
|
||||||
|
let caps = raw.trim();
|
||||||
|
if let Ok(bits) = u64::from_str_radix(caps, 16) {
|
||||||
|
const CAP_NET_ADMIN_BIT: u64 = 12;
|
||||||
|
return (bits & (1u64 << CAP_NET_ADMIN_BIT)) != 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
{
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
// Process-owned firewall reconciliation and privileged conntrack helpers.
|
|
||||||
|
|
||||||
mod actor;
|
|
||||||
mod command;
|
|
||||||
mod iptables;
|
|
||||||
mod model;
|
|
||||||
mod nftables;
|
|
||||||
mod runtime;
|
|
||||||
mod transaction;
|
|
||||||
|
|
||||||
pub(crate) use actor::FirewallAuthority;
|
|
||||||
pub(super) use runtime::{
|
|
||||||
DeleteOutcome, delete_conntrack_entry, effective_conntrack_enabled, probe_runtime_support,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[path = "firewall/tests.rs"]
|
|
||||||
mod tests;
|
|
||||||
@@ -1,371 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use tokio::sync::{Notify, watch};
|
|
||||||
use tokio_util::sync::CancellationToken;
|
|
||||||
use tracing::warn;
|
|
||||||
|
|
||||||
use crate::config::ProxyConfig;
|
|
||||||
use crate::maestro::control_plane::ProcessControlPlane;
|
|
||||||
use crate::stats::Stats;
|
|
||||||
|
|
||||||
use super::command::{CommandError, FirewallCommandRunner, SystemCommandRunner};
|
|
||||||
use super::model::{AppliedPlan, AppliedState, DesiredPolicy, DesiredState};
|
|
||||||
use super::transaction::{InterruptibleRunner, reconcile_once, recover_to_empty};
|
|
||||||
|
|
||||||
const SHUTDOWN_CLEANUP_TIMEOUT: Duration = Duration::from_secs(30);
|
|
||||||
const SHUTDOWN_WAIT_TIMEOUT: Duration = Duration::from_secs(35);
|
|
||||||
const INITIAL_RECONCILE_TIMEOUT: Duration = Duration::from_secs(65);
|
|
||||||
const RETRY_DELAYS: [Duration; 6] = [
|
|
||||||
Duration::from_secs(1),
|
|
||||||
Duration::from_secs(2),
|
|
||||||
Duration::from_secs(4),
|
|
||||||
Duration::from_secs(8),
|
|
||||||
Duration::from_secs(16),
|
|
||||||
Duration::from_secs(30),
|
|
||||||
];
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub(super) enum ReconcileOutcome {
|
|
||||||
Applied,
|
|
||||||
Failed,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub(super) struct ReconcileStatus {
|
|
||||||
pub(super) generation: u64,
|
|
||||||
pub(super) outcome: ReconcileOutcome,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Process-owned publisher and shutdown owner for conntrack firewall policy.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub(crate) struct FirewallAuthority {
|
|
||||||
desired_tx: watch::Sender<Option<DesiredState>>,
|
|
||||||
status_rx: watch::Receiver<Option<ReconcileStatus>>,
|
|
||||||
terminal: CancellationToken,
|
|
||||||
closed: Arc<AtomicBool>,
|
|
||||||
completed_flag: Arc<AtomicBool>,
|
|
||||||
cleanup_succeeded: Arc<AtomicBool>,
|
|
||||||
completed: Arc<Notify>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FirewallAuthority {
|
|
||||||
/// Starts the single process-owned firewall reconciler.
|
|
||||||
pub(crate) fn spawn(control_plane: &ProcessControlPlane) -> Result<Self, String> {
|
|
||||||
let (desired_tx, desired_rx) = watch::channel(None);
|
|
||||||
let (status_tx, status_rx) = watch::channel(None);
|
|
||||||
let terminal = CancellationToken::new();
|
|
||||||
let closed = Arc::new(AtomicBool::new(false));
|
|
||||||
let completed_flag = Arc::new(AtomicBool::new(false));
|
|
||||||
let cleanup_succeeded = Arc::new(AtomicBool::new(false));
|
|
||||||
let completed = Arc::new(Notify::new());
|
|
||||||
let actor = FirewallReconciler::new(
|
|
||||||
SystemCommandRunner,
|
|
||||||
desired_rx,
|
|
||||||
status_tx,
|
|
||||||
terminal.clone(),
|
|
||||||
Arc::clone(&closed),
|
|
||||||
Arc::clone(&completed_flag),
|
|
||||||
Arc::clone(&cleanup_succeeded),
|
|
||||||
Arc::clone(&completed),
|
|
||||||
);
|
|
||||||
control_plane
|
|
||||||
.spawn_cooperative(move |process_cancellation| async move {
|
|
||||||
actor.run(process_cancellation).await;
|
|
||||||
})
|
|
||||||
.map_err(|_| {
|
|
||||||
"process control-plane admission closed before conntrack firewall startup"
|
|
||||||
.to_string()
|
|
||||||
})?;
|
|
||||||
Ok(Self {
|
|
||||||
desired_tx,
|
|
||||||
status_rx,
|
|
||||||
terminal,
|
|
||||||
closed,
|
|
||||||
completed_flag,
|
|
||||||
cleanup_succeeded,
|
|
||||||
completed,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Publishes policy only after its runtime generation becomes active.
|
|
||||||
pub(crate) fn publish(
|
|
||||||
&self,
|
|
||||||
generation: u64,
|
|
||||||
config: Arc<ProxyConfig>,
|
|
||||||
stats: Arc<Stats>,
|
|
||||||
) -> bool {
|
|
||||||
if self.closed.load(Ordering::Acquire) {
|
|
||||||
stats.set_conntrack_rule_apply_ok(false);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
stats.set_conntrack_rule_apply_ok(false);
|
|
||||||
self.desired_tx.send_replace(Some(DesiredState {
|
|
||||||
generation,
|
|
||||||
policy: DesiredPolicy::from_config(config.as_ref()),
|
|
||||||
stats,
|
|
||||||
}));
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Publishes startup policy and waits for its first bounded attempt.
|
|
||||||
pub(crate) async fn publish_initial(
|
|
||||||
&self,
|
|
||||||
generation: u64,
|
|
||||||
config: Arc<ProxyConfig>,
|
|
||||||
stats: Arc<Stats>,
|
|
||||||
) -> bool {
|
|
||||||
let mut status_rx = self.status_rx.clone();
|
|
||||||
if !self.publish(generation, config, stats) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
tokio::time::timeout(INITIAL_RECONCILE_TIMEOUT, async move {
|
|
||||||
loop {
|
|
||||||
if let Some(status) = *status_rx.borrow_and_update()
|
|
||||||
&& status.generation == generation
|
|
||||||
{
|
|
||||||
return status.outcome == ReconcileOutcome::Applied;
|
|
||||||
}
|
|
||||||
if status_rx.changed().await.is_err() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stops policy admission and waits for bounded terminal cleanup.
|
|
||||||
pub(crate) async fn shutdown_and_clear(&self) -> bool {
|
|
||||||
let completed = self.completed.notified();
|
|
||||||
tokio::pin!(completed);
|
|
||||||
completed.as_mut().enable();
|
|
||||||
if !self.closed.swap(true, Ordering::AcqRel) {
|
|
||||||
if let Some(desired) = self.desired_tx.borrow().as_ref() {
|
|
||||||
desired.stats.set_conntrack_rule_apply_ok(false);
|
|
||||||
}
|
|
||||||
self.terminal.cancel();
|
|
||||||
}
|
|
||||||
let finished = self.completed_flag.load(Ordering::Acquire)
|
|
||||||
|| tokio::time::timeout(SHUTDOWN_WAIT_TIMEOUT, completed)
|
|
||||||
.await
|
|
||||||
.is_ok();
|
|
||||||
finished && self.cleanup_succeeded.load(Ordering::Acquire)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct CompletionGuard {
|
|
||||||
closed: Arc<AtomicBool>,
|
|
||||||
completed_flag: Arc<AtomicBool>,
|
|
||||||
completed: Arc<Notify>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for CompletionGuard {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.closed.store(true, Ordering::Release);
|
|
||||||
self.completed_flag.store(true, Ordering::Release);
|
|
||||||
self.completed.notify_waiters();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) struct FirewallReconciler<R> {
|
|
||||||
runner: R,
|
|
||||||
desired_rx: watch::Receiver<Option<DesiredState>>,
|
|
||||||
status_tx: watch::Sender<Option<ReconcileStatus>>,
|
|
||||||
terminal: CancellationToken,
|
|
||||||
completion: CompletionGuard,
|
|
||||||
cleanup_succeeded: Arc<AtomicBool>,
|
|
||||||
applied: AppliedState,
|
|
||||||
last_generation: u64,
|
|
||||||
last_policy: Option<DesiredPolicy>,
|
|
||||||
last_stats: Option<Arc<Stats>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<R> FirewallReconciler<R>
|
|
||||||
where
|
|
||||||
R: FirewallCommandRunner + 'static,
|
|
||||||
{
|
|
||||||
pub(super) fn new(
|
|
||||||
runner: R,
|
|
||||||
desired_rx: watch::Receiver<Option<DesiredState>>,
|
|
||||||
status_tx: watch::Sender<Option<ReconcileStatus>>,
|
|
||||||
terminal: CancellationToken,
|
|
||||||
closed: Arc<AtomicBool>,
|
|
||||||
completed_flag: Arc<AtomicBool>,
|
|
||||||
cleanup_succeeded: Arc<AtomicBool>,
|
|
||||||
completed: Arc<Notify>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
runner,
|
|
||||||
desired_rx,
|
|
||||||
status_tx,
|
|
||||||
terminal,
|
|
||||||
completion: CompletionGuard {
|
|
||||||
closed,
|
|
||||||
completed_flag,
|
|
||||||
completed,
|
|
||||||
},
|
|
||||||
cleanup_succeeded,
|
|
||||||
applied: AppliedState::Unknown,
|
|
||||||
last_generation: 0,
|
|
||||||
last_policy: None,
|
|
||||||
last_stats: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn run(mut self, process_cancellation: CancellationToken) {
|
|
||||||
let mut current = None;
|
|
||||||
let mut retry_index = 0usize;
|
|
||||||
'run: loop {
|
|
||||||
if current.is_none() {
|
|
||||||
let changed = tokio::select! {
|
|
||||||
biased;
|
|
||||||
_ = self.terminal.cancelled() => false,
|
|
||||||
_ = process_cancellation.cancelled() => false,
|
|
||||||
changed = self.desired_rx.changed() => changed.is_ok(),
|
|
||||||
};
|
|
||||||
if !changed {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
current = self.take_latest_desired();
|
|
||||||
retry_index = 0;
|
|
||||||
if current.is_none() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let desired = current.as_ref().expect("desired state is present").clone();
|
|
||||||
let interruptible =
|
|
||||||
InterruptibleRunner::new(&self.runner, &self.terminal, &process_cancellation);
|
|
||||||
let result =
|
|
||||||
reconcile_once(&interruptible, &interruptible, &mut self.applied, &desired).await;
|
|
||||||
match result {
|
|
||||||
Ok(()) => {
|
|
||||||
desired
|
|
||||||
.stats
|
|
||||||
.increment_conntrack_rule_reconcile_success_total();
|
|
||||||
desired.stats.set_conntrack_rule_apply_ok(true);
|
|
||||||
self.status_tx.send_replace(Some(ReconcileStatus {
|
|
||||||
generation: desired.generation,
|
|
||||||
outcome: ReconcileOutcome::Applied,
|
|
||||||
}));
|
|
||||||
if self.terminal.is_cancelled() || process_cancellation.is_cancelled() {
|
|
||||||
desired.stats.set_conntrack_rule_apply_ok(false);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
current = None;
|
|
||||||
retry_index = 0;
|
|
||||||
}
|
|
||||||
Err(failure) if failure.cancelled => break,
|
|
||||||
Err(failure) => {
|
|
||||||
desired
|
|
||||||
.stats
|
|
||||||
.increment_conntrack_rule_reconcile_error_total();
|
|
||||||
desired.stats.set_conntrack_rule_apply_ok(false);
|
|
||||||
if let Some(rollback_succeeded) = failure.rollback_succeeded {
|
|
||||||
if rollback_succeeded {
|
|
||||||
desired
|
|
||||||
.stats
|
|
||||||
.increment_conntrack_rule_rollback_success_total();
|
|
||||||
} else {
|
|
||||||
desired
|
|
||||||
.stats
|
|
||||||
.increment_conntrack_rule_rollback_error_total();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.status_tx.send_replace(Some(ReconcileStatus {
|
|
||||||
generation: desired.generation,
|
|
||||||
outcome: ReconcileOutcome::Failed,
|
|
||||||
}));
|
|
||||||
warn!(
|
|
||||||
generation = desired.generation,
|
|
||||||
error = %failure.message,
|
|
||||||
"Failed to reconcile conntrack firewall policy"
|
|
||||||
);
|
|
||||||
|
|
||||||
let delay = RETRY_DELAYS[retry_index.min(RETRY_DELAYS.len() - 1)];
|
|
||||||
retry_index = retry_index.saturating_add(1);
|
|
||||||
let retry_deadline = tokio::time::Instant::now() + delay;
|
|
||||||
loop {
|
|
||||||
tokio::select! {
|
|
||||||
biased;
|
|
||||||
_ = self.terminal.cancelled() => break 'run,
|
|
||||||
_ = process_cancellation.cancelled() => break 'run,
|
|
||||||
changed = self.desired_rx.changed() => {
|
|
||||||
if changed.is_err() {
|
|
||||||
break 'run;
|
|
||||||
}
|
|
||||||
if let Some(next) = self.take_latest_desired() {
|
|
||||||
current = Some(next);
|
|
||||||
retry_index = 0;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ = tokio::time::sleep_until(retry_deadline) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.terminal.is_cancelled() || process_cancellation.is_cancelled() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if self.desired_rx.has_changed().unwrap_or(false) {
|
|
||||||
if let Some(next) = self.take_latest_desired() {
|
|
||||||
current = Some(next);
|
|
||||||
retry_index = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(stats) = &self.last_stats {
|
|
||||||
stats.set_conntrack_rule_apply_ok(false);
|
|
||||||
}
|
|
||||||
if let Err(error) =
|
|
||||||
tokio::time::timeout(SHUTDOWN_CLEANUP_TIMEOUT, recover_to_empty(&self.runner))
|
|
||||||
.await
|
|
||||||
.unwrap_or_else(|_| {
|
|
||||||
Err(CommandError::failed("firewall shutdown cleanup timed out"))
|
|
||||||
})
|
|
||||||
{
|
|
||||||
warn!(error = %error, "Failed to clear conntrack firewall policy during shutdown");
|
|
||||||
} else {
|
|
||||||
self.applied = AppliedState::Known(AppliedPlan::Empty);
|
|
||||||
self.cleanup_succeeded.store(true, Ordering::Release);
|
|
||||||
}
|
|
||||||
let _completion = &self.completion;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn take_latest_desired(&mut self) -> Option<DesiredState> {
|
|
||||||
let next = self.desired_rx.borrow_and_update().clone()?;
|
|
||||||
if next.generation < self.last_generation {
|
|
||||||
warn!(
|
|
||||||
generation = next.generation,
|
|
||||||
active_generation = self.last_generation,
|
|
||||||
"Ignored stale conntrack firewall policy publication"
|
|
||||||
);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if next.generation == self.last_generation {
|
|
||||||
if self.last_policy.as_ref() != Some(&next.policy) {
|
|
||||||
warn!(
|
|
||||||
generation = next.generation,
|
|
||||||
"Ignored conflicting conntrack firewall policy for active generation"
|
|
||||||
);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
self.last_stats = Some(next.stats.clone());
|
|
||||||
if let AppliedState::Known(applied) = &self.applied
|
|
||||||
&& applied.matches_policy(&next.policy)
|
|
||||||
{
|
|
||||||
next.stats.set_conntrack_rule_apply_ok(true);
|
|
||||||
}
|
|
||||||
return Some(next);
|
|
||||||
}
|
|
||||||
self.last_generation = next.generation;
|
|
||||||
self.last_policy = Some(next.policy.clone());
|
|
||||||
self.last_stats = Some(next.stats.clone());
|
|
||||||
Some(next)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
use tokio::io::AsyncWriteExt;
|
|
||||||
#[cfg(unix)]
|
|
||||||
use tokio::process::Command;
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
use crate::util::trusted_command::resolve_trusted_helper;
|
|
||||||
|
|
||||||
const COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
||||||
pub(super) struct CommandSpec {
|
|
||||||
pub(super) binary: &'static str,
|
|
||||||
pub(super) args: Vec<String>,
|
|
||||||
pub(super) stdin: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CommandSpec {
|
|
||||||
pub(super) fn new(binary: &'static str, args: impl IntoIterator<Item = &'static str>) -> Self {
|
|
||||||
Self {
|
|
||||||
binary,
|
|
||||||
args: args.into_iter().map(str::to_string).collect(),
|
|
||||||
stdin: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn with_stdin(
|
|
||||||
binary: &'static str,
|
|
||||||
args: impl IntoIterator<Item = &'static str>,
|
|
||||||
stdin: String,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
binary,
|
|
||||||
args: args.into_iter().map(str::to_string).collect(),
|
|
||||||
stdin: Some(stdin),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub(super) enum CommandErrorKind {
|
|
||||||
Missing,
|
|
||||||
NotFound,
|
|
||||||
Cancelled,
|
|
||||||
Timeout,
|
|
||||||
Failed,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
||||||
pub(super) struct CommandError {
|
|
||||||
pub(super) kind: CommandErrorKind,
|
|
||||||
pub(super) message: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CommandError {
|
|
||||||
pub(super) fn cancelled() -> Self {
|
|
||||||
Self {
|
|
||||||
kind: CommandErrorKind::Cancelled,
|
|
||||||
message: "firewall transaction cancelled".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn failed(message: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
kind: CommandErrorKind::Failed,
|
|
||||||
message: message.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for CommandError {
|
|
||||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
formatter.write_str(&self.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) trait FirewallCommandRunner: Send + Sync {
|
|
||||||
fn available(&self, binary: &str) -> bool;
|
|
||||||
|
|
||||||
fn has_cap_net_admin(&self) -> bool;
|
|
||||||
|
|
||||||
async fn run(&self, spec: CommandSpec) -> Result<(), CommandError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Default)]
|
|
||||||
pub(super) struct SystemCommandRunner;
|
|
||||||
|
|
||||||
impl FirewallCommandRunner for SystemCommandRunner {
|
|
||||||
fn available(&self, binary: &str) -> bool {
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
resolve_trusted_helper(binary).is_some()
|
|
||||||
}
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
{
|
|
||||||
let _ = binary;
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn has_cap_net_admin(&self) -> bool {
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
let Ok(status) = std::fs::read_to_string("/proc/self/status") else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
for line in status.lines() {
|
|
||||||
if let Some(raw) = line.strip_prefix("CapEff:") {
|
|
||||||
let caps = raw.trim();
|
|
||||||
if let Ok(bits) = u64::from_str_radix(caps, 16) {
|
|
||||||
const CAP_NET_ADMIN_BIT: u64 = 12;
|
|
||||||
return (bits & (1u64 << CAP_NET_ADMIN_BIT)) != 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
{
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run(&self, spec: CommandSpec) -> Result<(), CommandError> {
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
{
|
|
||||||
Err(CommandError {
|
|
||||||
kind: CommandErrorKind::Missing,
|
|
||||||
message: format!("{} is not available", spec.binary),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
let Some(command_path) = resolve_trusted_helper(spec.binary) else {
|
|
||||||
return Err(CommandError {
|
|
||||||
kind: CommandErrorKind::Missing,
|
|
||||||
message: format!("{} is not available", spec.binary),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
let mut command = Command::new(command_path);
|
|
||||||
command.args(&spec.args);
|
|
||||||
command.env("LC_ALL", "C");
|
|
||||||
if spec.stdin.is_some() {
|
|
||||||
command.stdin(std::process::Stdio::piped());
|
|
||||||
}
|
|
||||||
command.stdout(std::process::Stdio::null());
|
|
||||||
command.stderr(std::process::Stdio::piped());
|
|
||||||
command.kill_on_drop(true);
|
|
||||||
let mut child = command.spawn().map_err(|error| CommandError {
|
|
||||||
kind: CommandErrorKind::Failed,
|
|
||||||
message: format!("spawn {} failed: {error}", spec.binary),
|
|
||||||
})?;
|
|
||||||
let binary = spec.binary;
|
|
||||||
let output = tokio::time::timeout(COMMAND_TIMEOUT, async move {
|
|
||||||
if let Some(blob) = spec.stdin
|
|
||||||
&& let Some(mut writer) = child.stdin.take()
|
|
||||||
{
|
|
||||||
writer
|
|
||||||
.write_all(blob.as_bytes())
|
|
||||||
.await
|
|
||||||
.map_err(|error| CommandError {
|
|
||||||
kind: CommandErrorKind::Failed,
|
|
||||||
message: format!("stdin write {binary} failed: {error}"),
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
child
|
|
||||||
.wait_with_output()
|
|
||||||
.await
|
|
||||||
.map_err(|error| CommandError {
|
|
||||||
kind: CommandErrorKind::Failed,
|
|
||||||
message: format!("wait {binary} failed: {error}"),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|_| CommandError {
|
|
||||||
kind: CommandErrorKind::Timeout,
|
|
||||||
message: format!("{binary} timed out after {}s", COMMAND_TIMEOUT.as_secs()),
|
|
||||||
})??;
|
|
||||||
if output.status.success() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
|
||||||
let message = if stderr.is_empty() {
|
|
||||||
format!("{binary} exited with status {}", output.status)
|
|
||||||
} else {
|
|
||||||
stderr
|
|
||||||
};
|
|
||||||
let kind = if is_not_found_error(&message) {
|
|
||||||
CommandErrorKind::NotFound
|
|
||||||
} else {
|
|
||||||
CommandErrorKind::Failed
|
|
||||||
};
|
|
||||||
Err(CommandError { kind, message })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn is_not_found_error(message: &str) -> bool {
|
|
||||||
message.contains("No chain/target/match by that name")
|
|
||||||
|| message.contains("Bad rule (does a matching rule exist in that chain?)")
|
|
||||||
|| message.contains("Could not process rule: No such file or directory")
|
|
||||||
}
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
use super::command::{CommandError, CommandErrorKind, CommandSpec, FirewallCommandRunner};
|
|
||||||
use super::model::{NotrackTarget, ShadowSlot};
|
|
||||||
|
|
||||||
const DISPATCH_CHAIN: &str = "TELEMT_NOTRACK";
|
|
||||||
const SHADOW_CHAIN_A: &str = "TELEMT_NT_A";
|
|
||||||
const SHADOW_CHAIN_B: &str = "TELEMT_NT_B";
|
|
||||||
const MAX_OWNED_JUMPS: usize = 8;
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub(super) enum IpFamily {
|
|
||||||
V4,
|
|
||||||
V6,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IpFamily {
|
|
||||||
fn command_binary(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::V4 => "iptables",
|
|
||||||
Self::V6 => "ip6tables",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn restore_binary(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::V4 => "iptables-restore",
|
|
||||||
Self::V6 => "ip6tables-restore",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn family_available<R: FirewallCommandRunner>(runner: &R, family: IpFamily) -> bool {
|
|
||||||
runner.available(family.command_binary()) && runner.available(family.restore_binary())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn shadow_chain(slot: ShadowSlot) -> &'static str {
|
|
||||||
match slot {
|
|
||||||
ShadowSlot::A => SHADOW_CHAIN_A,
|
|
||||||
ShadowSlot::B => SHADOW_CHAIN_B,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn stage_family<R: FirewallCommandRunner>(
|
|
||||||
runner: &R,
|
|
||||||
family: IpFamily,
|
|
||||||
slot: ShadowSlot,
|
|
||||||
targets: &[NotrackTarget],
|
|
||||||
) -> Result<(), CommandError> {
|
|
||||||
if targets.is_empty() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
require_family(runner, family)?;
|
|
||||||
ensure_owned_chains(runner, family).await?;
|
|
||||||
let script = render_stage_script(slot, targets);
|
|
||||||
runner
|
|
||||||
.run(CommandSpec::with_stdin(
|
|
||||||
family.restore_binary(),
|
|
||||||
["--noflush"],
|
|
||||||
script,
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn activate_family<R: FirewallCommandRunner>(
|
|
||||||
runner: &R,
|
|
||||||
family: IpFamily,
|
|
||||||
slot: Option<ShadowSlot>,
|
|
||||||
) -> Result<(), CommandError> {
|
|
||||||
require_family(runner, family)?;
|
|
||||||
if slot.is_some() {
|
|
||||||
ensure_prerouting_jump(runner, family).await?;
|
|
||||||
}
|
|
||||||
runner
|
|
||||||
.run(CommandSpec::with_stdin(
|
|
||||||
family.restore_binary(),
|
|
||||||
["--noflush"],
|
|
||||||
render_dispatch_script(slot),
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn cleanup_all<R: FirewallCommandRunner>(runner: &R) -> Result<(), CommandError> {
|
|
||||||
let mut errors = Vec::new();
|
|
||||||
for family in [IpFamily::V4, IpFamily::V6] {
|
|
||||||
if !runner.available(family.command_binary()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Err(error) = cleanup_family(runner, family).await {
|
|
||||||
errors.push(error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if errors.is_empty() {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(CommandError::failed(errors.join("; ")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cleanup_family<R: FirewallCommandRunner>(
|
|
||||||
runner: &R,
|
|
||||||
family: IpFamily,
|
|
||||||
) -> Result<(), CommandError> {
|
|
||||||
let binary = family.command_binary();
|
|
||||||
let mut errors = Vec::new();
|
|
||||||
for _ in 0..MAX_OWNED_JUMPS {
|
|
||||||
let result = runner
|
|
||||||
.run(CommandSpec::new(
|
|
||||||
binary,
|
|
||||||
["-t", "raw", "-D", "PREROUTING", "-j", DISPATCH_CHAIN],
|
|
||||||
))
|
|
||||||
.await;
|
|
||||||
match result {
|
|
||||||
Ok(()) => {}
|
|
||||||
Err(error)
|
|
||||||
if matches!(
|
|
||||||
error.kind,
|
|
||||||
CommandErrorKind::NotFound | CommandErrorKind::Missing
|
|
||||||
) =>
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
errors.push(error.message);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for chain in [DISPATCH_CHAIN, SHADOW_CHAIN_A, SHADOW_CHAIN_B] {
|
|
||||||
for operation in ["-F", "-X"] {
|
|
||||||
let result = runner
|
|
||||||
.run(CommandSpec::new(binary, ["-t", "raw", operation, chain]))
|
|
||||||
.await;
|
|
||||||
if let Err(error) = result
|
|
||||||
&& !matches!(
|
|
||||||
error.kind,
|
|
||||||
CommandErrorKind::NotFound | CommandErrorKind::Missing
|
|
||||||
)
|
|
||||||
{
|
|
||||||
errors.push(error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if errors.is_empty() {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(CommandError::failed(errors.join("; ")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn ensure_prerouting_jump<R: FirewallCommandRunner>(
|
|
||||||
runner: &R,
|
|
||||||
family: IpFamily,
|
|
||||||
) -> Result<(), CommandError> {
|
|
||||||
let binary = family.command_binary();
|
|
||||||
match runner
|
|
||||||
.run(CommandSpec::new(
|
|
||||||
binary,
|
|
||||||
["-t", "raw", "-C", "PREROUTING", "-j", DISPATCH_CHAIN],
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(()) => Ok(()),
|
|
||||||
Err(error) if error.kind == CommandErrorKind::NotFound => {
|
|
||||||
runner
|
|
||||||
.run(CommandSpec::new(
|
|
||||||
binary,
|
|
||||||
["-t", "raw", "-I", "PREROUTING", "1", "-j", DISPATCH_CHAIN],
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
Err(error) => Err(error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn ensure_owned_chains<R: FirewallCommandRunner>(
|
|
||||||
runner: &R,
|
|
||||||
family: IpFamily,
|
|
||||||
) -> Result<(), CommandError> {
|
|
||||||
let binary = family.command_binary();
|
|
||||||
for chain in [DISPATCH_CHAIN, SHADOW_CHAIN_A, SHADOW_CHAIN_B] {
|
|
||||||
match runner
|
|
||||||
.run(CommandSpec::new(binary, ["-t", "raw", "-N", chain]))
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(()) => {}
|
|
||||||
Err(error) if is_chain_exists_error(&error.message) => {}
|
|
||||||
Err(error) => return Err(error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn is_chain_exists_error(message: &str) -> bool {
|
|
||||||
message.contains("Chain already exists")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn require_family<R: FirewallCommandRunner>(
|
|
||||||
runner: &R,
|
|
||||||
family: IpFamily,
|
|
||||||
) -> Result<(), CommandError> {
|
|
||||||
for binary in [family.command_binary(), family.restore_binary()] {
|
|
||||||
if !runner.available(binary) {
|
|
||||||
return Err(CommandError {
|
|
||||||
kind: CommandErrorKind::Missing,
|
|
||||||
message: format!("{binary} is required for conntrack firewall reconciliation"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn render_stage_script(slot: ShadowSlot, targets: &[NotrackTarget]) -> String {
|
|
||||||
let chain = shadow_chain(slot);
|
|
||||||
let mut script = format!("*raw\n-F {chain}\n");
|
|
||||||
for target in targets {
|
|
||||||
script.push_str("-A ");
|
|
||||||
script.push_str(chain);
|
|
||||||
script.push_str(" -p tcp --dport ");
|
|
||||||
script.push_str(&target.port.to_string());
|
|
||||||
if let Some(ip) = target.ip {
|
|
||||||
script.push_str(" -d ");
|
|
||||||
script.push_str(&ip.to_string());
|
|
||||||
}
|
|
||||||
script.push_str(" -j CT --notrack\n");
|
|
||||||
}
|
|
||||||
script.push_str("COMMIT\n");
|
|
||||||
script
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn render_dispatch_script(slot: Option<ShadowSlot>) -> String {
|
|
||||||
let mut script = format!("*raw\n-F {DISPATCH_CHAIN}\n");
|
|
||||||
if let Some(slot) = slot {
|
|
||||||
script.push_str("-A ");
|
|
||||||
script.push_str(DISPATCH_CHAIN);
|
|
||||||
script.push_str(" -j ");
|
|
||||||
script.push_str(shadow_chain(slot));
|
|
||||||
script.push('\n');
|
|
||||||
}
|
|
||||||
script.push_str("COMMIT\n");
|
|
||||||
script
|
|
||||||
}
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
use std::collections::BTreeSet;
|
|
||||||
use std::net::IpAddr;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use crate::config::{ConntrackBackend, ConntrackMode, ProxyConfig};
|
|
||||||
use crate::stats::Stats;
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub(super) enum ShadowSlot {
|
|
||||||
A,
|
|
||||||
B,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ShadowSlot {
|
|
||||||
pub(super) fn other(self) -> Self {
|
|
||||||
match self {
|
|
||||||
Self::A => Self::B,
|
|
||||||
Self::B => Self::A,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
|
||||||
pub(super) struct NotrackTarget {
|
|
||||||
pub(super) ip: Option<IpAddr>,
|
|
||||||
pub(super) port: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
||||||
pub(super) enum DesiredPolicy {
|
|
||||||
Empty,
|
|
||||||
Rules {
|
|
||||||
configured_backend: ConntrackBackend,
|
|
||||||
v4: Vec<NotrackTarget>,
|
|
||||||
v6: Vec<NotrackTarget>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DesiredPolicy {
|
|
||||||
pub(super) fn from_config(cfg: &ProxyConfig) -> Self {
|
|
||||||
if !cfg.server.conntrack_control.inline_conntrack_control
|
|
||||||
|| matches!(cfg.server.conntrack_control.mode, ConntrackMode::Tracked)
|
|
||||||
{
|
|
||||||
return Self::Empty;
|
|
||||||
}
|
|
||||||
let (v4, v6) = notrack_targets(cfg);
|
|
||||||
if v4.is_empty() && v6.is_empty() {
|
|
||||||
Self::Empty
|
|
||||||
} else {
|
|
||||||
Self::Rules {
|
|
||||||
configured_backend: cfg.server.conntrack_control.backend,
|
|
||||||
v4,
|
|
||||||
v6,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub(super) struct DesiredState {
|
|
||||||
pub(super) generation: u64,
|
|
||||||
pub(super) policy: DesiredPolicy,
|
|
||||||
pub(super) stats: Arc<Stats>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
||||||
pub(super) enum AppliedPlan {
|
|
||||||
Empty,
|
|
||||||
Iptables {
|
|
||||||
slot: ShadowSlot,
|
|
||||||
v4: Vec<NotrackTarget>,
|
|
||||||
v6: Vec<NotrackTarget>,
|
|
||||||
},
|
|
||||||
Nftables {
|
|
||||||
slot: ShadowSlot,
|
|
||||||
v4: Vec<NotrackTarget>,
|
|
||||||
v6: Vec<NotrackTarget>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AppliedPlan {
|
|
||||||
pub(super) fn slot(&self) -> Option<ShadowSlot> {
|
|
||||||
match self {
|
|
||||||
Self::Empty => None,
|
|
||||||
Self::Iptables { slot, .. } | Self::Nftables { slot, .. } => Some(*slot),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn matches_policy(&self, policy: &DesiredPolicy) -> bool {
|
|
||||||
match (self, policy) {
|
|
||||||
(Self::Empty, DesiredPolicy::Empty) => true,
|
|
||||||
(
|
|
||||||
Self::Iptables { v4, v6, .. },
|
|
||||||
DesiredPolicy::Rules {
|
|
||||||
configured_backend,
|
|
||||||
v4: desired_v4,
|
|
||||||
v6: desired_v6,
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
matches!(
|
|
||||||
configured_backend,
|
|
||||||
ConntrackBackend::Auto | ConntrackBackend::Iptables
|
|
||||||
) && v4 == desired_v4
|
|
||||||
&& v6 == desired_v6
|
|
||||||
}
|
|
||||||
(
|
|
||||||
Self::Nftables { v4, v6, .. },
|
|
||||||
DesiredPolicy::Rules {
|
|
||||||
configured_backend,
|
|
||||||
v4: desired_v4,
|
|
||||||
v6: desired_v6,
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
matches!(
|
|
||||||
configured_backend,
|
|
||||||
ConntrackBackend::Auto | ConntrackBackend::Nftables
|
|
||||||
) && v4 == desired_v4
|
|
||||||
&& v6 == desired_v6
|
|
||||||
}
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
||||||
pub(super) enum AppliedState {
|
|
||||||
Known(AppliedPlan),
|
|
||||||
Unknown,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn listener_port_set(cfg: &ProxyConfig) -> Vec<u16> {
|
|
||||||
let mut ports = BTreeSet::new();
|
|
||||||
if cfg.server.listeners.is_empty() {
|
|
||||||
ports.insert(cfg.server.port);
|
|
||||||
} else {
|
|
||||||
for listener in &cfg.server.listeners {
|
|
||||||
ports.insert(listener.port.unwrap_or(cfg.server.port));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ports.into_iter().collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn notrack_targets(cfg: &ProxyConfig) -> (Vec<NotrackTarget>, Vec<NotrackTarget>) {
|
|
||||||
let mut v4_targets = BTreeSet::new();
|
|
||||||
let mut v6_targets = BTreeSet::new();
|
|
||||||
match cfg.server.conntrack_control.mode {
|
|
||||||
ConntrackMode::Tracked => {}
|
|
||||||
ConntrackMode::Notrack => {
|
|
||||||
if cfg.server.listeners.is_empty() {
|
|
||||||
let port = cfg.server.port;
|
|
||||||
for raw in [
|
|
||||||
cfg.server.listen_addr_ipv4.as_deref(),
|
|
||||||
cfg.server.listen_addr_ipv6.as_deref(),
|
|
||||||
]
|
|
||||||
.into_iter()
|
|
||||||
.flatten()
|
|
||||||
{
|
|
||||||
if let Ok(ip) = raw.parse::<IpAddr>() {
|
|
||||||
let target = NotrackTarget {
|
|
||||||
ip: (!ip.is_unspecified()).then_some(ip),
|
|
||||||
port,
|
|
||||||
};
|
|
||||||
if ip.is_ipv4() {
|
|
||||||
v4_targets.insert(target);
|
|
||||||
} else {
|
|
||||||
v6_targets.insert(target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for listener in &cfg.server.listeners {
|
|
||||||
let target = NotrackTarget {
|
|
||||||
ip: (!listener.ip.is_unspecified()).then_some(listener.ip),
|
|
||||||
port: listener.port.unwrap_or(cfg.server.port),
|
|
||||||
};
|
|
||||||
if listener.ip.is_ipv4() {
|
|
||||||
v4_targets.insert(target);
|
|
||||||
} else {
|
|
||||||
v6_targets.insert(target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ConntrackMode::Hybrid => {
|
|
||||||
for ip in &cfg.server.conntrack_control.hybrid_listener_ips {
|
|
||||||
for port in listener_port_set(cfg) {
|
|
||||||
let target = NotrackTarget {
|
|
||||||
ip: Some(*ip),
|
|
||||||
port,
|
|
||||||
};
|
|
||||||
if ip.is_ipv4() {
|
|
||||||
v4_targets.insert(target);
|
|
||||||
} else {
|
|
||||||
v6_targets.insert(target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(
|
|
||||||
v4_targets.into_iter().collect(),
|
|
||||||
v6_targets.into_iter().collect(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
use super::command::{CommandError, CommandErrorKind, CommandSpec, FirewallCommandRunner};
|
|
||||||
use super::model::{NotrackTarget, ShadowSlot};
|
|
||||||
|
|
||||||
const LEGACY_TABLE: &str = "telemt_conntrack";
|
|
||||||
const TABLE_A: &str = "telemt_conntrack_a";
|
|
||||||
const TABLE_B: &str = "telemt_conntrack_b";
|
|
||||||
|
|
||||||
pub(super) fn available<R: FirewallCommandRunner>(runner: &R) -> bool {
|
|
||||||
runner.available("nft")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn table(slot: ShadowSlot) -> &'static str {
|
|
||||||
match slot {
|
|
||||||
ShadowSlot::A => TABLE_A,
|
|
||||||
ShadowSlot::B => TABLE_B,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn stage<R: FirewallCommandRunner>(
|
|
||||||
runner: &R,
|
|
||||||
slot: ShadowSlot,
|
|
||||||
v4: &[NotrackTarget],
|
|
||||||
v6: &[NotrackTarget],
|
|
||||||
) -> Result<(), CommandError> {
|
|
||||||
require_nft(runner)?;
|
|
||||||
delete_table_if_present(runner, table(slot)).await?;
|
|
||||||
runner
|
|
||||||
.run(CommandSpec::with_stdin(
|
|
||||||
"nft",
|
|
||||||
["-f", "-"],
|
|
||||||
render_stage_script(slot, v4, v6),
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn activate<R: FirewallCommandRunner>(
|
|
||||||
runner: &R,
|
|
||||||
slot: ShadowSlot,
|
|
||||||
) -> Result<(), CommandError> {
|
|
||||||
require_nft(runner)?;
|
|
||||||
runner
|
|
||||||
.run(CommandSpec::with_stdin(
|
|
||||||
"nft",
|
|
||||||
["-f", "-"],
|
|
||||||
render_activate_script(slot),
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn deactivate<R: FirewallCommandRunner>(
|
|
||||||
runner: &R,
|
|
||||||
slot: ShadowSlot,
|
|
||||||
) -> Result<(), CommandError> {
|
|
||||||
delete_table_if_present(runner, table(slot)).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn cleanup_all<R: FirewallCommandRunner>(runner: &R) -> Result<(), CommandError> {
|
|
||||||
if !runner.available("nft") {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let mut errors = Vec::new();
|
|
||||||
for table_name in [LEGACY_TABLE, TABLE_A, TABLE_B] {
|
|
||||||
if let Err(error) = delete_table_if_present(runner, table_name).await {
|
|
||||||
errors.push(error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if errors.is_empty() {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(CommandError::failed(errors.join("; ")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete_table_if_present<R: FirewallCommandRunner>(
|
|
||||||
runner: &R,
|
|
||||||
table_name: &'static str,
|
|
||||||
) -> Result<(), CommandError> {
|
|
||||||
match runner
|
|
||||||
.run(CommandSpec::new(
|
|
||||||
"nft",
|
|
||||||
["delete", "table", "inet", table_name],
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(()) => Ok(()),
|
|
||||||
Err(error)
|
|
||||||
if matches!(
|
|
||||||
error.kind,
|
|
||||||
CommandErrorKind::NotFound | CommandErrorKind::Missing
|
|
||||||
) =>
|
|
||||||
{
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(error) => Err(error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn require_nft<R: FirewallCommandRunner>(runner: &R) -> Result<(), CommandError> {
|
|
||||||
if runner.available("nft") {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(CommandError {
|
|
||||||
kind: CommandErrorKind::Missing,
|
|
||||||
message: "nft is required for conntrack firewall reconciliation".to_string(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn render_stage_script(
|
|
||||||
slot: ShadowSlot,
|
|
||||||
v4: &[NotrackTarget],
|
|
||||||
v6: &[NotrackTarget],
|
|
||||||
) -> String {
|
|
||||||
let table = table(slot);
|
|
||||||
let mut script = format!("add table inet {table}\nadd chain inet {table} rules\n");
|
|
||||||
for target in v4 {
|
|
||||||
script.push_str("add rule inet ");
|
|
||||||
script.push_str(table);
|
|
||||||
script.push_str(" rules tcp dport ");
|
|
||||||
script.push_str(&target.port.to_string());
|
|
||||||
if let Some(ip) = target.ip {
|
|
||||||
script.push_str(" ip daddr ");
|
|
||||||
script.push_str(&ip.to_string());
|
|
||||||
}
|
|
||||||
script.push_str(" notrack\n");
|
|
||||||
}
|
|
||||||
for target in v6 {
|
|
||||||
script.push_str("add rule inet ");
|
|
||||||
script.push_str(table);
|
|
||||||
script.push_str(" rules tcp dport ");
|
|
||||||
script.push_str(&target.port.to_string());
|
|
||||||
if let Some(ip) = target.ip {
|
|
||||||
script.push_str(" ip6 daddr ");
|
|
||||||
script.push_str(&ip.to_string());
|
|
||||||
}
|
|
||||||
script.push_str(" notrack\n");
|
|
||||||
}
|
|
||||||
script
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn render_activate_script(slot: ShadowSlot) -> String {
|
|
||||||
let table = table(slot);
|
|
||||||
format!(
|
|
||||||
"add chain inet {table} preraw {{ type filter hook prerouting priority raw; policy accept; }}\nadd rule inet {table} preraw jump rules\n"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
use tracing::debug;
|
|
||||||
|
|
||||||
use crate::config::{ConntrackBackend, ProxyConfig};
|
|
||||||
use crate::conntrack_control::{ConntrackRuntimeSupport, NetfilterBackend};
|
|
||||||
use crate::proxy::shared_state::ConntrackCloseEvent;
|
|
||||||
|
|
||||||
use super::command::{CommandSpec, FirewallCommandRunner, SystemCommandRunner};
|
|
||||||
use super::nftables;
|
|
||||||
|
|
||||||
/// Probes the effective firewall backend and conntrack deletion capability.
|
|
||||||
pub(in crate::conntrack_control) fn probe_runtime_support(
|
|
||||||
configured_backend: ConntrackBackend,
|
|
||||||
) -> ConntrackRuntimeSupport {
|
|
||||||
let runner = SystemCommandRunner;
|
|
||||||
let iptables_available = runner.available("iptables");
|
|
||||||
let netfilter_backend = match configured_backend {
|
|
||||||
ConntrackBackend::Auto if nftables::available(&runner) => Some(NetfilterBackend::Nftables),
|
|
||||||
ConntrackBackend::Auto if iptables_available => Some(NetfilterBackend::Iptables),
|
|
||||||
ConntrackBackend::Nftables if nftables::available(&runner) => {
|
|
||||||
Some(NetfilterBackend::Nftables)
|
|
||||||
}
|
|
||||||
ConntrackBackend::Iptables if iptables_available => Some(NetfilterBackend::Iptables),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
ConntrackRuntimeSupport {
|
|
||||||
netfilter_backend,
|
|
||||||
has_cap_net_admin: runner.has_cap_net_admin(),
|
|
||||||
has_conntrack_binary: runner.available("conntrack"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolves whether conntrack close publication is usable for this runtime.
|
|
||||||
pub(in crate::conntrack_control) fn effective_conntrack_enabled(
|
|
||||||
cfg: &ProxyConfig,
|
|
||||||
runtime_support: ConntrackRuntimeSupport,
|
|
||||||
) -> bool {
|
|
||||||
cfg.server.conntrack_control.inline_conntrack_control
|
|
||||||
&& runtime_support.has_cap_net_admin
|
|
||||||
&& runtime_support.netfilter_backend.is_some()
|
|
||||||
&& runtime_support.has_conntrack_binary
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Result of one best-effort kernel conntrack deletion.
|
|
||||||
pub(in crate::conntrack_control) enum DeleteOutcome {
|
|
||||||
/// The kernel reported successful deletion.
|
|
||||||
Deleted,
|
|
||||||
/// No matching conntrack entry existed.
|
|
||||||
NotFound,
|
|
||||||
/// The helper was unavailable or returned an unexpected failure.
|
|
||||||
Error,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deletes the exact TCP tuple represented by one close event.
|
|
||||||
pub(in crate::conntrack_control) async fn delete_conntrack_entry(
|
|
||||||
event: ConntrackCloseEvent,
|
|
||||||
) -> DeleteOutcome {
|
|
||||||
let runner = SystemCommandRunner;
|
|
||||||
if !runner.available("conntrack") {
|
|
||||||
return DeleteOutcome::Error;
|
|
||||||
}
|
|
||||||
let args = vec![
|
|
||||||
"-D".to_string(),
|
|
||||||
"-p".to_string(),
|
|
||||||
"tcp".to_string(),
|
|
||||||
"-s".to_string(),
|
|
||||||
event.src.ip().to_string(),
|
|
||||||
"--sport".to_string(),
|
|
||||||
event.src.port().to_string(),
|
|
||||||
"-d".to_string(),
|
|
||||||
event.dst.ip().to_string(),
|
|
||||||
"--dport".to_string(),
|
|
||||||
event.dst.port().to_string(),
|
|
||||||
];
|
|
||||||
match runner
|
|
||||||
.run(CommandSpec {
|
|
||||||
binary: "conntrack",
|
|
||||||
args,
|
|
||||||
stdin: None,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(()) => DeleteOutcome::Deleted,
|
|
||||||
Err(error) if error.message.contains("0 flow entries have been deleted") => {
|
|
||||||
DeleteOutcome::NotFound
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
debug!(error = %error, "conntrack delete failed");
|
|
||||||
DeleteOutcome::Error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,488 +0,0 @@
|
|||||||
use std::collections::{BTreeMap, BTreeSet};
|
|
||||||
use std::future::pending;
|
|
||||||
use std::sync::atomic::Ordering;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use tokio::sync::{Notify, watch};
|
|
||||||
use tokio_util::sync::CancellationToken;
|
|
||||||
|
|
||||||
use crate::config::ConntrackBackend;
|
|
||||||
use crate::stats::Stats;
|
|
||||||
|
|
||||||
use super::actor::{FirewallReconciler, ReconcileOutcome};
|
|
||||||
use super::command::{CommandError, CommandErrorKind, CommandSpec, FirewallCommandRunner};
|
|
||||||
use super::model::{
|
|
||||||
AppliedPlan, AppliedState, DesiredPolicy, DesiredState, NotrackTarget, ShadowSlot,
|
|
||||||
};
|
|
||||||
use super::transaction::{InterruptibleRunner, reconcile_once};
|
|
||||||
|
|
||||||
#[path = "tests/model_tests.rs"]
|
|
||||||
mod model_tests;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct FailureRule {
|
|
||||||
binary: &'static str,
|
|
||||||
occurrence: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct FakeState {
|
|
||||||
calls: Vec<CommandSpec>,
|
|
||||||
binary_calls: BTreeMap<&'static str, usize>,
|
|
||||||
failures: Vec<FailureRule>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct FakeRunner {
|
|
||||||
available: Arc<BTreeSet<&'static str>>,
|
|
||||||
has_cap_net_admin: bool,
|
|
||||||
state: Arc<Mutex<FakeState>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FakeRunner {
|
|
||||||
fn all_available() -> Self {
|
|
||||||
Self {
|
|
||||||
available: Arc::new(BTreeSet::from([
|
|
||||||
"conntrack",
|
|
||||||
"ip6tables",
|
|
||||||
"ip6tables-restore",
|
|
||||||
"iptables",
|
|
||||||
"iptables-restore",
|
|
||||||
"nft",
|
|
||||||
])),
|
|
||||||
has_cap_net_admin: true,
|
|
||||||
state: Arc::new(Mutex::new(FakeState::default())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn with_failure(self, binary: &'static str, occurrence: usize) -> Self {
|
|
||||||
self.state
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.failures
|
|
||||||
.push(FailureRule { binary, occurrence });
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
fn calls(&self) -> Vec<CommandSpec> {
|
|
||||||
self.state.lock().unwrap().calls.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FirewallCommandRunner for FakeRunner {
|
|
||||||
fn available(&self, binary: &str) -> bool {
|
|
||||||
self.available.contains(binary)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn has_cap_net_admin(&self) -> bool {
|
|
||||||
self.has_cap_net_admin
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run(&self, spec: CommandSpec) -> Result<(), CommandError> {
|
|
||||||
let mut state = self.state.lock().unwrap();
|
|
||||||
let occurrence = {
|
|
||||||
let count = state.binary_calls.entry(spec.binary).or_default();
|
|
||||||
*count += 1;
|
|
||||||
*count
|
|
||||||
};
|
|
||||||
state.calls.push(spec.clone());
|
|
||||||
if state
|
|
||||||
.failures
|
|
||||||
.iter()
|
|
||||||
.any(|failure| failure.binary == spec.binary && failure.occurrence == occurrence)
|
|
||||||
{
|
|
||||||
return Err(CommandError::failed(format!(
|
|
||||||
"injected {} failure at occurrence {}",
|
|
||||||
spec.binary, occurrence
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
drop(state);
|
|
||||||
|
|
||||||
let operation = spec.args.get(2).map(String::as_str);
|
|
||||||
if (matches!(spec.binary, "iptables" | "ip6tables")
|
|
||||||
&& matches!(operation, Some("-C" | "-D" | "-F" | "-X")))
|
|
||||||
|| (spec.binary == "nft" && spec.args.first().map(String::as_str) == Some("delete"))
|
|
||||||
{
|
|
||||||
return Err(CommandError {
|
|
||||||
kind: CommandErrorKind::NotFound,
|
|
||||||
message: "injected object not found".to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BlockingRunner {
|
|
||||||
entered: Arc<Notify>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FirewallCommandRunner for BlockingRunner {
|
|
||||||
fn available(&self, _binary: &str) -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
fn has_cap_net_admin(&self) -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run(&self, _spec: CommandSpec) -> Result<(), CommandError> {
|
|
||||||
self.entered.notify_one();
|
|
||||||
pending().await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn target(ip: Option<&str>, port: u16) -> NotrackTarget {
|
|
||||||
NotrackTarget {
|
|
||||||
ip: ip.map(|value| value.parse().unwrap()),
|
|
||||||
port,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn desired(generation: u64, policy: DesiredPolicy) -> DesiredState {
|
|
||||||
DesiredState {
|
|
||||||
generation,
|
|
||||||
policy,
|
|
||||||
stats: Arc::new(Stats::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn dual_stack_policy(port: u16) -> DesiredPolicy {
|
|
||||||
DesiredPolicy::Rules {
|
|
||||||
configured_backend: ConntrackBackend::Iptables,
|
|
||||||
v4: vec![target(Some("192.0.2.10"), port)],
|
|
||||||
v6: vec![target(Some("2001:db8::10"), port)],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn nft_dual_stack_policy(port: u16) -> DesiredPolicy {
|
|
||||||
DesiredPolicy::Rules {
|
|
||||||
configured_backend: ConntrackBackend::Nftables,
|
|
||||||
v4: vec![target(Some("192.0.2.10"), port)],
|
|
||||||
v6: vec![target(Some("2001:db8::10"), port)],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn successful_reconcile_flips_between_shadow_slots() {
|
|
||||||
let runner = FakeRunner::all_available();
|
|
||||||
let mut applied = AppliedState::Known(AppliedPlan::Empty);
|
|
||||||
let first = desired(1, dual_stack_policy(443));
|
|
||||||
|
|
||||||
reconcile_once(&runner, &runner, &mut applied, &first)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(matches!(
|
|
||||||
applied,
|
|
||||||
AppliedState::Known(AppliedPlan::Iptables {
|
|
||||||
slot: ShadowSlot::A,
|
|
||||||
..
|
|
||||||
})
|
|
||||||
));
|
|
||||||
|
|
||||||
let second = desired(2, dual_stack_policy(8443));
|
|
||||||
reconcile_once(&runner, &runner, &mut applied, &second)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(matches!(
|
|
||||||
applied,
|
|
||||||
AppliedState::Known(AppliedPlan::Iptables {
|
|
||||||
slot: ShadowSlot::B,
|
|
||||||
..
|
|
||||||
})
|
|
||||||
));
|
|
||||||
assert!(runner.calls().iter().any(|call| {
|
|
||||||
call.stdin
|
|
||||||
.as_deref()
|
|
||||||
.is_some_and(|script| script.contains("-A TELEMT_NOTRACK -j TELEMT_NT_B"))
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn identical_policy_is_command_free_after_convergence() {
|
|
||||||
let runner = FakeRunner::all_available();
|
|
||||||
let mut applied = AppliedState::Known(AppliedPlan::Empty);
|
|
||||||
reconcile_once(
|
|
||||||
&runner,
|
|
||||||
&runner,
|
|
||||||
&mut applied,
|
|
||||||
&desired(1, dual_stack_policy(443)),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let calls_after_convergence = runner.calls().len();
|
|
||||||
|
|
||||||
reconcile_once(
|
|
||||||
&runner,
|
|
||||||
&runner,
|
|
||||||
&mut applied,
|
|
||||||
&desired(2, dual_stack_policy(443)),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(runner.calls().len(), calls_after_convergence);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn backend_migration_failure_restores_previous_backend() {
|
|
||||||
let runner = FakeRunner::all_available().with_failure("ip6tables-restore", 3);
|
|
||||||
let mut applied = AppliedState::Known(AppliedPlan::Empty);
|
|
||||||
reconcile_once(
|
|
||||||
&runner,
|
|
||||||
&runner,
|
|
||||||
&mut applied,
|
|
||||||
&desired(1, dual_stack_policy(443)),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let previous = applied.clone();
|
|
||||||
|
|
||||||
let failure = reconcile_once(
|
|
||||||
&runner,
|
|
||||||
&runner,
|
|
||||||
&mut applied,
|
|
||||||
&desired(2, nft_dual_stack_policy(8443)),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap_err();
|
|
||||||
|
|
||||||
assert_eq!(failure.rollback_succeeded, Some(true));
|
|
||||||
assert_eq!(applied, previous);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn unavailable_target_does_not_clear_confirmed_applied_policy() {
|
|
||||||
let runner = FakeRunner::all_available();
|
|
||||||
let mut applied = AppliedState::Known(AppliedPlan::Empty);
|
|
||||||
reconcile_once(
|
|
||||||
&runner,
|
|
||||||
&runner,
|
|
||||||
&mut applied,
|
|
||||||
&desired(1, dual_stack_policy(443)),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let previous = applied.clone();
|
|
||||||
let unavailable = FakeRunner {
|
|
||||||
available: Arc::new(BTreeSet::new()),
|
|
||||||
has_cap_net_admin: false,
|
|
||||||
state: Arc::new(Mutex::new(FakeState::default())),
|
|
||||||
};
|
|
||||||
|
|
||||||
let failure = reconcile_once(
|
|
||||||
&unavailable,
|
|
||||||
&unavailable,
|
|
||||||
&mut applied,
|
|
||||||
&desired(2, nft_dual_stack_policy(8443)),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap_err();
|
|
||||||
|
|
||||||
assert_eq!(failure.rollback_succeeded, None);
|
|
||||||
assert_eq!(applied, previous);
|
|
||||||
assert!(unavailable.calls().is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn partial_dual_stack_failure_restores_previous_applied_plan() {
|
|
||||||
let runner = FakeRunner::all_available().with_failure("ip6tables-restore", 4);
|
|
||||||
let mut applied = AppliedState::Known(AppliedPlan::Empty);
|
|
||||||
let first = desired(1, dual_stack_policy(443));
|
|
||||||
reconcile_once(&runner, &runner, &mut applied, &first)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let previous = applied.clone();
|
|
||||||
|
|
||||||
let failure = reconcile_once(
|
|
||||||
&runner,
|
|
||||||
&runner,
|
|
||||||
&mut applied,
|
|
||||||
&desired(2, dual_stack_policy(8443)),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap_err();
|
|
||||||
|
|
||||||
assert_eq!(failure.rollback_succeeded, Some(true));
|
|
||||||
assert_eq!(applied, previous);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn rollback_failure_marks_applied_state_unknown() {
|
|
||||||
let runner = FakeRunner::all_available()
|
|
||||||
.with_failure("ip6tables-restore", 4)
|
|
||||||
.with_failure("nft", 2);
|
|
||||||
let mut applied = AppliedState::Known(AppliedPlan::Empty);
|
|
||||||
reconcile_once(
|
|
||||||
&runner,
|
|
||||||
&runner,
|
|
||||||
&mut applied,
|
|
||||||
&desired(1, dual_stack_policy(443)),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let failure = reconcile_once(
|
|
||||||
&runner,
|
|
||||||
&runner,
|
|
||||||
&mut applied,
|
|
||||||
&desired(2, dual_stack_policy(8443)),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap_err();
|
|
||||||
|
|
||||||
assert_eq!(failure.rollback_succeeded, Some(false));
|
|
||||||
assert_eq!(applied, AppliedState::Unknown);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn transaction_cancellation_does_not_claim_a_new_applied_plan() {
|
|
||||||
let entered = Arc::new(Notify::new());
|
|
||||||
let runner = BlockingRunner {
|
|
||||||
entered: entered.clone(),
|
|
||||||
};
|
|
||||||
let terminal = CancellationToken::new();
|
|
||||||
let process_cancellation = CancellationToken::new();
|
|
||||||
let interruptible = InterruptibleRunner::new(&runner, &terminal, &process_cancellation);
|
|
||||||
let mut applied = AppliedState::Known(AppliedPlan::Empty);
|
|
||||||
let desired = desired(1, dual_stack_policy(443));
|
|
||||||
let failure = {
|
|
||||||
let transaction = reconcile_once(&interruptible, &runner, &mut applied, &desired);
|
|
||||||
tokio::pin!(transaction);
|
|
||||||
|
|
||||||
tokio::select! {
|
|
||||||
_ = entered.notified() => terminal.cancel(),
|
|
||||||
_ = &mut transaction => panic!("transaction completed before injected cancellation"),
|
|
||||||
}
|
|
||||||
transaction.await.unwrap_err()
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(failure.cancelled);
|
|
||||||
assert_eq!(applied, AppliedState::Known(AppliedPlan::Empty));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn desired_watch_coalesces_and_rejects_stale_or_conflicting_generations() {
|
|
||||||
let runner = FakeRunner::all_available();
|
|
||||||
let (desired_tx, desired_rx) = watch::channel(None);
|
|
||||||
let (status_tx, _status_rx) = watch::channel(None);
|
|
||||||
let terminal = CancellationToken::new();
|
|
||||||
let closed = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
||||||
let completed = Arc::new(Notify::new());
|
|
||||||
let mut reconciler = FirewallReconciler::new(
|
|
||||||
runner,
|
|
||||||
desired_rx,
|
|
||||||
status_tx,
|
|
||||||
terminal,
|
|
||||||
closed,
|
|
||||||
Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
|
||||||
Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
|
||||||
completed,
|
|
||||||
);
|
|
||||||
|
|
||||||
desired_tx.send_replace(Some(desired(1, dual_stack_policy(443))));
|
|
||||||
desired_tx.send_replace(Some(desired(2, dual_stack_policy(8443))));
|
|
||||||
assert_eq!(reconciler.take_latest_desired().unwrap().generation, 2);
|
|
||||||
|
|
||||||
desired_tx.send_replace(Some(desired(1, dual_stack_policy(443))));
|
|
||||||
assert!(reconciler.take_latest_desired().is_none());
|
|
||||||
|
|
||||||
desired_tx.send_replace(Some(desired(2, dual_stack_policy(9443))));
|
|
||||||
assert!(reconciler.take_latest_desired().is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(start_paused = true)]
|
|
||||||
async fn actor_retries_with_backoff_and_cleans_owned_rules_on_shutdown() {
|
|
||||||
let runner = FakeRunner::all_available().with_failure("iptables-restore", 1);
|
|
||||||
let observed_runner = runner.clone();
|
|
||||||
let (desired_tx, desired_rx) = watch::channel(None);
|
|
||||||
let (status_tx, mut status_rx) = watch::channel(None);
|
|
||||||
let terminal = CancellationToken::new();
|
|
||||||
let closed = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
||||||
let completed_flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
||||||
let cleanup_succeeded = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
||||||
let completed = Arc::new(Notify::new());
|
|
||||||
let reconciler = FirewallReconciler::new(
|
|
||||||
runner,
|
|
||||||
desired_rx,
|
|
||||||
status_tx,
|
|
||||||
terminal.clone(),
|
|
||||||
closed.clone(),
|
|
||||||
completed_flag.clone(),
|
|
||||||
cleanup_succeeded.clone(),
|
|
||||||
completed,
|
|
||||||
);
|
|
||||||
let process_cancellation = CancellationToken::new();
|
|
||||||
let task = tokio::spawn(reconciler.run(process_cancellation));
|
|
||||||
|
|
||||||
let initial_desired = desired(2, dual_stack_policy(443));
|
|
||||||
let desired_stats = initial_desired.stats.clone();
|
|
||||||
desired_tx.send_replace(Some(initial_desired));
|
|
||||||
status_rx.changed().await.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
status_rx.borrow().as_ref().unwrap().outcome,
|
|
||||||
ReconcileOutcome::Failed
|
|
||||||
);
|
|
||||||
let calls_after_failure = observed_runner.calls().len();
|
|
||||||
|
|
||||||
desired_tx.send_replace(Some(desired(1, dual_stack_policy(7443))));
|
|
||||||
tokio::task::yield_now().await;
|
|
||||||
desired_tx.send_replace(Some(desired(2, dual_stack_policy(9443))));
|
|
||||||
tokio::task::yield_now().await;
|
|
||||||
tokio::time::advance(Duration::from_millis(999)).await;
|
|
||||||
tokio::task::yield_now().await;
|
|
||||||
assert_eq!(observed_runner.calls().len(), calls_after_failure);
|
|
||||||
|
|
||||||
tokio::time::advance(Duration::from_millis(1)).await;
|
|
||||||
status_rx.changed().await.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
status_rx.borrow().as_ref().unwrap().outcome,
|
|
||||||
ReconcileOutcome::Applied
|
|
||||||
);
|
|
||||||
assert_eq!(status_rx.borrow().as_ref().unwrap().generation, 2);
|
|
||||||
assert!(observed_runner.calls().iter().all(|call| {
|
|
||||||
call.stdin
|
|
||||||
.as_ref()
|
|
||||||
.is_none_or(|script| !script.contains("7443") && !script.contains("9443"))
|
|
||||||
}));
|
|
||||||
let calls_before_shutdown = observed_runner.calls().len();
|
|
||||||
|
|
||||||
terminal.cancel();
|
|
||||||
tokio::time::timeout(Duration::from_secs(1), task)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
assert!(closed.load(Ordering::Acquire));
|
|
||||||
assert!(completed_flag.load(Ordering::Acquire));
|
|
||||||
assert!(cleanup_succeeded.load(Ordering::Acquire));
|
|
||||||
assert!(!desired_stats.get_conntrack_rule_apply_ok());
|
|
||||||
assert!(observed_runner.calls().len() > calls_before_shutdown);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn actor_reports_terminal_cleanup_failure_separately_from_completion() {
|
|
||||||
let runner = FakeRunner::all_available().with_failure("nft", 1);
|
|
||||||
let (_desired_tx, desired_rx) = watch::channel(None);
|
|
||||||
let (status_tx, _status_rx) = watch::channel(None);
|
|
||||||
let terminal = CancellationToken::new();
|
|
||||||
terminal.cancel();
|
|
||||||
let closed = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
||||||
let completed_flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
||||||
let cleanup_succeeded = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
||||||
let reconciler = FirewallReconciler::new(
|
|
||||||
runner,
|
|
||||||
desired_rx,
|
|
||||||
status_tx,
|
|
||||||
terminal,
|
|
||||||
closed.clone(),
|
|
||||||
completed_flag.clone(),
|
|
||||||
cleanup_succeeded.clone(),
|
|
||||||
Arc::new(Notify::new()),
|
|
||||||
);
|
|
||||||
|
|
||||||
reconciler.run(CancellationToken::new()).await;
|
|
||||||
|
|
||||||
assert!(closed.load(Ordering::Acquire));
|
|
||||||
assert!(completed_flag.load(Ordering::Acquire));
|
|
||||||
assert!(!cleanup_succeeded.load(Ordering::Acquire));
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
use crate::config::{ConntrackBackend, ConntrackMode, ProxyConfig};
|
|
||||||
|
|
||||||
use super::super::command::is_not_found_error;
|
|
||||||
use super::super::iptables::{self, is_chain_exists_error};
|
|
||||||
use super::super::model::{DesiredPolicy, ShadowSlot};
|
|
||||||
use super::super::nftables;
|
|
||||||
use super::target;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn desired_policy_derives_exact_listener_targets() {
|
|
||||||
let mut config = ProxyConfig::default();
|
|
||||||
config.server.port = 8443;
|
|
||||||
config.server.listen_addr_ipv4 = Some("0.0.0.0".to_string());
|
|
||||||
config.server.listen_addr_ipv6 = Some("2001:db8::10".to_string());
|
|
||||||
config.server.conntrack_control.inline_conntrack_control = true;
|
|
||||||
config.server.conntrack_control.mode = ConntrackMode::Notrack;
|
|
||||||
config.server.conntrack_control.backend = ConntrackBackend::Iptables;
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
DesiredPolicy::from_config(&config),
|
|
||||||
DesiredPolicy::Rules {
|
|
||||||
configured_backend: ConntrackBackend::Iptables,
|
|
||||||
v4: vec![target(None, 8443)],
|
|
||||||
v6: vec![target(Some("2001:db8::10"), 8443)],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
config.server.conntrack_control.mode = ConntrackMode::Tracked;
|
|
||||||
assert_eq!(DesiredPolicy::from_config(&config), DesiredPolicy::Empty);
|
|
||||||
config.server.conntrack_control.mode = ConntrackMode::Notrack;
|
|
||||||
config.server.conntrack_control.inline_conntrack_control = false;
|
|
||||||
assert_eq!(DesiredPolicy::from_config(&config), DesiredPolicy::Empty);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hybrid_policy_is_a_sorted_deduplicated_address_port_product() {
|
|
||||||
let mut config = ProxyConfig::default();
|
|
||||||
config.server.conntrack_control.inline_conntrack_control = true;
|
|
||||||
config.server.conntrack_control.mode = ConntrackMode::Hybrid;
|
|
||||||
config.server.conntrack_control.hybrid_listener_ips = vec![
|
|
||||||
"2001:db8::10".parse().unwrap(),
|
|
||||||
"192.0.2.10".parse().unwrap(),
|
|
||||||
"192.0.2.10".parse().unwrap(),
|
|
||||||
];
|
|
||||||
config.server.listeners = vec![
|
|
||||||
serde_json::from_value(serde_json::json!({
|
|
||||||
"ip": "0.0.0.0",
|
|
||||||
"port": 8443
|
|
||||||
}))
|
|
||||||
.unwrap(),
|
|
||||||
serde_json::from_value(serde_json::json!({
|
|
||||||
"ip": "::",
|
|
||||||
"port": 443
|
|
||||||
}))
|
|
||||||
.unwrap(),
|
|
||||||
];
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
DesiredPolicy::from_config(&config),
|
|
||||||
DesiredPolicy::Rules {
|
|
||||||
configured_backend: ConntrackBackend::Auto,
|
|
||||||
v4: vec![
|
|
||||||
target(Some("192.0.2.10"), 443),
|
|
||||||
target(Some("192.0.2.10"), 8443),
|
|
||||||
],
|
|
||||||
v6: vec![
|
|
||||||
target(Some("2001:db8::10"), 443),
|
|
||||||
target(Some("2001:db8::10"), 8443),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn restore_renderers_keep_staging_detached_from_activation() {
|
|
||||||
let stage = iptables::render_stage_script(ShadowSlot::B, &[target(Some("192.0.2.20"), 443)]);
|
|
||||||
assert!(stage.contains("-F TELEMT_NT_B\n"));
|
|
||||||
assert!(stage.contains("-A TELEMT_NT_B -p tcp --dport 443 -d 192.0.2.20 -j CT --notrack\n"));
|
|
||||||
assert!(!stage.contains("-A TELEMT_NOTRACK -j TELEMT_NT_B"));
|
|
||||||
assert!(!stage.contains(":TELEMT_"));
|
|
||||||
|
|
||||||
let activation = iptables::render_dispatch_script(Some(ShadowSlot::B));
|
|
||||||
assert!(activation.contains("-F TELEMT_NOTRACK\n"));
|
|
||||||
assert!(activation.contains("-A TELEMT_NOTRACK -j TELEMT_NT_B\n"));
|
|
||||||
assert!(!activation.contains(":TELEMT_"));
|
|
||||||
|
|
||||||
let nft_stage = nftables::render_stage_script(
|
|
||||||
ShadowSlot::A,
|
|
||||||
&[target(None, 443)],
|
|
||||||
&[target(Some("2001:db8::20"), 8443)],
|
|
||||||
);
|
|
||||||
assert!(!nft_stage.contains("hook prerouting"));
|
|
||||||
assert!(nft_stage.contains("tcp dport 443 notrack\n"));
|
|
||||||
assert!(nft_stage.contains("tcp dport 8443 ip6 daddr 2001:db8::20 notrack\n"));
|
|
||||||
assert!(nftables::render_activate_script(ShadowSlot::A).contains("hook prerouting"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn command_error_classification_only_accepts_absent_owned_objects() {
|
|
||||||
assert!(is_not_found_error(
|
|
||||||
"iptables: No chain/target/match by that name."
|
|
||||||
));
|
|
||||||
assert!(is_not_found_error(
|
|
||||||
"Bad rule (does a matching rule exist in that chain?)."
|
|
||||||
));
|
|
||||||
assert!(is_not_found_error(
|
|
||||||
"Error: Could not process rule: No such file or directory"
|
|
||||||
));
|
|
||||||
assert!(!is_not_found_error("Permission denied"));
|
|
||||||
assert!(!is_not_found_error(
|
|
||||||
"can't initialize iptables table `raw': Table does not exist"
|
|
||||||
));
|
|
||||||
assert!(!is_not_found_error(
|
|
||||||
"Another app is currently holding the xtables lock"
|
|
||||||
));
|
|
||||||
assert!(is_chain_exists_error("iptables: Chain already exists."));
|
|
||||||
assert!(!is_chain_exists_error("Permission denied"));
|
|
||||||
}
|
|
||||||
@@ -1,335 +0,0 @@
|
|||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use tokio_util::sync::CancellationToken;
|
|
||||||
|
|
||||||
use crate::config::ConntrackBackend;
|
|
||||||
|
|
||||||
use super::command::{CommandError, CommandErrorKind, CommandSpec, FirewallCommandRunner};
|
|
||||||
use super::iptables::{self, IpFamily};
|
|
||||||
use super::model::{AppliedPlan, AppliedState, DesiredPolicy, DesiredState, ShadowSlot};
|
|
||||||
use super::nftables;
|
|
||||||
|
|
||||||
const TRANSACTION_TIMEOUT: Duration = Duration::from_secs(30);
|
|
||||||
|
|
||||||
pub(super) struct InterruptibleRunner<'a, R> {
|
|
||||||
inner: &'a R,
|
|
||||||
terminal: &'a CancellationToken,
|
|
||||||
process_cancellation: &'a CancellationToken,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a, R> InterruptibleRunner<'a, R> {
|
|
||||||
pub(super) fn new(
|
|
||||||
inner: &'a R,
|
|
||||||
terminal: &'a CancellationToken,
|
|
||||||
process_cancellation: &'a CancellationToken,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
inner,
|
|
||||||
terminal,
|
|
||||||
process_cancellation,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<R: FirewallCommandRunner> FirewallCommandRunner for InterruptibleRunner<'_, R> {
|
|
||||||
fn available(&self, binary: &str) -> bool {
|
|
||||||
self.inner.available(binary)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn has_cap_net_admin(&self) -> bool {
|
|
||||||
self.inner.has_cap_net_admin()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run(&self, spec: CommandSpec) -> Result<(), CommandError> {
|
|
||||||
tokio::select! {
|
|
||||||
biased;
|
|
||||||
_ = self.terminal.cancelled() => Err(CommandError::cancelled()),
|
|
||||||
_ = self.process_cancellation.cancelled() => Err(CommandError::cancelled()),
|
|
||||||
result = self.inner.run(spec) => result,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub(super) struct ReconcileFailure {
|
|
||||||
pub(super) message: String,
|
|
||||||
pub(super) rollback_succeeded: Option<bool>,
|
|
||||||
pub(super) cancelled: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn reconcile_once<I, R>(
|
|
||||||
interruptible: &I,
|
|
||||||
recovery_runner: &R,
|
|
||||||
applied: &mut AppliedState,
|
|
||||||
desired: &DesiredState,
|
|
||||||
) -> Result<(), ReconcileFailure>
|
|
||||||
where
|
|
||||||
I: FirewallCommandRunner,
|
|
||||||
R: FirewallCommandRunner,
|
|
||||||
{
|
|
||||||
if let AppliedState::Known(current) = applied
|
|
||||||
&& current.matches_policy(&desired.policy)
|
|
||||||
{
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
if matches!(applied, AppliedState::Unknown) {
|
|
||||||
match tokio::time::timeout(TRANSACTION_TIMEOUT, recover_to_empty(interruptible)).await {
|
|
||||||
Ok(Ok(())) => *applied = AppliedState::Known(AppliedPlan::Empty),
|
|
||||||
Ok(Err(error)) if error.kind == CommandErrorKind::Cancelled => {
|
|
||||||
return Err(cancelled_failure());
|
|
||||||
}
|
|
||||||
Ok(Err(error)) => {
|
|
||||||
return Err(ReconcileFailure {
|
|
||||||
message: format!("startup recovery failed: {error}"),
|
|
||||||
rollback_succeeded: None,
|
|
||||||
cancelled: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
return Err(ReconcileFailure {
|
|
||||||
message: "startup recovery timed out".to_string(),
|
|
||||||
rollback_succeeded: None,
|
|
||||||
cancelled: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let previous = match applied {
|
|
||||||
AppliedState::Known(plan) => plan.clone(),
|
|
||||||
AppliedState::Unknown => unreachable!("unknown state was recovered above"),
|
|
||||||
};
|
|
||||||
let target = match resolve_target(interruptible, &desired.policy, &previous) {
|
|
||||||
Ok(target) => target,
|
|
||||||
Err(error) => {
|
|
||||||
return Err(ReconcileFailure {
|
|
||||||
message: error.message,
|
|
||||||
rollback_succeeded: None,
|
|
||||||
cancelled: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let transition = tokio::time::timeout(
|
|
||||||
TRANSACTION_TIMEOUT,
|
|
||||||
transition_plan(interruptible, &previous, &target),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
match transition {
|
|
||||||
Ok(Ok(())) => {
|
|
||||||
*applied = AppliedState::Known(target);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Ok(Err(error)) if error.kind == CommandErrorKind::Cancelled => Err(cancelled_failure()),
|
|
||||||
Ok(Err(error)) => {
|
|
||||||
rollback_after_failure(recovery_runner, applied, previous, error.message).await
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
rollback_after_failure(
|
|
||||||
recovery_runner,
|
|
||||||
applied,
|
|
||||||
previous,
|
|
||||||
"firewall apply transaction timed out".to_string(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn rollback_after_failure<R: FirewallCommandRunner>(
|
|
||||||
runner: &R,
|
|
||||||
applied: &mut AppliedState,
|
|
||||||
previous: AppliedPlan,
|
|
||||||
apply_error: String,
|
|
||||||
) -> Result<(), ReconcileFailure> {
|
|
||||||
let rollback = tokio::time::timeout(TRANSACTION_TIMEOUT, restore_plan(runner, &previous)).await;
|
|
||||||
let rollback_result = match rollback {
|
|
||||||
Ok(result) => result,
|
|
||||||
Err(_) => Err(CommandError::failed("firewall rollback timed out")),
|
|
||||||
};
|
|
||||||
match rollback_result {
|
|
||||||
Ok(()) => {
|
|
||||||
*applied = AppliedState::Known(previous);
|
|
||||||
Err(ReconcileFailure {
|
|
||||||
message: apply_error,
|
|
||||||
rollback_succeeded: Some(true),
|
|
||||||
cancelled: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Err(rollback_error) => {
|
|
||||||
*applied = AppliedState::Unknown;
|
|
||||||
if rollback_error.kind == CommandErrorKind::Cancelled {
|
|
||||||
return Err(cancelled_failure());
|
|
||||||
}
|
|
||||||
Err(ReconcileFailure {
|
|
||||||
message: format!("{apply_error}; rollback failed: {rollback_error}"),
|
|
||||||
rollback_succeeded: Some(false),
|
|
||||||
cancelled: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cancelled_failure() -> ReconcileFailure {
|
|
||||||
ReconcileFailure {
|
|
||||||
message: "firewall transaction cancelled for process shutdown".to_string(),
|
|
||||||
rollback_succeeded: None,
|
|
||||||
cancelled: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn resolve_target<R: FirewallCommandRunner>(
|
|
||||||
runner: &R,
|
|
||||||
desired: &DesiredPolicy,
|
|
||||||
previous: &AppliedPlan,
|
|
||||||
) -> Result<AppliedPlan, CommandError> {
|
|
||||||
let DesiredPolicy::Rules {
|
|
||||||
configured_backend,
|
|
||||||
v4,
|
|
||||||
v6,
|
|
||||||
} = desired
|
|
||||||
else {
|
|
||||||
return Ok(AppliedPlan::Empty);
|
|
||||||
};
|
|
||||||
if !runner.has_cap_net_admin() {
|
|
||||||
return Err(CommandError::failed(
|
|
||||||
"CAP_NET_ADMIN is required for conntrack firewall reconciliation",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let next_slot = previous.slot().map_or(ShadowSlot::A, ShadowSlot::other);
|
|
||||||
let iptables_available = (v4.is_empty() || iptables::family_available(runner, IpFamily::V4))
|
|
||||||
&& (v6.is_empty() || iptables::family_available(runner, IpFamily::V6));
|
|
||||||
match configured_backend {
|
|
||||||
ConntrackBackend::Nftables if nftables::available(runner) => Ok(AppliedPlan::Nftables {
|
|
||||||
slot: next_slot,
|
|
||||||
v4: v4.clone(),
|
|
||||||
v6: v6.clone(),
|
|
||||||
}),
|
|
||||||
ConntrackBackend::Iptables if iptables_available => Ok(AppliedPlan::Iptables {
|
|
||||||
slot: next_slot,
|
|
||||||
v4: v4.clone(),
|
|
||||||
v6: v6.clone(),
|
|
||||||
}),
|
|
||||||
ConntrackBackend::Auto if nftables::available(runner) => Ok(AppliedPlan::Nftables {
|
|
||||||
slot: next_slot,
|
|
||||||
v4: v4.clone(),
|
|
||||||
v6: v6.clone(),
|
|
||||||
}),
|
|
||||||
ConntrackBackend::Auto if iptables_available => Ok(AppliedPlan::Iptables {
|
|
||||||
slot: next_slot,
|
|
||||||
v4: v4.clone(),
|
|
||||||
v6: v6.clone(),
|
|
||||||
}),
|
|
||||||
backend => Err(CommandError::failed(format!(
|
|
||||||
"configured conntrack firewall backend {backend:?} is unavailable"
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn transition_plan<R: FirewallCommandRunner>(
|
|
||||||
runner: &R,
|
|
||||||
previous: &AppliedPlan,
|
|
||||||
target: &AppliedPlan,
|
|
||||||
) -> Result<(), CommandError> {
|
|
||||||
install_plan(runner, target).await?;
|
|
||||||
match (previous, target) {
|
|
||||||
(
|
|
||||||
AppliedPlan::Iptables {
|
|
||||||
v4: previous_v4,
|
|
||||||
v6: previous_v6,
|
|
||||||
..
|
|
||||||
},
|
|
||||||
AppliedPlan::Iptables { v4, v6, .. },
|
|
||||||
) => {
|
|
||||||
if !previous_v4.is_empty() && v4.is_empty() {
|
|
||||||
iptables::activate_family(runner, IpFamily::V4, None).await?;
|
|
||||||
}
|
|
||||||
if !previous_v6.is_empty() && v6.is_empty() {
|
|
||||||
iptables::activate_family(runner, IpFamily::V6, None).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(
|
|
||||||
AppliedPlan::Nftables {
|
|
||||||
slot: previous_slot,
|
|
||||||
..
|
|
||||||
},
|
|
||||||
AppliedPlan::Nftables { slot, .. },
|
|
||||||
) if previous_slot != slot => {
|
|
||||||
nftables::deactivate(runner, *previous_slot).await?;
|
|
||||||
}
|
|
||||||
(_, _) if previous != target => clear_plan(runner, previous).await?,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn install_plan<R: FirewallCommandRunner>(
|
|
||||||
runner: &R,
|
|
||||||
plan: &AppliedPlan,
|
|
||||||
) -> Result<(), CommandError> {
|
|
||||||
match plan {
|
|
||||||
AppliedPlan::Empty => Ok(()),
|
|
||||||
AppliedPlan::Iptables { slot, v4, v6 } => {
|
|
||||||
if !v4.is_empty() {
|
|
||||||
iptables::stage_family(runner, IpFamily::V4, *slot, v4).await?;
|
|
||||||
}
|
|
||||||
if !v6.is_empty() {
|
|
||||||
iptables::stage_family(runner, IpFamily::V6, *slot, v6).await?;
|
|
||||||
}
|
|
||||||
if !v4.is_empty() {
|
|
||||||
iptables::activate_family(runner, IpFamily::V4, Some(*slot)).await?;
|
|
||||||
}
|
|
||||||
if !v6.is_empty() {
|
|
||||||
iptables::activate_family(runner, IpFamily::V6, Some(*slot)).await?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
AppliedPlan::Nftables { slot, v4, v6 } => {
|
|
||||||
nftables::stage(runner, *slot, v4, v6).await?;
|
|
||||||
nftables::activate(runner, *slot).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn clear_plan<R: FirewallCommandRunner>(
|
|
||||||
runner: &R,
|
|
||||||
plan: &AppliedPlan,
|
|
||||||
) -> Result<(), CommandError> {
|
|
||||||
match plan {
|
|
||||||
AppliedPlan::Empty => Ok(()),
|
|
||||||
AppliedPlan::Iptables { v4, v6, .. } => {
|
|
||||||
if !v4.is_empty() {
|
|
||||||
iptables::activate_family(runner, IpFamily::V4, None).await?;
|
|
||||||
}
|
|
||||||
if !v6.is_empty() {
|
|
||||||
iptables::activate_family(runner, IpFamily::V6, None).await?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
AppliedPlan::Nftables { slot, .. } => nftables::deactivate(runner, *slot).await,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn restore_plan<R: FirewallCommandRunner>(
|
|
||||||
runner: &R,
|
|
||||||
plan: &AppliedPlan,
|
|
||||||
) -> Result<(), CommandError> {
|
|
||||||
recover_to_empty(runner).await?;
|
|
||||||
install_plan(runner, plan).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn recover_to_empty<R: FirewallCommandRunner>(
|
|
||||||
runner: &R,
|
|
||||||
) -> Result<(), CommandError> {
|
|
||||||
let nft_result = nftables::cleanup_all(runner).await;
|
|
||||||
let iptables_result = iptables::cleanup_all(runner).await;
|
|
||||||
match (nft_result, iptables_result) {
|
|
||||||
(Ok(()), Ok(())) => Ok(()),
|
|
||||||
(Err(first), Ok(())) | (Ok(()), Err(first)) => Err(first),
|
|
||||||
(Err(first), Err(second)) => Err(CommandError::failed(format!(
|
|
||||||
"{}; {}",
|
|
||||||
first.message, second.message
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+278
-27
@@ -4,20 +4,14 @@
|
|||||||
//! and privilege dropping for running telemt as a background service.
|
//! and privilege dropping for running telemt as a background service.
|
||||||
|
|
||||||
use std::fs::{self, File, OpenOptions};
|
use std::fs::{self, File, OpenOptions};
|
||||||
use std::io;
|
use std::io::{self, Read, Write};
|
||||||
use std::os::unix::fs::OpenOptionsExt;
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use nix::errno::Errno;
|
use nix::errno::Errno;
|
||||||
use nix::unistd::{self, ForkResult, Gid, Uid, chdir, close, fork, getpid, setsid};
|
use nix::fcntl::{Flock, FlockArg};
|
||||||
use tracing::info;
|
use nix::unistd::{self, ForkResult, Gid, Pid, Uid, chdir, close, fork, getpid, setsid};
|
||||||
|
use tracing::{debug, info, warn};
|
||||||
// PID file ownership and process-control helpers.
|
|
||||||
mod pid_file;
|
|
||||||
|
|
||||||
pub use pid_file::DaemonStatus;
|
|
||||||
#[allow(unused_imports)]
|
|
||||||
pub use pid_file::{PidFile, check_status, read_pid_file, signal_pid_file};
|
|
||||||
|
|
||||||
/// Default PID file location.
|
/// Default PID file location.
|
||||||
pub const DEFAULT_PID_FILE: &str = "/var/run/telemt.pid";
|
pub const DEFAULT_PID_FILE: &str = "/var/run/telemt.pid";
|
||||||
@@ -57,47 +51,36 @@ impl DaemonOptions {
|
|||||||
/// Error types for daemon operations.
|
/// Error types for daemon operations.
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum DaemonError {
|
pub enum DaemonError {
|
||||||
/// A daemonization fork failed.
|
|
||||||
#[error("fork failed: {0}")]
|
#[error("fork failed: {0}")]
|
||||||
ForkFailed(#[source] nix::Error),
|
ForkFailed(#[source] nix::Error),
|
||||||
|
|
||||||
/// Creation of the detached session failed.
|
|
||||||
#[error("setsid failed: {0}")]
|
#[error("setsid failed: {0}")]
|
||||||
SetsidFailed(#[source] nix::Error),
|
SetsidFailed(#[source] nix::Error),
|
||||||
|
|
||||||
/// Switching to the configured working directory failed.
|
|
||||||
#[error("chdir failed: {0}")]
|
#[error("chdir failed: {0}")]
|
||||||
ChdirFailed(#[source] nix::Error),
|
ChdirFailed(#[source] nix::Error),
|
||||||
|
|
||||||
/// Opening `/dev/null` for standard-stream redirection failed.
|
|
||||||
#[error("failed to open /dev/null: {0}")]
|
#[error("failed to open /dev/null: {0}")]
|
||||||
DevNullFailed(#[source] io::Error),
|
DevNullFailed(#[source] io::Error),
|
||||||
|
|
||||||
/// Redirecting a standard file descriptor failed.
|
|
||||||
#[error("failed to redirect stdio: {0}")]
|
#[error("failed to redirect stdio: {0}")]
|
||||||
RedirectFailed(#[source] nix::Error),
|
RedirectFailed(#[source] nix::Error),
|
||||||
|
|
||||||
/// A PID lifecycle operation failed.
|
|
||||||
#[error("PID file error: {0}")]
|
#[error("PID file error: {0}")]
|
||||||
PidFile(String),
|
PidFile(String),
|
||||||
|
|
||||||
/// Another process owns the daemon PID lifecycle.
|
|
||||||
#[error("another instance is already running (pid {0})")]
|
#[error("another instance is already running (pid {0})")]
|
||||||
AlreadyRunning(i32),
|
AlreadyRunning(i32),
|
||||||
|
|
||||||
/// The configured runtime user does not exist.
|
|
||||||
#[error("user '{0}' not found")]
|
#[error("user '{0}' not found")]
|
||||||
UserNotFound(String),
|
UserNotFound(String),
|
||||||
|
|
||||||
/// The configured runtime group does not exist.
|
|
||||||
#[error("group '{0}' not found")]
|
#[error("group '{0}' not found")]
|
||||||
GroupNotFound(String),
|
GroupNotFound(String),
|
||||||
|
|
||||||
/// Applying the configured runtime identity failed.
|
|
||||||
#[error("failed to set uid/gid: {0}")]
|
#[error("failed to set uid/gid: {0}")]
|
||||||
PrivilegeDrop(#[source] nix::Error),
|
PrivilegeDrop(#[source] nix::Error),
|
||||||
|
|
||||||
/// An underlying filesystem operation failed.
|
|
||||||
#[error("io error: {0}")]
|
#[error("io error: {0}")]
|
||||||
Io(#[from] io::Error),
|
Io(#[from] io::Error),
|
||||||
}
|
}
|
||||||
@@ -123,28 +106,38 @@ pub enum DaemonizeResult {
|
|||||||
/// Returns `DaemonizeResult::Parent` in the original parent (which should exit),
|
/// Returns `DaemonizeResult::Parent` in the original parent (which should exit),
|
||||||
/// or `DaemonizeResult::Child` in the final daemon child.
|
/// or `DaemonizeResult::Child` in the final daemon child.
|
||||||
pub fn daemonize(working_dir: Option<&Path>) -> Result<DaemonizeResult, DaemonError> {
|
pub fn daemonize(working_dir: Option<&Path>) -> Result<DaemonizeResult, DaemonError> {
|
||||||
|
// First fork
|
||||||
match unsafe { fork() } {
|
match unsafe { fork() } {
|
||||||
Ok(ForkResult::Parent { .. }) => {
|
Ok(ForkResult::Parent { .. }) => {
|
||||||
|
// Parent exits
|
||||||
return Ok(DaemonizeResult::Parent);
|
return Ok(DaemonizeResult::Parent);
|
||||||
}
|
}
|
||||||
Ok(ForkResult::Child) => {}
|
Ok(ForkResult::Child) => {
|
||||||
|
// Child continues
|
||||||
|
}
|
||||||
Err(e) => return Err(DaemonError::ForkFailed(e)),
|
Err(e) => return Err(DaemonError::ForkFailed(e)),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create new session, become session leader
|
||||||
setsid().map_err(DaemonError::SetsidFailed)?;
|
setsid().map_err(DaemonError::SetsidFailed)?;
|
||||||
|
|
||||||
// Second fork to ensure we can never acquire a controlling terminal
|
// Second fork to ensure we can never acquire a controlling terminal
|
||||||
match unsafe { fork() } {
|
match unsafe { fork() } {
|
||||||
Ok(ForkResult::Parent { .. }) => {
|
Ok(ForkResult::Parent { .. }) => {
|
||||||
|
// Intermediate parent exits
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
Ok(ForkResult::Child) => {}
|
Ok(ForkResult::Child) => {
|
||||||
|
// Final daemon child continues
|
||||||
|
}
|
||||||
Err(e) => return Err(DaemonError::ForkFailed(e)),
|
Err(e) => return Err(DaemonError::ForkFailed(e)),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Change working directory
|
||||||
let target_dir = working_dir.unwrap_or(Path::new("/"));
|
let target_dir = working_dir.unwrap_or(Path::new("/"));
|
||||||
chdir(target_dir).map_err(DaemonError::ChdirFailed)?;
|
chdir(target_dir).map_err(DaemonError::ChdirFailed)?;
|
||||||
|
|
||||||
|
// Redirect stdin, stdout, stderr to /dev/null
|
||||||
redirect_stdio_to_devnull()?;
|
redirect_stdio_to_devnull()?;
|
||||||
|
|
||||||
Ok(DaemonizeResult::Child)
|
Ok(DaemonizeResult::Child)
|
||||||
@@ -163,17 +156,21 @@ fn redirect_stdio_to_devnull() -> Result<(), DaemonError> {
|
|||||||
// Use libc::dup2 directly for redirecting standard file descriptors
|
// Use libc::dup2 directly for redirecting standard file descriptors
|
||||||
// nix 0.31's dup2 requires OwnedFd which doesn't work well with stdio fds
|
// nix 0.31's dup2 requires OwnedFd which doesn't work well with stdio fds
|
||||||
unsafe {
|
unsafe {
|
||||||
|
// Redirect stdin (fd 0)
|
||||||
if libc::dup2(devnull_fd, 0) < 0 {
|
if libc::dup2(devnull_fd, 0) < 0 {
|
||||||
return Err(DaemonError::RedirectFailed(Errno::last()));
|
return Err(DaemonError::RedirectFailed(Errno::last()));
|
||||||
}
|
}
|
||||||
|
// Redirect stdout (fd 1)
|
||||||
if libc::dup2(devnull_fd, 1) < 0 {
|
if libc::dup2(devnull_fd, 1) < 0 {
|
||||||
return Err(DaemonError::RedirectFailed(Errno::last()));
|
return Err(DaemonError::RedirectFailed(Errno::last()));
|
||||||
}
|
}
|
||||||
|
// Redirect stderr (fd 2)
|
||||||
if libc::dup2(devnull_fd, 2) < 0 {
|
if libc::dup2(devnull_fd, 2) < 0 {
|
||||||
return Err(DaemonError::RedirectFailed(Errno::last()));
|
return Err(DaemonError::RedirectFailed(Errno::last()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close original devnull fd if it's not one of the standard fds
|
||||||
if devnull_fd > 2 {
|
if devnull_fd > 2 {
|
||||||
let _ = close(devnull_fd);
|
let _ = close(devnull_fd);
|
||||||
}
|
}
|
||||||
@@ -181,6 +178,166 @@ fn redirect_stdio_to_devnull() -> Result<(), DaemonError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// PID file manager with flock-based locking.
|
||||||
|
pub struct PidFile {
|
||||||
|
path: PathBuf,
|
||||||
|
file: Option<File>,
|
||||||
|
locked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PidFile {
|
||||||
|
/// Creates a new PID file manager for the given path.
|
||||||
|
pub fn new<P: AsRef<Path>>(path: P) -> Self {
|
||||||
|
Self {
|
||||||
|
path: path.as_ref().to_path_buf(),
|
||||||
|
file: None,
|
||||||
|
locked: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if another instance is already running.
|
||||||
|
///
|
||||||
|
/// Returns the PID of the running instance if one exists.
|
||||||
|
pub fn check_running(&self) -> Result<Option<i32>, DaemonError> {
|
||||||
|
if !self.path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to read existing PID
|
||||||
|
let mut contents = String::new();
|
||||||
|
File::open(&self.path)
|
||||||
|
.and_then(|mut f| f.read_to_string(&mut contents))
|
||||||
|
.map_err(|e| {
|
||||||
|
DaemonError::PidFile(format!("cannot read {}: {}", self.path.display(), e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let pid: i32 = contents
|
||||||
|
.trim()
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| DaemonError::PidFile(format!("invalid PID in {}", self.path.display())))?;
|
||||||
|
|
||||||
|
// Check if process is still running
|
||||||
|
if is_process_running(pid) {
|
||||||
|
Ok(Some(pid))
|
||||||
|
} else {
|
||||||
|
// Stale PID file
|
||||||
|
debug!(pid, path = %self.path.display(), "Removing stale PID file");
|
||||||
|
let _ = fs::remove_file(&self.path);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acquires the PID file lock and writes the current PID.
|
||||||
|
///
|
||||||
|
/// Fails if another instance is already running.
|
||||||
|
pub fn acquire(&mut self) -> Result<(), DaemonError> {
|
||||||
|
// Check for running instance first
|
||||||
|
if let Some(pid) = self.check_running()? {
|
||||||
|
return Err(DaemonError::AlreadyRunning(pid));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure parent directory exists
|
||||||
|
if let Some(parent) = self.path.parent() {
|
||||||
|
if !parent.exists() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| {
|
||||||
|
DaemonError::PidFile(format!(
|
||||||
|
"cannot create directory {}: {}",
|
||||||
|
parent.display(),
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open/create PID file with exclusive lock
|
||||||
|
let file = OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
|
.mode(0o644)
|
||||||
|
.open(&self.path)
|
||||||
|
.map_err(|e| {
|
||||||
|
DaemonError::PidFile(format!("cannot open {}: {}", self.path.display(), e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Try to acquire exclusive lock (non-blocking)
|
||||||
|
let flock = Flock::lock(file, FlockArg::LockExclusiveNonblock).map_err(|(_, errno)| {
|
||||||
|
// Check if another instance grabbed the lock
|
||||||
|
if let Some(pid) = self.check_running().ok().flatten() {
|
||||||
|
DaemonError::AlreadyRunning(pid)
|
||||||
|
} else {
|
||||||
|
DaemonError::PidFile(format!("cannot lock {}: {}", self.path.display(), errno))
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Write our PID
|
||||||
|
let pid = getpid();
|
||||||
|
let mut file = flock
|
||||||
|
.unlock()
|
||||||
|
.map_err(|(_, errno)| DaemonError::PidFile(format!("unlock failed: {}", errno)))?;
|
||||||
|
|
||||||
|
writeln!(file, "{}", pid).map_err(|e| {
|
||||||
|
DaemonError::PidFile(format!(
|
||||||
|
"cannot write PID to {}: {}",
|
||||||
|
self.path.display(),
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Re-acquire lock and keep it
|
||||||
|
let flock = Flock::lock(file, FlockArg::LockExclusiveNonblock).map_err(|(_, errno)| {
|
||||||
|
DaemonError::PidFile(format!("cannot re-lock {}: {}", self.path.display(), errno))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
self.file = Some(flock.unlock().map_err(|(_, errno)| {
|
||||||
|
DaemonError::PidFile(format!("unlock for storage failed: {}", errno))
|
||||||
|
})?);
|
||||||
|
self.locked = true;
|
||||||
|
|
||||||
|
info!(pid = pid.as_raw(), path = %self.path.display(), "PID file created");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Releases the PID file lock and removes the file.
|
||||||
|
pub fn release(&mut self) -> Result<(), DaemonError> {
|
||||||
|
if let Some(file) = self.file.take() {
|
||||||
|
drop(file);
|
||||||
|
}
|
||||||
|
self.locked = false;
|
||||||
|
|
||||||
|
if self.path.exists() {
|
||||||
|
fs::remove_file(&self.path).map_err(|e| {
|
||||||
|
DaemonError::PidFile(format!("cannot remove {}: {}", self.path.display(), e))
|
||||||
|
})?;
|
||||||
|
debug!(path = %self.path.display(), "PID file removed");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the path to this PID file.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn path(&self) -> &Path {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for PidFile {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if self.locked {
|
||||||
|
if let Err(e) = self.release() {
|
||||||
|
warn!(error = %e, "Failed to clean up PID file on drop");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if a process with the given PID is running.
|
||||||
|
fn is_process_running(pid: i32) -> bool {
|
||||||
|
// kill(pid, 0) checks if process exists without sending a signal
|
||||||
|
nix::sys::signal::kill(Pid::from_raw(pid), None).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
// macOS gates nix::unistd::setgroups differently in the current dependency set,
|
// macOS gates nix::unistd::setgroups differently in the current dependency set,
|
||||||
// so call libc directly there while preserving the original nix path elsewhere.
|
// so call libc directly there while preserving the original nix path elsewhere.
|
||||||
fn set_supplementary_groups(gid: Gid) -> Result<(), nix::Error> {
|
fn set_supplementary_groups(gid: Gid) -> Result<(), nix::Error> {
|
||||||
@@ -226,12 +383,10 @@ pub fn drop_privileges(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (target_uid.is_some() || target_gid.is_some())
|
if (target_uid.is_some() || target_gid.is_some())
|
||||||
&& let Some(pid_file) = pid_file
|
&& let Some(file) = pid_file.and_then(|pid| pid.file.as_ref())
|
||||||
{
|
{
|
||||||
for file in pid_file.ownership_file_handles().into_iter().flatten() {
|
|
||||||
unistd::fchown(file, target_uid, target_gid).map_err(DaemonError::PrivilegeDrop)?;
|
unistd::fchown(file, target_uid, target_gid).map_err(DaemonError::PrivilegeDrop)?;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(gid) = target_gid {
|
if let Some(gid) = target_gid {
|
||||||
unistd::setgid(gid).map_err(DaemonError::PrivilegeDrop)?;
|
unistd::setgid(gid).map_err(DaemonError::PrivilegeDrop)?;
|
||||||
@@ -246,7 +401,7 @@ pub fn drop_privileges(
|
|||||||
if uid.as_raw() != 0
|
if uid.as_raw() != 0
|
||||||
&& let Some(pid) = pid_file
|
&& let Some(pid) = pid_file
|
||||||
{
|
{
|
||||||
let parent = pid.path().parent().unwrap_or(Path::new("."));
|
let parent = pid.path.parent().unwrap_or(Path::new("."));
|
||||||
let probe_path = parent.join(format!(
|
let probe_path = parent.join(format!(
|
||||||
".telemt_pid_probe_{}_{}",
|
".telemt_pid_probe_{}_{}",
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
@@ -281,6 +436,7 @@ pub fn drop_privileges(
|
|||||||
|
|
||||||
/// Looks up a user by name and returns their UID.
|
/// Looks up a user by name and returns their UID.
|
||||||
fn lookup_user(name: &str) -> Result<Uid, DaemonError> {
|
fn lookup_user(name: &str) -> Result<Uid, DaemonError> {
|
||||||
|
// Use libc getpwnam
|
||||||
let c_name =
|
let c_name =
|
||||||
std::ffi::CString::new(name).map_err(|_| DaemonError::UserNotFound(name.to_string()))?;
|
std::ffi::CString::new(name).map_err(|_| DaemonError::UserNotFound(name.to_string()))?;
|
||||||
|
|
||||||
@@ -324,6 +480,76 @@ fn lookup_group(name: &str) -> Result<Gid, DaemonError> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reads PID from a PID file.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn read_pid_file<P: AsRef<Path>>(path: P) -> Result<i32, DaemonError> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
let mut contents = String::new();
|
||||||
|
File::open(path)
|
||||||
|
.and_then(|mut f| f.read_to_string(&mut contents))
|
||||||
|
.map_err(|e| DaemonError::PidFile(format!("cannot read {}: {}", path.display(), e)))?;
|
||||||
|
|
||||||
|
contents
|
||||||
|
.trim()
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| DaemonError::PidFile(format!("invalid PID in {}", path.display())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends a signal to the process specified in a PID file.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn signal_pid_file<P: AsRef<Path>>(
|
||||||
|
path: P,
|
||||||
|
signal: nix::sys::signal::Signal,
|
||||||
|
) -> Result<(), DaemonError> {
|
||||||
|
let pid = read_pid_file(&path)?;
|
||||||
|
|
||||||
|
if !is_process_running(pid) {
|
||||||
|
return Err(DaemonError::PidFile(format!(
|
||||||
|
"process {} from {} is not running",
|
||||||
|
pid,
|
||||||
|
path.as_ref().display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
nix::sys::signal::kill(Pid::from_raw(pid), signal)
|
||||||
|
.map_err(|e| DaemonError::PidFile(format!("cannot signal process {}: {}", pid, e)))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the status of the daemon based on PID file.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum DaemonStatus {
|
||||||
|
/// Daemon is running with the given PID.
|
||||||
|
Running(i32),
|
||||||
|
/// PID file exists but process is not running.
|
||||||
|
Stale(i32),
|
||||||
|
/// No PID file exists.
|
||||||
|
NotRunning,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks the daemon status from a PID file.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn check_status<P: AsRef<Path>>(path: P) -> DaemonStatus {
|
||||||
|
let path = path.as_ref();
|
||||||
|
|
||||||
|
if !path.exists() {
|
||||||
|
return DaemonStatus::NotRunning;
|
||||||
|
}
|
||||||
|
|
||||||
|
match read_pid_file(path) {
|
||||||
|
Ok(pid) => {
|
||||||
|
if is_process_running(pid) {
|
||||||
|
DaemonStatus::Running(pid)
|
||||||
|
} else {
|
||||||
|
DaemonStatus::Stale(pid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => DaemonStatus::NotRunning,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -345,4 +571,29 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert!(!opts.should_daemonize());
|
assert!(!opts.should_daemonize());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_check_status_not_running() {
|
||||||
|
let path = "/tmp/telemt_test_nonexistent.pid";
|
||||||
|
assert_eq!(check_status(path), DaemonStatus::NotRunning);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pid_file_basic() {
|
||||||
|
let path = "/tmp/telemt_test_pidfile.pid";
|
||||||
|
let _ = fs::remove_file(path);
|
||||||
|
|
||||||
|
let mut pf = PidFile::new(path);
|
||||||
|
assert!(pf.check_running().unwrap().is_none());
|
||||||
|
|
||||||
|
pf.acquire().unwrap();
|
||||||
|
assert!(Path::new(path).exists());
|
||||||
|
|
||||||
|
// Read it back
|
||||||
|
let pid = read_pid_file(path).unwrap();
|
||||||
|
assert_eq!(pid, std::process::id() as i32);
|
||||||
|
|
||||||
|
pf.release().unwrap();
|
||||||
|
assert!(!Path::new(path).exists());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,511 +0,0 @@
|
|||||||
use std::ffi::OsStr;
|
|
||||||
use std::fs::{self, File};
|
|
||||||
use std::io::{self, ErrorKind, Read, Write};
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
use std::os::fd::{FromRawFd, OwnedFd};
|
|
||||||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use nix::fcntl::{Flock, FlockArg, OFlag, openat};
|
|
||||||
use nix::sys::stat::Mode;
|
|
||||||
use nix::unistd::{Pid, UnlinkatFlags, getpid, unlinkat};
|
|
||||||
use tracing::{debug, info, warn};
|
|
||||||
|
|
||||||
use super::DaemonError;
|
|
||||||
use crate::util::secure_fs::AnchoredPath;
|
|
||||||
|
|
||||||
/// PID file manager backed by a persistent sibling lock file.
|
|
||||||
pub struct PidFile {
|
|
||||||
path: PathBuf,
|
|
||||||
lock_path: PathBuf,
|
|
||||||
pid_file: Option<File>,
|
|
||||||
pid_identity: Option<FileIdentity>,
|
|
||||||
lock_file: Option<Flock<File>>,
|
|
||||||
anchor: Option<AnchoredPath>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
struct FileIdentity {
|
|
||||||
device: u64,
|
|
||||||
inode: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FileIdentity {
|
|
||||||
fn from_metadata(metadata: &fs::Metadata) -> Self {
|
|
||||||
Self {
|
|
||||||
device: metadata.dev(),
|
|
||||||
inode: metadata.ino(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PidFile {
|
|
||||||
/// Creates a new PID file manager for the given path.
|
|
||||||
pub fn new<P: AsRef<Path>>(path: P) -> Self {
|
|
||||||
let path = normalize_pid_path(path.as_ref());
|
|
||||||
let lock_path = sibling_lock_path(&path);
|
|
||||||
Self {
|
|
||||||
path,
|
|
||||||
lock_path,
|
|
||||||
pid_file: None,
|
|
||||||
pid_identity: None,
|
|
||||||
lock_file: None,
|
|
||||||
anchor: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks whether the PID file names a running process without modifying either file.
|
|
||||||
pub fn check_running(&self) -> Result<Option<i32>, DaemonError> {
|
|
||||||
let Some(pid) = read_pid_file_if_exists(&self.path)? else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
Ok(is_process_running(pid).then_some(pid))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Acquires the persistent sibling lock and writes the current PID.
|
|
||||||
///
|
|
||||||
/// Fails if another owner holds the lock or the existing PID names a running process.
|
|
||||||
pub fn acquire(&mut self) -> Result<(), DaemonError> {
|
|
||||||
let anchor =
|
|
||||||
AnchoredPath::open_trusted_parent_or_create(&self.path, 0o755).map_err(|error| {
|
|
||||||
DaemonError::PidFile(format!(
|
|
||||||
"cannot open trusted parent for {}: {}",
|
|
||||||
self.path.display(),
|
|
||||||
error
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
let lock_name = self.lock_path.file_name().ok_or_else(|| {
|
|
||||||
DaemonError::PidFile(format!(
|
|
||||||
"lock path {} has no file name",
|
|
||||||
self.lock_path.display()
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
let lock_file = open_file_at(
|
|
||||||
&anchor,
|
|
||||||
lock_name,
|
|
||||||
OFlag::O_RDWR | OFlag::O_CREAT | OFlag::O_CLOEXEC | OFlag::O_NOFOLLOW,
|
|
||||||
0o644,
|
|
||||||
)
|
|
||||||
.map_err(|error| {
|
|
||||||
DaemonError::PidFile(format!(
|
|
||||||
"cannot open lock file {}: {}",
|
|
||||||
self.lock_path.display(),
|
|
||||||
error
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
validate_regular_single_link(&lock_file, &self.lock_path)?;
|
|
||||||
let lock_file =
|
|
||||||
Flock::lock(lock_file, FlockArg::LockExclusiveNonblock).map_err(|(_, errno)| {
|
|
||||||
if let Some(pid) = read_pid_file_at(&anchor, &self.path)
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
.filter(|pid| is_process_running(*pid))
|
|
||||||
{
|
|
||||||
DaemonError::AlreadyRunning(pid)
|
|
||||||
} else {
|
|
||||||
DaemonError::PidFile(format!(
|
|
||||||
"cannot lock {}: {}",
|
|
||||||
self.lock_path.display(),
|
|
||||||
errno
|
|
||||||
))
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if let Some(pid) = read_pid_file_at(&anchor, &self.path)?
|
|
||||||
&& is_process_running(pid)
|
|
||||||
{
|
|
||||||
return Err(DaemonError::AlreadyRunning(pid));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut pid_file = open_file_at(
|
|
||||||
&anchor,
|
|
||||||
anchor.name(),
|
|
||||||
OFlag::O_RDWR | OFlag::O_CREAT | OFlag::O_CLOEXEC | OFlag::O_NOFOLLOW,
|
|
||||||
0o644,
|
|
||||||
)
|
|
||||||
.map_err(|error| {
|
|
||||||
DaemonError::PidFile(format!("cannot open {}: {}", self.path.display(), error))
|
|
||||||
})?;
|
|
||||||
let pid_metadata = validate_regular_single_link(&pid_file, &self.path)?;
|
|
||||||
let pid_identity = FileIdentity::from_metadata(&pid_metadata);
|
|
||||||
// Validate the opened inode before modifying it so a hard-link substitution
|
|
||||||
// cannot turn PID publication into truncation of an unrelated file.
|
|
||||||
pid_file.set_len(0).map_err(|error| {
|
|
||||||
DaemonError::PidFile(format!(
|
|
||||||
"cannot truncate {}: {}",
|
|
||||||
self.path.display(),
|
|
||||||
error
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
let pid = getpid();
|
|
||||||
writeln!(pid_file, "{}", pid).map_err(|error| {
|
|
||||||
DaemonError::PidFile(format!(
|
|
||||||
"cannot write PID to {}: {}",
|
|
||||||
self.path.display(),
|
|
||||||
error
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
pid_file.sync_data().map_err(|error| {
|
|
||||||
DaemonError::PidFile(format!(
|
|
||||||
"cannot sync PID file {}: {}",
|
|
||||||
self.path.display(),
|
|
||||||
error
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
self.pid_file = Some(pid_file);
|
|
||||||
self.pid_identity = Some(pid_identity);
|
|
||||||
self.lock_file = Some(lock_file);
|
|
||||||
self.anchor = Some(anchor);
|
|
||||||
info!(pid = pid.as_raw(), path = %self.path.display(), "PID file created");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Removes the PID file while retaining exclusive lock ownership until cleanup completes.
|
|
||||||
pub fn release(&mut self) -> Result<(), DaemonError> {
|
|
||||||
if self.lock_file.is_none() {
|
|
||||||
self.pid_file = None;
|
|
||||||
self.pid_identity = None;
|
|
||||||
self.anchor = None;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let removal = match self.anchor.as_ref() {
|
|
||||||
Some(anchor) => remove_owned_pid_file(anchor, &self.path, self.pid_identity),
|
|
||||||
None => Err(DaemonError::PidFile(
|
|
||||||
"PID file lock is held without a directory anchor".to_string(),
|
|
||||||
)),
|
|
||||||
};
|
|
||||||
self.pid_file = None;
|
|
||||||
self.pid_identity = None;
|
|
||||||
self.lock_file = None;
|
|
||||||
self.anchor = None;
|
|
||||||
removal?;
|
|
||||||
debug!(path = %self.path.display(), "PID file removed");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the path to this PID file.
|
|
||||||
pub fn path(&self) -> &Path {
|
|
||||||
&self.path
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns open files whose ownership must follow the target runtime identity.
|
|
||||||
pub(super) fn ownership_file_handles(&self) -> [Option<&File>; 2] {
|
|
||||||
[self.pid_file.as_ref(), self.lock_file.as_deref()]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for PidFile {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
if self.lock_file.is_some()
|
|
||||||
&& let Err(error) = self.release()
|
|
||||||
{
|
|
||||||
warn!(error = %error, "Failed to clean up PID file on drop");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sibling_lock_path(path: &Path) -> PathBuf {
|
|
||||||
let mut lock_path = path.as_os_str().to_os_string();
|
|
||||||
lock_path.push(".lock");
|
|
||||||
lock_path.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_pid_path(path: &Path) -> PathBuf {
|
|
||||||
let legacy_run = Path::new("/var/run");
|
|
||||||
let Ok(remainder) = path.strip_prefix(legacy_run) else {
|
|
||||||
return path.to_path_buf();
|
|
||||||
};
|
|
||||||
let Ok(var_metadata) = fs::metadata("/var") else {
|
|
||||||
return path.to_path_buf();
|
|
||||||
};
|
|
||||||
let Ok(link_metadata) = fs::symlink_metadata(legacy_run) else {
|
|
||||||
return path.to_path_buf();
|
|
||||||
};
|
|
||||||
let Ok(target) = fs::read_link(legacy_run) else {
|
|
||||||
return path.to_path_buf();
|
|
||||||
};
|
|
||||||
let trusted_var = var_metadata.is_dir()
|
|
||||||
&& var_metadata.uid() == 0
|
|
||||||
&& var_metadata.permissions().mode() & 0o022 == 0;
|
|
||||||
let trusted_alias = link_metadata.file_type().is_symlink()
|
|
||||||
&& link_metadata.uid() == 0
|
|
||||||
&& (target == Path::new("/run") || target == Path::new("../run"));
|
|
||||||
if trusted_var && trusted_alias {
|
|
||||||
Path::new("/run").join(remainder)
|
|
||||||
} else {
|
|
||||||
path.to_path_buf()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn open_file_at(anchor: &AnchoredPath, name: &OsStr, flags: OFlag, mode: u32) -> io::Result<File> {
|
|
||||||
let descriptor = openat(anchor.parent(), name, flags, Mode::from_bits_truncate(mode))
|
|
||||||
.map_err(|error| io::Error::from_raw_os_error(error as i32))?;
|
|
||||||
Ok(File::from(descriptor))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_pid_file_if_exists(path: &Path) -> Result<Option<i32>, DaemonError> {
|
|
||||||
let anchor = match AnchoredPath::open_trusted_parent(path) {
|
|
||||||
Ok(anchor) => anchor,
|
|
||||||
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
|
|
||||||
Err(error) => {
|
|
||||||
return Err(DaemonError::PidFile(format!(
|
|
||||||
"cannot open trusted parent for {}: {}",
|
|
||||||
path.display(),
|
|
||||||
error
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
read_pid_file_at(&anchor, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_pid_file_at(anchor: &AnchoredPath, path: &Path) -> Result<Option<i32>, DaemonError> {
|
|
||||||
let mut file = match open_file_at(
|
|
||||||
anchor,
|
|
||||||
anchor.name(),
|
|
||||||
OFlag::O_RDONLY | OFlag::O_CLOEXEC | OFlag::O_NOFOLLOW,
|
|
||||||
0,
|
|
||||||
) {
|
|
||||||
Ok(file) => file,
|
|
||||||
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
|
|
||||||
Err(error) => {
|
|
||||||
return Err(DaemonError::PidFile(format!(
|
|
||||||
"cannot read {}: {}",
|
|
||||||
path.display(),
|
|
||||||
error
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let metadata = validate_regular_single_link(&file, path)?;
|
|
||||||
if metadata.len() > 64 {
|
|
||||||
return Err(DaemonError::PidFile(format!(
|
|
||||||
"invalid PID in {}",
|
|
||||||
path.display()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let mut contents = String::new();
|
|
||||||
file.read_to_string(&mut contents).map_err(|error| {
|
|
||||||
DaemonError::PidFile(format!("cannot read {}: {}", path.display(), error))
|
|
||||||
})?;
|
|
||||||
let pid: i32 = contents
|
|
||||||
.trim()
|
|
||||||
.parse()
|
|
||||||
.map_err(|_| DaemonError::PidFile(format!("invalid PID in {}", path.display())))?;
|
|
||||||
if pid <= 1 {
|
|
||||||
return Err(DaemonError::PidFile(format!(
|
|
||||||
"invalid PID in {}",
|
|
||||||
path.display()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(Some(pid))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn remove_owned_pid_file(
|
|
||||||
anchor: &AnchoredPath,
|
|
||||||
path: &Path,
|
|
||||||
expected: Option<FileIdentity>,
|
|
||||||
) -> Result<(), DaemonError> {
|
|
||||||
let file = match open_file_at(
|
|
||||||
anchor,
|
|
||||||
anchor.name(),
|
|
||||||
OFlag::O_RDONLY | OFlag::O_CLOEXEC | OFlag::O_NOFOLLOW,
|
|
||||||
0,
|
|
||||||
) {
|
|
||||||
Ok(file) => file,
|
|
||||||
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
|
|
||||||
Err(error) => {
|
|
||||||
return Err(DaemonError::PidFile(format!(
|
|
||||||
"cannot inspect {} before removal: {}",
|
|
||||||
path.display(),
|
|
||||||
error
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let metadata = validate_regular_single_link(&file, path)?;
|
|
||||||
if expected != Some(FileIdentity::from_metadata(&metadata)) {
|
|
||||||
return Err(DaemonError::PidFile(format!(
|
|
||||||
"refusing to remove replaced PID file {}",
|
|
||||||
path.display()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
drop(file);
|
|
||||||
unlinkat(anchor.parent(), anchor.name(), UnlinkatFlags::NoRemoveDir).map_err(|error| {
|
|
||||||
DaemonError::PidFile(format!(
|
|
||||||
"cannot remove {}: {}",
|
|
||||||
path.display(),
|
|
||||||
io::Error::from_raw_os_error(error as i32)
|
|
||||||
))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_regular_single_link(file: &File, path: &Path) -> Result<fs::Metadata, DaemonError> {
|
|
||||||
let metadata = file.metadata().map_err(|error| {
|
|
||||||
DaemonError::PidFile(format!("cannot inspect {}: {}", path.display(), error))
|
|
||||||
})?;
|
|
||||||
if !metadata.is_file() || metadata.nlink() != 1 {
|
|
||||||
return Err(DaemonError::PidFile(format!(
|
|
||||||
"{} must be a regular file with one directory entry",
|
|
||||||
path.display()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(metadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads a PID from a PID file.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn read_pid_file<P: AsRef<Path>>(path: P) -> Result<i32, DaemonError> {
|
|
||||||
let path = normalize_pid_path(path.as_ref());
|
|
||||||
read_pid_file_if_exists(&path)?.ok_or_else(|| {
|
|
||||||
DaemonError::PidFile(format!(
|
|
||||||
"cannot read {}: file does not exist",
|
|
||||||
path.display()
|
|
||||||
))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sends a signal to the process specified in a PID file.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn signal_pid_file<P: AsRef<Path>>(
|
|
||||||
path: P,
|
|
||||||
signal: nix::sys::signal::Signal,
|
|
||||||
) -> Result<(), DaemonError> {
|
|
||||||
let path = normalize_pid_path(path.as_ref());
|
|
||||||
let pid = read_pid_file(&path)?;
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
let pidfd = open_pidfd(pid)?;
|
|
||||||
if !daemon_lock_is_held(&path)? {
|
|
||||||
return Err(DaemonError::PidFile(format!(
|
|
||||||
"refusing to signal unlocked or stale PID file {}",
|
|
||||||
path.display()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
return signal_pidfd(&pidfd, pid, signal);
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
nix::sys::signal::kill(Pid::from_raw(pid), signal)
|
|
||||||
.map_err(|error| DaemonError::PidFile(format!("cannot signal process {}: {}", pid, error)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Daemon state derived from the PID file.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum DaemonStatus {
|
|
||||||
/// Daemon is running with the given PID.
|
|
||||||
Running(i32),
|
|
||||||
/// PID file exists but the named process is not running.
|
|
||||||
Stale(i32),
|
|
||||||
/// No readable PID file exists.
|
|
||||||
NotRunning,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks daemon status without modifying the PID or lock file.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn check_status<P: AsRef<Path>>(path: P) -> DaemonStatus {
|
|
||||||
let path = normalize_pid_path(path.as_ref());
|
|
||||||
match read_pid_file_if_exists(&path) {
|
|
||||||
Ok(Some(pid)) if daemon_lock_is_held(&path).unwrap_or(false) && is_process_running(pid) => {
|
|
||||||
DaemonStatus::Running(pid)
|
|
||||||
}
|
|
||||||
Ok(Some(pid)) => DaemonStatus::Stale(pid),
|
|
||||||
Ok(None) | Err(_) => DaemonStatus::NotRunning,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn daemon_lock_is_held(path: &Path) -> Result<bool, DaemonError> {
|
|
||||||
let lock_path = sibling_lock_path(path);
|
|
||||||
let anchor = match AnchoredPath::open_trusted_parent(path) {
|
|
||||||
Ok(anchor) => anchor,
|
|
||||||
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(false),
|
|
||||||
Err(error) => {
|
|
||||||
return Err(DaemonError::PidFile(format!(
|
|
||||||
"cannot open trusted parent for {}: {}",
|
|
||||||
path.display(),
|
|
||||||
error
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let lock_name = lock_path.file_name().ok_or_else(|| {
|
|
||||||
DaemonError::PidFile(format!(
|
|
||||||
"lock path {} has no file name",
|
|
||||||
lock_path.display()
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
let file = match open_file_at(
|
|
||||||
&anchor,
|
|
||||||
lock_name,
|
|
||||||
OFlag::O_RDWR | OFlag::O_CLOEXEC | OFlag::O_NOFOLLOW,
|
|
||||||
0,
|
|
||||||
) {
|
|
||||||
Ok(file) => file,
|
|
||||||
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(false),
|
|
||||||
Err(error) => {
|
|
||||||
return Err(DaemonError::PidFile(format!(
|
|
||||||
"cannot inspect lock {}: {}",
|
|
||||||
lock_path.display(),
|
|
||||||
error
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
validate_regular_single_link(&file, &lock_path)?;
|
|
||||||
match Flock::lock(file, FlockArg::LockExclusiveNonblock) {
|
|
||||||
Ok(_available) => Ok(false),
|
|
||||||
Err((_file, nix::errno::Errno::EWOULDBLOCK)) => Ok(true),
|
|
||||||
Err((_file, error)) => Err(DaemonError::PidFile(format!(
|
|
||||||
"cannot inspect lock ownership for {}: {}",
|
|
||||||
lock_path.display(),
|
|
||||||
error
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
fn open_pidfd(pid: i32) -> Result<OwnedFd, DaemonError> {
|
|
||||||
// SAFETY: `pidfd_open` receives a validated positive PID and no pointer arguments.
|
|
||||||
let descriptor = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) };
|
|
||||||
if descriptor < 0 {
|
|
||||||
return Err(DaemonError::PidFile(format!(
|
|
||||||
"cannot open stable process handle for {}: {}",
|
|
||||||
pid,
|
|
||||||
std::io::Error::last_os_error()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
// SAFETY: a successful `pidfd_open` returns one newly owned descriptor.
|
|
||||||
Ok(unsafe { OwnedFd::from_raw_fd(descriptor as i32) })
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
fn signal_pidfd(
|
|
||||||
pidfd: &OwnedFd,
|
|
||||||
pid: i32,
|
|
||||||
signal: nix::sys::signal::Signal,
|
|
||||||
) -> Result<(), DaemonError> {
|
|
||||||
use std::os::fd::AsRawFd;
|
|
||||||
|
|
||||||
// SAFETY: the pidfd is owned and valid, and both optional pointer arguments are null.
|
|
||||||
let result = unsafe {
|
|
||||||
libc::syscall(
|
|
||||||
libc::SYS_pidfd_send_signal,
|
|
||||||
pidfd.as_raw_fd(),
|
|
||||||
signal as libc::c_int,
|
|
||||||
std::ptr::null::<libc::siginfo_t>(),
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
if result == 0 {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(DaemonError::PidFile(format!(
|
|
||||||
"cannot signal process {} through stable handle: {}",
|
|
||||||
pid,
|
|
||||||
std::io::Error::last_os_error()
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_process_running(pid: i32) -> bool {
|
|
||||||
nix::sys::signal::kill(Pid::from_raw(pid), None).is_ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests;
|
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
use std::os::unix::fs::{MetadataExt, symlink};
|
|
||||||
use std::process::{Child, Command, Stdio};
|
|
||||||
use std::thread;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
const HELPER_PID_PATH: &str = "TELEMT_PID_LOCK_HELPER_PATH";
|
|
||||||
const HELPER_READY_PATH: &str = "TELEMT_PID_LOCK_HELPER_READY";
|
|
||||||
const HELPER_STOP_PATH: &str = "TELEMT_PID_LOCK_HELPER_STOP";
|
|
||||||
|
|
||||||
fn wait_for_path(path: &Path, timeout: Duration) -> bool {
|
|
||||||
let deadline = Instant::now() + timeout;
|
|
||||||
while Instant::now() < deadline {
|
|
||||||
if path.exists() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
thread::sleep(Duration::from_millis(10));
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
fn wait_for_child(child: &mut Child, timeout: Duration) -> Option<std::process::ExitStatus> {
|
|
||||||
let deadline = Instant::now() + timeout;
|
|
||||||
while Instant::now() < deadline {
|
|
||||||
if let Some(status) = child.try_wait().unwrap() {
|
|
||||||
return Some(status);
|
|
||||||
}
|
|
||||||
thread::sleep(Duration::from_millis(10));
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pid_file_remains_send_and_sync() {
|
|
||||||
fn assert_send_sync<T: Send + Sync>() {}
|
|
||||||
|
|
||||||
assert_send_sync::<PidFile>();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn system_var_run_alias_keeps_the_default_pid_path_usable() {
|
|
||||||
let Ok(metadata) = fs::symlink_metadata("/var/run") else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Ok(target) = fs::read_link("/var/run") else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if !metadata.file_type().is_symlink()
|
|
||||||
|| (target != Path::new("/run") && target != Path::new("../run"))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let pid_file = PidFile::new("/var/run/telemt.pid");
|
|
||||||
|
|
||||||
assert_eq!(pid_file.path(), Path::new("/run/telemt.pid"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lock_holder_subprocess() {
|
|
||||||
let Some(pid_path) = std::env::var_os(HELPER_PID_PATH) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let ready_path = PathBuf::from(std::env::var_os(HELPER_READY_PATH).unwrap());
|
|
||||||
let stop_path = PathBuf::from(std::env::var_os(HELPER_STOP_PATH).unwrap());
|
|
||||||
let mut pid_file = PidFile::new(PathBuf::from(pid_path));
|
|
||||||
pid_file.acquire().unwrap();
|
|
||||||
fs::write(&ready_path, b"ready").unwrap();
|
|
||||||
assert!(wait_for_path(&stop_path, Duration::from_secs(10)));
|
|
||||||
pid_file.release().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn persistent_sibling_lock_serializes_processes_after_pid_unlink() {
|
|
||||||
let directory = tempfile::tempdir().unwrap();
|
|
||||||
let pid_path = directory.path().join("telemt.pid");
|
|
||||||
let lock_path = sibling_lock_path(&pid_path);
|
|
||||||
let ready_path = directory.path().join("ready");
|
|
||||||
let stop_path = directory.path().join("stop");
|
|
||||||
let mut child = Command::new(std::env::current_exe().unwrap())
|
|
||||||
.args([
|
|
||||||
"--exact",
|
|
||||||
"daemon::pid_file::tests::lock_holder_subprocess",
|
|
||||||
"--nocapture",
|
|
||||||
])
|
|
||||||
.env(HELPER_PID_PATH, &pid_path)
|
|
||||||
.env(HELPER_READY_PATH, &ready_path)
|
|
||||||
.env(HELPER_STOP_PATH, &stop_path)
|
|
||||||
.stdout(Stdio::null())
|
|
||||||
.stderr(Stdio::null())
|
|
||||||
.spawn()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
if !wait_for_path(&ready_path, Duration::from_secs(5)) {
|
|
||||||
let _ = child.kill();
|
|
||||||
let _ = child.wait();
|
|
||||||
panic!("PID lock holder did not become ready");
|
|
||||||
}
|
|
||||||
let lock_inode = fs::metadata(&lock_path).unwrap().ino();
|
|
||||||
fs::remove_file(&pid_path).unwrap();
|
|
||||||
|
|
||||||
let mut contender = PidFile::new(&pid_path);
|
|
||||||
assert!(contender.acquire().is_err());
|
|
||||||
|
|
||||||
fs::write(&stop_path, b"stop").unwrap();
|
|
||||||
let status = wait_for_child(&mut child, Duration::from_secs(5)).unwrap_or_else(|| {
|
|
||||||
let _ = child.kill();
|
|
||||||
child.wait().unwrap()
|
|
||||||
});
|
|
||||||
assert!(status.success());
|
|
||||||
assert_eq!(fs::metadata(&lock_path).unwrap().ino(), lock_inode);
|
|
||||||
|
|
||||||
contender.acquire().unwrap();
|
|
||||||
assert_eq!(fs::metadata(&lock_path).unwrap().ino(), lock_inode);
|
|
||||||
contender.release().unwrap();
|
|
||||||
assert!(!pid_path.exists());
|
|
||||||
assert!(lock_path.exists());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn stale_pid_checks_are_read_only() {
|
|
||||||
let directory = tempfile::tempdir().unwrap();
|
|
||||||
let pid_path = directory.path().join("telemt.pid");
|
|
||||||
fs::write(&pid_path, b"2000000000\n").unwrap();
|
|
||||||
let pid_file = PidFile::new(&pid_path);
|
|
||||||
|
|
||||||
assert_eq!(pid_file.check_running().unwrap(), None);
|
|
||||||
assert_eq!(check_status(&pid_path), DaemonStatus::Stale(2_000_000_000));
|
|
||||||
assert!(pid_path.exists());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn status_requires_live_lock_ownership() {
|
|
||||||
let directory = tempfile::tempdir().unwrap();
|
|
||||||
let pid_path = directory.path().join("telemt.pid");
|
|
||||||
fs::write(&pid_path, format!("{}\n", std::process::id())).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
check_status(&pid_path),
|
|
||||||
DaemonStatus::Stale(std::process::id() as i32)
|
|
||||||
);
|
|
||||||
|
|
||||||
fs::remove_file(&pid_path).unwrap();
|
|
||||||
let mut owner = PidFile::new(&pid_path);
|
|
||||||
owner.acquire().unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
check_status(&pid_path),
|
|
||||||
DaemonStatus::Running(std::process::id() as i32)
|
|
||||||
);
|
|
||||||
owner.release().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unowned_release_does_not_remove_pid_file() {
|
|
||||||
let directory = tempfile::tempdir().unwrap();
|
|
||||||
let pid_path = directory.path().join("telemt.pid");
|
|
||||||
fs::write(&pid_path, b"2000000000\n").unwrap();
|
|
||||||
let mut pid_file = PidFile::new(&pid_path);
|
|
||||||
|
|
||||||
pid_file.release().unwrap();
|
|
||||||
|
|
||||||
assert!(pid_path.exists());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn acquire_rejects_pid_symlink_without_truncating_target() {
|
|
||||||
let directory = tempfile::tempdir().unwrap();
|
|
||||||
let pid_path = directory.path().join("telemt.pid");
|
|
||||||
let target_path = directory.path().join("target");
|
|
||||||
fs::write(&target_path, b"preserve\n").unwrap();
|
|
||||||
symlink(&target_path, &pid_path).unwrap();
|
|
||||||
let mut pid_file = PidFile::new(&pid_path);
|
|
||||||
|
|
||||||
assert!(pid_file.acquire().is_err());
|
|
||||||
assert_eq!(fs::read(&target_path).unwrap(), b"preserve\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn acquire_rejects_pid_hard_link_without_truncating_target() {
|
|
||||||
let directory = tempfile::tempdir().unwrap();
|
|
||||||
let pid_path = directory.path().join("telemt.pid");
|
|
||||||
let target_path = directory.path().join("target");
|
|
||||||
fs::write(&target_path, b"preserve\n").unwrap();
|
|
||||||
fs::hard_link(&target_path, &pid_path).unwrap();
|
|
||||||
let mut pid_file = PidFile::new(&pid_path);
|
|
||||||
|
|
||||||
assert!(pid_file.acquire().is_err());
|
|
||||||
assert_eq!(fs::read(&target_path).unwrap(), b"preserve\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn acquire_rejects_symlinked_parent_without_publishing_outside() {
|
|
||||||
let directory = tempfile::tempdir().unwrap();
|
|
||||||
let real_parent = directory.path().join("real");
|
|
||||||
let linked_parent = directory.path().join("linked");
|
|
||||||
fs::create_dir(&real_parent).unwrap();
|
|
||||||
symlink(&real_parent, &linked_parent).unwrap();
|
|
||||||
let pid_path = linked_parent.join("telemt.pid");
|
|
||||||
let mut pid_file = PidFile::new(&pid_path);
|
|
||||||
|
|
||||||
assert!(pid_file.acquire().is_err());
|
|
||||||
assert!(!real_parent.join("telemt.pid").exists());
|
|
||||||
assert!(!real_parent.join("telemt.pid.lock").exists());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn release_remains_anchored_after_parent_path_replacement() {
|
|
||||||
let directory = tempfile::tempdir().unwrap();
|
|
||||||
let active_parent = directory.path().join("active");
|
|
||||||
let moved_parent = directory.path().join("moved");
|
|
||||||
fs::create_dir(&active_parent).unwrap();
|
|
||||||
let pid_path = active_parent.join("telemt.pid");
|
|
||||||
let mut pid_file = PidFile::new(&pid_path);
|
|
||||||
pid_file.acquire().unwrap();
|
|
||||||
|
|
||||||
fs::rename(&active_parent, &moved_parent).unwrap();
|
|
||||||
fs::create_dir(&active_parent).unwrap();
|
|
||||||
fs::write(active_parent.join("telemt.pid"), b"replacement\n").unwrap();
|
|
||||||
|
|
||||||
pid_file.release().unwrap();
|
|
||||||
|
|
||||||
assert!(!moved_parent.join("telemt.pid").exists());
|
|
||||||
assert_eq!(
|
|
||||||
fs::read(active_parent.join("telemt.pid")).unwrap(),
|
|
||||||
b"replacement\n"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn release_does_not_remove_replacement_path() {
|
|
||||||
let directory = tempfile::tempdir().unwrap();
|
|
||||||
let pid_path = directory.path().join("telemt.pid");
|
|
||||||
let owned_path = directory.path().join("owned.pid");
|
|
||||||
let mut pid_file = PidFile::new(&pid_path);
|
|
||||||
pid_file.acquire().unwrap();
|
|
||||||
fs::rename(&pid_path, &owned_path).unwrap();
|
|
||||||
fs::write(&pid_path, b"replacement\n").unwrap();
|
|
||||||
|
|
||||||
let error = pid_file.release().unwrap_err();
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
error
|
|
||||||
.to_string()
|
|
||||||
.contains("refusing to remove replaced PID file")
|
|
||||||
);
|
|
||||||
assert_eq!(fs::read(&pid_path).unwrap(), b"replacement\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pid_parser_rejects_process_group_values() {
|
|
||||||
let directory = tempfile::tempdir().unwrap();
|
|
||||||
let pid_path = directory.path().join("telemt.pid");
|
|
||||||
|
|
||||||
for value in ["-1\n", "0\n", "1\n"] {
|
|
||||||
fs::write(&pid_path, value).unwrap();
|
|
||||||
assert!(read_pid_file(&pid_path).is_err());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pid_file_release_keeps_lock_inode() {
|
|
||||||
let directory = tempfile::tempdir().unwrap();
|
|
||||||
let pid_path = directory.path().join("telemt.pid");
|
|
||||||
let lock_path = sibling_lock_path(&pid_path);
|
|
||||||
let mut pid_file = PidFile::new(&pid_path);
|
|
||||||
|
|
||||||
pid_file.acquire().unwrap();
|
|
||||||
assert!(
|
|
||||||
pid_file
|
|
||||||
.ownership_file_handles()
|
|
||||||
.into_iter()
|
|
||||||
.all(|file| file.is_some())
|
|
||||||
);
|
|
||||||
assert_eq!(read_pid_file(&pid_path).unwrap(), std::process::id() as i32);
|
|
||||||
let lock_inode = fs::metadata(&lock_path).unwrap().ino();
|
|
||||||
pid_file.release().unwrap();
|
|
||||||
|
|
||||||
assert!(!pid_path.exists());
|
|
||||||
assert!(lock_path.exists());
|
|
||||||
pid_file.acquire().unwrap();
|
|
||||||
assert_eq!(fs::metadata(&lock_path).unwrap().ino(), lock_inode);
|
|
||||||
pid_file.release().unwrap();
|
|
||||||
}
|
|
||||||
+11
-40
@@ -15,7 +15,6 @@ use arc_swap::ArcSwap;
|
|||||||
use tokio::sync::{Mutex as AsyncMutex, RwLock};
|
use tokio::sync::{Mutex as AsyncMutex, RwLock};
|
||||||
|
|
||||||
use crate::config::UserMaxUniqueIpsMode;
|
use crate::config::UserMaxUniqueIpsMode;
|
||||||
use crate::proxy::user_admission::UserIncarnation;
|
|
||||||
|
|
||||||
const CLEANUP_DRAIN_BATCH_LIMIT: usize = 1024;
|
const CLEANUP_DRAIN_BATCH_LIMIT: usize = 1024;
|
||||||
const MAX_ACTIVE_IP_ENTRIES: u64 = 131_072;
|
const MAX_ACTIVE_IP_ENTRIES: u64 = 131_072;
|
||||||
@@ -33,20 +32,15 @@ mod tests;
|
|||||||
struct UserIpShard {
|
struct UserIpShard {
|
||||||
active_ips: HashMap<String, HashMap<IpAddr, usize>>,
|
active_ips: HashMap<String, HashMap<IpAddr, usize>>,
|
||||||
recent_ips: HashMap<String, HashMap<IpAddr, Instant>>,
|
recent_ips: HashMap<String, HashMap<IpAddr, Instant>>,
|
||||||
incarnations: HashMap<String, UserIncarnation>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
struct CleanupShard {
|
struct CleanupShard {
|
||||||
queue: Mutex<CleanupQueue>,
|
queue: Mutex<HashMap<String, HashMap<IpAddr, usize>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
type CleanupQueue = HashMap<String, HashMap<UserIncarnation, HashMap<IpAddr, usize>>>;
|
|
||||||
type CleanupBatch = HashMap<(String, UserIncarnation, IpAddr), usize>;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct UserIpLimitPolicy {
|
struct UserIpLimitPolicy {
|
||||||
source_generation: u64,
|
|
||||||
max_ips: Arc<HashMap<String, usize>>,
|
max_ips: Arc<HashMap<String, usize>>,
|
||||||
default_max_ips: usize,
|
default_max_ips: usize,
|
||||||
mode: UserMaxUniqueIpsMode,
|
mode: UserMaxUniqueIpsMode,
|
||||||
@@ -56,7 +50,6 @@ struct UserIpLimitPolicy {
|
|||||||
impl Default for UserIpLimitPolicy {
|
impl Default for UserIpLimitPolicy {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
source_generation: 0,
|
|
||||||
max_ips: Arc::new(HashMap::new()),
|
max_ips: Arc::new(HashMap::new()),
|
||||||
default_max_ips: 0,
|
default_max_ips: 0,
|
||||||
mode: UserMaxUniqueIpsMode::ActiveWindow,
|
mode: UserMaxUniqueIpsMode::ActiveWindow,
|
||||||
@@ -75,7 +68,6 @@ pub struct UserIpTracker {
|
|||||||
recent_cap_rejects: Arc<AtomicU64>,
|
recent_cap_rejects: Arc<AtomicU64>,
|
||||||
cleanup_deferred_releases: Arc<AtomicU64>,
|
cleanup_deferred_releases: Arc<AtomicU64>,
|
||||||
limit_policy: Arc<ArcSwap<UserIpLimitPolicy>>,
|
limit_policy: Arc<ArcSwap<UserIpLimitPolicy>>,
|
||||||
policy_update: Arc<Mutex<()>>,
|
|
||||||
last_compact_epoch_secs: Arc<AtomicU64>,
|
last_compact_epoch_secs: Arc<AtomicU64>,
|
||||||
cleanup_queue_len: Arc<AtomicU64>,
|
cleanup_queue_len: Arc<AtomicU64>,
|
||||||
cleanup_shards: Arc<Box<[CleanupShard]>>,
|
cleanup_shards: Arc<Box<[CleanupShard]>>,
|
||||||
@@ -127,7 +119,6 @@ impl UserIpTracker {
|
|||||||
recent_cap_rejects: Arc::new(AtomicU64::new(0)),
|
recent_cap_rejects: Arc::new(AtomicU64::new(0)),
|
||||||
cleanup_deferred_releases: Arc::new(AtomicU64::new(0)),
|
cleanup_deferred_releases: Arc::new(AtomicU64::new(0)),
|
||||||
limit_policy: Arc::new(ArcSwap::from_pointee(UserIpLimitPolicy::default())),
|
limit_policy: Arc::new(ArcSwap::from_pointee(UserIpLimitPolicy::default())),
|
||||||
policy_update: Arc::new(Mutex::new(())),
|
|
||||||
last_compact_epoch_secs: Arc::new(AtomicU64::new(0)),
|
last_compact_epoch_secs: Arc::new(AtomicU64::new(0)),
|
||||||
cleanup_queue_len: Arc::new(AtomicU64::new(0)),
|
cleanup_queue_len: Arc::new(AtomicU64::new(0)),
|
||||||
cleanup_shards: Arc::new(cleanup_shards),
|
cleanup_shards: Arc::new(cleanup_shards),
|
||||||
@@ -203,26 +194,19 @@ impl UserIpTracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn pop_one_cleanup(
|
pub(super) fn pop_one_cleanup(
|
||||||
queue: &mut CleanupQueue,
|
queue: &mut HashMap<String, HashMap<IpAddr, usize>>,
|
||||||
) -> Option<(String, UserIncarnation, IpAddr, usize)> {
|
) -> Option<(String, IpAddr, usize)> {
|
||||||
let user = queue.keys().next().cloned()?;
|
let user = queue.keys().next().cloned()?;
|
||||||
let incarnation = queue.get(&user)?.keys().next().copied()?;
|
let ip = queue.get(&user)?.keys().next().copied()?;
|
||||||
let ip = queue
|
let count = queue.get_mut(&user)?.remove(&ip)?;
|
||||||
.get(&user)?
|
let remove_user = queue
|
||||||
.get(&incarnation)?
|
.get(&user)
|
||||||
.keys()
|
.map(|user_queue| user_queue.is_empty())
|
||||||
.next()
|
.unwrap_or(false);
|
||||||
.copied()?;
|
if remove_user {
|
||||||
let incarnations = queue.get_mut(&user)?;
|
|
||||||
let ips = incarnations.get_mut(&incarnation)?;
|
|
||||||
let count = ips.remove(&ip)?;
|
|
||||||
if ips.is_empty() {
|
|
||||||
incarnations.remove(&incarnation);
|
|
||||||
}
|
|
||||||
if incarnations.is_empty() {
|
|
||||||
queue.remove(&user);
|
queue.remove(&user);
|
||||||
}
|
}
|
||||||
Some((user, incarnation, ip, count))
|
Some((user, ip, count))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -238,19 +222,6 @@ impl UserIpTracker {
|
|||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
pub(super) fn observe_cleanup_poison_for_tests(&self) {}
|
pub(super) fn observe_cleanup_poison_for_tests(&self) {}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) async fn hold_user_shard_for_tests(
|
|
||||||
&self,
|
|
||||||
user: &str,
|
|
||||||
entered: tokio::sync::oneshot::Sender<()>,
|
|
||||||
release: tokio::sync::oneshot::Receiver<()>,
|
|
||||||
) {
|
|
||||||
let shard_idx = Self::shard_idx(user);
|
|
||||||
let _guard = self.shards[shard_idx].write().await;
|
|
||||||
let _ = entered.send(());
|
|
||||||
let _ = release.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn now_epoch_secs() -> u64 {
|
pub(super) fn now_epoch_secs() -> u64 {
|
||||||
std::time::SystemTime::now()
|
std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
|||||||
+25
-107
@@ -2,84 +2,47 @@ use super::*;
|
|||||||
|
|
||||||
impl UserIpTracker {
|
impl UserIpTracker {
|
||||||
pub async fn set_limit_policy(&self, mode: UserMaxUniqueIpsMode, window_secs: u64) {
|
pub async fn set_limit_policy(&self, mode: UserMaxUniqueIpsMode, window_secs: u64) {
|
||||||
let _policy_update = self.policy_update.lock().unwrap_or_else(|poisoned| {
|
self.limit_policy.rcu(|current| {
|
||||||
self.policy_update.clear_poison();
|
Arc::new(UserIpLimitPolicy {
|
||||||
poisoned.into_inner()
|
|
||||||
});
|
|
||||||
let current = self.limit_policy.load_full();
|
|
||||||
self.limit_policy.store(Arc::new(UserIpLimitPolicy {
|
|
||||||
mode,
|
mode,
|
||||||
window_secs: window_secs.max(1),
|
window_secs: window_secs.max(1),
|
||||||
..(*current).clone()
|
..(**current).clone()
|
||||||
}));
|
})
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_user_limit(&self, username: &str, max_ips: usize) {
|
pub async fn set_user_limit(&self, username: &str, max_ips: usize) {
|
||||||
let _policy_update = self.policy_update.lock().unwrap_or_else(|poisoned| {
|
let username = username.to_string();
|
||||||
self.policy_update.clear_poison();
|
self.limit_policy.rcu(|current| {
|
||||||
poisoned.into_inner()
|
|
||||||
});
|
|
||||||
let current = self.limit_policy.load_full();
|
|
||||||
let mut limits = current.max_ips.as_ref().clone();
|
let mut limits = current.max_ips.as_ref().clone();
|
||||||
limits.insert(username.to_string(), max_ips);
|
limits.insert(username.clone(), max_ips);
|
||||||
self.limit_policy.store(Arc::new(UserIpLimitPolicy {
|
Arc::new(UserIpLimitPolicy {
|
||||||
max_ips: Arc::new(limits),
|
max_ips: Arc::new(limits),
|
||||||
..(*current).clone()
|
..(**current).clone()
|
||||||
}));
|
})
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn remove_user_limit(&self, username: &str) {
|
pub async fn remove_user_limit(&self, username: &str) {
|
||||||
let _policy_update = self.policy_update.lock().unwrap_or_else(|poisoned| {
|
self.limit_policy.rcu(|current| {
|
||||||
self.policy_update.clear_poison();
|
|
||||||
poisoned.into_inner()
|
|
||||||
});
|
|
||||||
let current = self.limit_policy.load_full();
|
|
||||||
let mut limits = current.max_ips.as_ref().clone();
|
let mut limits = current.max_ips.as_ref().clone();
|
||||||
limits.remove(username);
|
limits.remove(username);
|
||||||
self.limit_policy.store(Arc::new(UserIpLimitPolicy {
|
Arc::new(UserIpLimitPolicy {
|
||||||
max_ips: Arc::new(limits),
|
max_ips: Arc::new(limits),
|
||||||
..(*current).clone()
|
..(**current).clone()
|
||||||
}));
|
})
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn load_limits(&self, default_limit: usize, limits: &HashMap<String, usize>) {
|
pub async fn load_limits(&self, default_limit: usize, limits: &HashMap<String, usize>) {
|
||||||
let _policy_update = self.policy_update.lock().unwrap_or_else(|poisoned| {
|
let limits = Arc::new(limits.clone());
|
||||||
self.policy_update.clear_poison();
|
self.limit_policy.rcu(|current| {
|
||||||
poisoned.into_inner()
|
Arc::new(UserIpLimitPolicy {
|
||||||
});
|
max_ips: Arc::clone(&limits),
|
||||||
let current = self.limit_policy.load_full();
|
|
||||||
self.limit_policy.store(Arc::new(UserIpLimitPolicy {
|
|
||||||
max_ips: Arc::new(limits.clone()),
|
|
||||||
default_max_ips: default_limit,
|
default_max_ips: default_limit,
|
||||||
..(*current).clone()
|
..(**current).clone()
|
||||||
}));
|
})
|
||||||
}
|
|
||||||
|
|
||||||
/// Atomically publishes one coherent policy from the active runtime generation.
|
|
||||||
pub(crate) async fn apply_policy_from_source(
|
|
||||||
&self,
|
|
||||||
source_generation: u64,
|
|
||||||
default_limit: usize,
|
|
||||||
limits: &HashMap<String, usize>,
|
|
||||||
mode: UserMaxUniqueIpsMode,
|
|
||||||
window_secs: u64,
|
|
||||||
) -> bool {
|
|
||||||
let _policy_update = self.policy_update.lock().unwrap_or_else(|poisoned| {
|
|
||||||
self.policy_update.clear_poison();
|
|
||||||
poisoned.into_inner()
|
|
||||||
});
|
});
|
||||||
let current = self.limit_policy.load_full();
|
|
||||||
if source_generation < current.source_generation {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
self.limit_policy.store(Arc::new(UserIpLimitPolicy {
|
|
||||||
source_generation,
|
|
||||||
max_ips: Arc::new(limits.clone()),
|
|
||||||
default_max_ips: default_limit,
|
|
||||||
mode,
|
|
||||||
window_secs: window_secs.max(1),
|
|
||||||
}));
|
|
||||||
true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn prune_recent(
|
pub(super) fn prune_recent(
|
||||||
@@ -96,17 +59,8 @@ impl UserIpTracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn check_and_add(&self, username: &str, ip: IpAddr) -> Result<(), String> {
|
pub async fn check_and_add(&self, username: &str, ip: IpAddr) -> Result<(), String> {
|
||||||
self.check_and_add_for_incarnation(username, 0, ip).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reserves an IP slot for one exact user incarnation.
|
|
||||||
pub(crate) async fn check_and_add_for_incarnation(
|
|
||||||
&self,
|
|
||||||
username: &str,
|
|
||||||
incarnation: UserIncarnation,
|
|
||||||
ip: IpAddr,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
self.drain_cleanup_for_user(username).await;
|
self.drain_cleanup_for_user(username).await;
|
||||||
|
self.maybe_compact_empty_users().await;
|
||||||
let policy = self.limit_policy.load();
|
let policy = self.limit_policy.load();
|
||||||
let limit = Self::user_limit(&policy, username);
|
let limit = Self::user_limit(&policy, username);
|
||||||
let mode = policy.mode;
|
let mode = policy.mode;
|
||||||
@@ -115,30 +69,6 @@ impl UserIpTracker {
|
|||||||
|
|
||||||
let shard_idx = Self::shard_idx(username);
|
let shard_idx = Self::shard_idx(username);
|
||||||
let mut shard = self.shards[shard_idx].write().await;
|
let mut shard = self.shards[shard_idx].write().await;
|
||||||
if let Some(current) = shard.incarnations.get(username).copied() {
|
|
||||||
if current > incarnation {
|
|
||||||
return Err(format!(
|
|
||||||
"IP tracker rejected stale user incarnation for '{username}'"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if current < incarnation {
|
|
||||||
let removed_active = shard
|
|
||||||
.active_ips
|
|
||||||
.remove(username)
|
|
||||||
.map(|ips| ips.len())
|
|
||||||
.unwrap_or(0);
|
|
||||||
let removed_recent = shard
|
|
||||||
.recent_ips
|
|
||||||
.remove(username)
|
|
||||||
.map(|ips| ips.len())
|
|
||||||
.unwrap_or(0);
|
|
||||||
Self::decrement_counter(&self.active_entry_count, removed_active);
|
|
||||||
Self::decrement_counter(&self.recent_entry_count, removed_recent);
|
|
||||||
shard.incarnations.insert(username.to_string(), incarnation);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
shard.incarnations.insert(username.to_string(), incarnation);
|
|
||||||
}
|
|
||||||
let user_active = shard.active_ips.entry(username.to_string()).or_default();
|
let user_active = shard.active_ips.entry(username.to_string()).or_default();
|
||||||
let active_contains_ip = user_active.contains_key(&ip);
|
let active_contains_ip = user_active.contains_key(&ip);
|
||||||
let active_len = user_active.len();
|
let active_len = user_active.len();
|
||||||
@@ -244,21 +174,9 @@ impl UserIpTracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn remove_ip(&self, username: &str, ip: IpAddr) {
|
pub async fn remove_ip(&self, username: &str, ip: IpAddr) {
|
||||||
self.remove_ip_for_incarnation(username, 0, ip).await;
|
self.maybe_compact_empty_users().await;
|
||||||
}
|
|
||||||
|
|
||||||
/// Releases an IP slot only from the incarnation that acquired it.
|
|
||||||
pub(crate) async fn remove_ip_for_incarnation(
|
|
||||||
&self,
|
|
||||||
username: &str,
|
|
||||||
incarnation: UserIncarnation,
|
|
||||||
ip: IpAddr,
|
|
||||||
) {
|
|
||||||
let shard_idx = Self::shard_idx(username);
|
let shard_idx = Self::shard_idx(username);
|
||||||
let mut shard = self.shards[shard_idx].write().await;
|
let mut shard = self.shards[shard_idx].write().await;
|
||||||
if shard.incarnations.get(username).copied() != Some(incarnation) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let mut removed_active_entries = 0usize;
|
let mut removed_active_entries = 0usize;
|
||||||
if let Some(user_ips) = shard.active_ips.get_mut(username) {
|
if let Some(user_ips) = shard.active_ips.get_mut(username) {
|
||||||
if let Some(count) = user_ips.get_mut(&ip) {
|
if let Some(count) = user_ips.get_mut(&ip) {
|
||||||
|
|||||||
+25
-162
@@ -1,90 +1,15 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
struct DetachedCleanupBatch<'a> {
|
|
||||||
tracker: &'a UserIpTracker,
|
|
||||||
shard_idx: usize,
|
|
||||||
entries: CleanupBatch,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DetachedCleanupBatch<'_> {
|
|
||||||
fn is_empty(&self) -> bool {
|
|
||||||
self.entries.is_empty()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn entries(&self) -> impl Iterator<Item = (&(String, UserIncarnation, IpAddr), &usize)> {
|
|
||||||
self.entries.iter()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn commit(mut self) {
|
|
||||||
let committed = self.entries.len();
|
|
||||||
self.entries.clear();
|
|
||||||
UserIpTracker::decrement_counter(&self.tracker.cleanup_queue_len, committed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for DetachedCleanupBatch<'_> {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
if self.entries.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cleanup_shard = &self.tracker.cleanup_shards[self.shard_idx];
|
|
||||||
let mut duplicate_entries = 0usize;
|
|
||||||
let mut restore = |queue: &mut CleanupQueue| {
|
|
||||||
for ((user, incarnation, ip), count) in self.entries.drain() {
|
|
||||||
let queued = queue
|
|
||||||
.entry(user)
|
|
||||||
.or_default()
|
|
||||||
.entry(incarnation)
|
|
||||||
.or_default()
|
|
||||||
.entry(ip)
|
|
||||||
.or_insert(0);
|
|
||||||
if *queued != 0 {
|
|
||||||
duplicate_entries = duplicate_entries.saturating_add(1);
|
|
||||||
}
|
|
||||||
*queued = queued.saturating_add(count);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match cleanup_shard.queue.lock() {
|
|
||||||
Ok(mut queue) => restore(&mut queue),
|
|
||||||
Err(poisoned) => {
|
|
||||||
let mut queue = poisoned.into_inner();
|
|
||||||
restore(&mut queue);
|
|
||||||
cleanup_shard.queue.clear_poison();
|
|
||||||
tracing::warn!(
|
|
||||||
"UserIpTracker cleanup_queue lock poisoned while restoring a cancelled cleanup batch"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
UserIpTracker::decrement_counter(&self.tracker.cleanup_queue_len, duplicate_entries);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UserIpTracker {
|
impl UserIpTracker {
|
||||||
/// Queues a deferred active IP cleanup for a later async drain.
|
/// Queues a deferred active IP cleanup for a later async drain.
|
||||||
pub fn enqueue_cleanup(&self, user: String, ip: IpAddr) {
|
pub fn enqueue_cleanup(&self, user: String, ip: IpAddr) {
|
||||||
self.enqueue_cleanup_for_incarnation(user, 0, ip);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Queues cleanup for the exact user incarnation that owns the reservation.
|
|
||||||
pub(crate) fn enqueue_cleanup_for_incarnation(
|
|
||||||
&self,
|
|
||||||
user: String,
|
|
||||||
incarnation: UserIncarnation,
|
|
||||||
ip: IpAddr,
|
|
||||||
) {
|
|
||||||
self.observe_cleanup_poison_for_tests();
|
self.observe_cleanup_poison_for_tests();
|
||||||
let shard_idx = Self::shard_idx(&user);
|
let shard_idx = Self::shard_idx(&user);
|
||||||
let cleanup_shard = &self.cleanup_shards[shard_idx];
|
let cleanup_shard = &self.cleanup_shards[shard_idx];
|
||||||
match cleanup_shard.queue.lock() {
|
match cleanup_shard.queue.lock() {
|
||||||
Ok(mut queue) => {
|
Ok(mut queue) => {
|
||||||
let count = queue
|
let user_queue = queue.entry(user).or_default();
|
||||||
.entry(user)
|
let count = user_queue.entry(ip).or_insert(0);
|
||||||
.or_default()
|
|
||||||
.entry(incarnation)
|
|
||||||
.or_default()
|
|
||||||
.entry(ip)
|
|
||||||
.or_insert(0);
|
|
||||||
if *count == 0 {
|
if *count == 0 {
|
||||||
self.cleanup_queue_len.fetch_add(1, Ordering::Relaxed);
|
self.cleanup_queue_len.fetch_add(1, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
@@ -94,13 +19,8 @@ impl UserIpTracker {
|
|||||||
}
|
}
|
||||||
Err(poisoned) => {
|
Err(poisoned) => {
|
||||||
let mut queue = poisoned.into_inner();
|
let mut queue = poisoned.into_inner();
|
||||||
let count = queue
|
let user_queue = queue.entry(user.clone()).or_default();
|
||||||
.entry(user.clone())
|
let count = user_queue.entry(ip).or_insert(0);
|
||||||
.or_default()
|
|
||||||
.entry(incarnation)
|
|
||||||
.or_default()
|
|
||||||
.entry(ip)
|
|
||||||
.or_insert(0);
|
|
||||||
if *count == 0 {
|
if *count == 0 {
|
||||||
self.cleanup_queue_len.fetch_add(1, Ordering::Relaxed);
|
self.cleanup_queue_len.fetch_add(1, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
@@ -122,26 +42,6 @@ impl UserIpTracker {
|
|||||||
self.cleanup_queue_len.load(Ordering::Relaxed) as usize
|
self.cleanup_queue_len.load(Ordering::Relaxed) as usize
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) fn cleanup_queue_physical_entries_for_tests(&self) -> usize {
|
|
||||||
self.cleanup_shards
|
|
||||||
.iter()
|
|
||||||
.map(|cleanup_shard| {
|
|
||||||
let count = |queue: &CleanupQueue| {
|
|
||||||
queue
|
|
||||||
.values()
|
|
||||||
.flat_map(HashMap::values)
|
|
||||||
.map(HashMap::len)
|
|
||||||
.sum::<usize>()
|
|
||||||
};
|
|
||||||
match cleanup_shard.queue.lock() {
|
|
||||||
Ok(queue) => count(&queue),
|
|
||||||
Err(poisoned) => count(&poisoned.into_inner()),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.sum()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) fn cleanup_queue_mutex_for_tests(
|
pub(crate) fn cleanup_queue_mutex_for_tests(
|
||||||
&self,
|
&self,
|
||||||
@@ -163,13 +63,12 @@ impl UserIpTracker {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let shard_idx = Self::shard_idx(user);
|
let shard_idx = Self::shard_idx(user);
|
||||||
let _drain_guard = self.cleanup_drain_locks[shard_idx].lock().await;
|
|
||||||
let cleanup_shard = &self.cleanup_shards[shard_idx];
|
let cleanup_shard = &self.cleanup_shards[shard_idx];
|
||||||
let to_remove = match cleanup_shard.queue.lock() {
|
let to_remove = match cleanup_shard.queue.lock() {
|
||||||
Ok(mut queue) => detach_user_cleanup(self, shard_idx, &mut queue, user),
|
Ok(mut queue) => queue.remove(user).unwrap_or_default(),
|
||||||
Err(poisoned) => {
|
Err(poisoned) => {
|
||||||
let mut queue = poisoned.into_inner();
|
let mut queue = poisoned.into_inner();
|
||||||
let drained = detach_user_cleanup(self, shard_idx, &mut queue, user);
|
let drained = queue.remove(user).unwrap_or_default();
|
||||||
cleanup_shard.queue.clear_poison();
|
cleanup_shard.queue.clear_poison();
|
||||||
drained
|
drained
|
||||||
}
|
}
|
||||||
@@ -177,19 +76,16 @@ impl UserIpTracker {
|
|||||||
if to_remove.is_empty() {
|
if to_remove.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
self.cleanup_queue_len
|
||||||
|
.fetch_sub(to_remove.len() as u64, Ordering::Relaxed);
|
||||||
let mut shard = self.shards[shard_idx].write().await;
|
let mut shard = self.shards[shard_idx].write().await;
|
||||||
let mut removed_active_entries = 0usize;
|
let mut removed_active_entries = 0usize;
|
||||||
for ((queued_user, incarnation, ip), pending_count) in to_remove.entries() {
|
for (ip, pending_count) in to_remove {
|
||||||
if shard.incarnations.get(queued_user).copied() != Some(*incarnation) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
removed_active_entries = removed_active_entries.saturating_add(
|
removed_active_entries = removed_active_entries.saturating_add(
|
||||||
Self::apply_active_cleanup(&mut shard.active_ips, queued_user, *ip, *pending_count),
|
Self::apply_active_cleanup(&mut shard.active_ips, user, ip, pending_count),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Self::decrement_counter(&self.active_entry_count, removed_active_entries);
|
Self::decrement_counter(&self.active_entry_count, removed_active_entries);
|
||||||
drop(shard);
|
|
||||||
to_remove.commit();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn drain_cleanup_shard(&self, shard_idx: usize) {
|
pub(super) async fn drain_cleanup_shard(&self, shard_idx: usize) {
|
||||||
@@ -204,20 +100,16 @@ impl UserIpTracker {
|
|||||||
if queue.is_empty() {
|
if queue.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let mut drained = HashMap::with_capacity(CLEANUP_DRAIN_BATCH_LIMIT);
|
let mut drained =
|
||||||
|
HashMap::with_capacity(queue.len().min(CLEANUP_DRAIN_BATCH_LIMIT));
|
||||||
for _ in 0..CLEANUP_DRAIN_BATCH_LIMIT {
|
for _ in 0..CLEANUP_DRAIN_BATCH_LIMIT {
|
||||||
let Some((user, incarnation, ip, count)) =
|
let Some((user, ip, count)) = Self::pop_one_cleanup(&mut queue) else {
|
||||||
Self::pop_one_cleanup(&mut queue)
|
|
||||||
else {
|
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
drained.insert((user, incarnation, ip), count);
|
self.cleanup_queue_len.fetch_sub(1, Ordering::Relaxed);
|
||||||
}
|
drained.insert((user, ip), count);
|
||||||
DetachedCleanupBatch {
|
|
||||||
tracker: self,
|
|
||||||
shard_idx,
|
|
||||||
entries: drained,
|
|
||||||
}
|
}
|
||||||
|
drained
|
||||||
}
|
}
|
||||||
Err(poisoned) => {
|
Err(poisoned) => {
|
||||||
let mut queue = poisoned.into_inner();
|
let mut queue = poisoned.into_inner();
|
||||||
@@ -225,61 +117,32 @@ impl UserIpTracker {
|
|||||||
cleanup_shard.queue.clear_poison();
|
cleanup_shard.queue.clear_poison();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let mut drained = HashMap::with_capacity(CLEANUP_DRAIN_BATCH_LIMIT);
|
let mut drained =
|
||||||
|
HashMap::with_capacity(queue.len().min(CLEANUP_DRAIN_BATCH_LIMIT));
|
||||||
for _ in 0..CLEANUP_DRAIN_BATCH_LIMIT {
|
for _ in 0..CLEANUP_DRAIN_BATCH_LIMIT {
|
||||||
let Some((user, incarnation, ip, count)) =
|
let Some((user, ip, count)) = Self::pop_one_cleanup(&mut queue) else {
|
||||||
Self::pop_one_cleanup(&mut queue)
|
|
||||||
else {
|
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
drained.insert((user, incarnation, ip), count);
|
self.cleanup_queue_len.fetch_sub(1, Ordering::Relaxed);
|
||||||
|
drained.insert((user, ip), count);
|
||||||
}
|
}
|
||||||
cleanup_shard.queue.clear_poison();
|
cleanup_shard.queue.clear_poison();
|
||||||
DetachedCleanupBatch {
|
drained
|
||||||
tracker: self,
|
|
||||||
shard_idx,
|
|
||||||
entries: drained,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
drop(_drain_guard);
|
||||||
if to_remove.is_empty() {
|
if to_remove.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut shard = self.shards[shard_idx].write().await;
|
let mut shard = self.shards[shard_idx].write().await;
|
||||||
let mut removed_active_entries = 0usize;
|
let mut removed_active_entries = 0usize;
|
||||||
for ((user, incarnation, ip), pending_count) in to_remove.entries() {
|
for ((user, ip), pending_count) in to_remove {
|
||||||
if shard.incarnations.get(user).copied() != Some(*incarnation) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
removed_active_entries = removed_active_entries.saturating_add(
|
removed_active_entries = removed_active_entries.saturating_add(
|
||||||
Self::apply_active_cleanup(&mut shard.active_ips, user, *ip, *pending_count),
|
Self::apply_active_cleanup(&mut shard.active_ips, &user, ip, pending_count),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Self::decrement_counter(&self.active_entry_count, removed_active_entries);
|
Self::decrement_counter(&self.active_entry_count, removed_active_entries);
|
||||||
drop(shard);
|
|
||||||
to_remove.commit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn detach_user_cleanup<'a>(
|
|
||||||
tracker: &'a UserIpTracker,
|
|
||||||
shard_idx: usize,
|
|
||||||
queue: &mut CleanupQueue,
|
|
||||||
user: &str,
|
|
||||||
) -> DetachedCleanupBatch<'a> {
|
|
||||||
let mut entries = CleanupBatch::new();
|
|
||||||
if let Some(incarnations) = queue.remove(user) {
|
|
||||||
for (incarnation, ips) in incarnations {
|
|
||||||
for (ip, count) in ips {
|
|
||||||
entries.insert((user.to_string(), incarnation, ip), count);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DetachedCleanupBatch {
|
|
||||||
tracker,
|
|
||||||
shard_idx,
|
|
||||||
entries,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,13 +68,10 @@ impl UserIpTracker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run_periodic_maintenance(self: Arc<Self>, source_generation: u64) {
|
pub async fn run_periodic_maintenance(self: Arc<Self>) {
|
||||||
let mut interval = tokio::time::interval(Duration::from_secs(1));
|
let mut interval = tokio::time::interval(Duration::from_secs(1));
|
||||||
loop {
|
loop {
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
if self.limit_policy.load().source_generation != source_generation {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
self.drain_cleanup_queue().await;
|
self.drain_cleanup_queue().await;
|
||||||
self.maybe_compact_empty_users().await;
|
self.maybe_compact_empty_users().await;
|
||||||
}
|
}
|
||||||
@@ -231,25 +228,8 @@ impl UserIpTracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn clear_user_ips(&self, username: &str) {
|
pub async fn clear_user_ips(&self, username: &str) {
|
||||||
self.clear_user_ips_if_not_newer(username, 0).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clears state while advancing the username fence to a newer incarnation.
|
|
||||||
pub(crate) async fn clear_user_ips_if_not_newer(
|
|
||||||
&self,
|
|
||||||
username: &str,
|
|
||||||
incarnation: UserIncarnation,
|
|
||||||
) {
|
|
||||||
let shard_idx = Self::shard_idx(username);
|
let shard_idx = Self::shard_idx(username);
|
||||||
let mut shard = self.shards[shard_idx].write().await;
|
let mut shard = self.shards[shard_idx].write().await;
|
||||||
if shard
|
|
||||||
.incarnations
|
|
||||||
.get(username)
|
|
||||||
.is_some_and(|current| *current > incarnation)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
shard.incarnations.insert(username.to_string(), incarnation);
|
|
||||||
let removed_active_entries = shard
|
let removed_active_entries = shard
|
||||||
.active_ips
|
.active_ips
|
||||||
.remove(username)
|
.remove(username)
|
||||||
@@ -266,36 +246,23 @@ impl UserIpTracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn clear_all(&self) {
|
pub async fn clear_all(&self) {
|
||||||
let mut cleanup_drain_guards = Vec::with_capacity(USER_IP_TRACKER_SHARDS);
|
|
||||||
for drain_lock in self.cleanup_drain_locks.iter() {
|
|
||||||
cleanup_drain_guards.push(drain_lock.lock().await);
|
|
||||||
}
|
|
||||||
for shard_lock in self.shards.iter() {
|
for shard_lock in self.shards.iter() {
|
||||||
let mut shard = shard_lock.write().await;
|
let mut shard = shard_lock.write().await;
|
||||||
shard.active_ips.clear();
|
shard.active_ips.clear();
|
||||||
shard.recent_ips.clear();
|
shard.recent_ips.clear();
|
||||||
shard.incarnations.clear();
|
|
||||||
}
|
}
|
||||||
self.active_entry_count.store(0, Ordering::Relaxed);
|
self.active_entry_count.store(0, Ordering::Relaxed);
|
||||||
self.recent_entry_count.store(0, Ordering::Relaxed);
|
self.recent_entry_count.store(0, Ordering::Relaxed);
|
||||||
let mut cleanup_queue_guards = Vec::with_capacity(USER_IP_TRACKER_SHARDS);
|
|
||||||
for cleanup_shard in self.cleanup_shards.iter() {
|
for cleanup_shard in self.cleanup_shards.iter() {
|
||||||
let queue = match cleanup_shard.queue.lock() {
|
match cleanup_shard.queue.lock() {
|
||||||
Ok(queue) => queue,
|
Ok(mut queue) => queue.clear(),
|
||||||
Err(poisoned) => {
|
Err(poisoned) => {
|
||||||
let queue = poisoned.into_inner();
|
poisoned.into_inner().clear();
|
||||||
cleanup_shard.queue.clear_poison();
|
cleanup_shard.queue.clear_poison();
|
||||||
queue
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
cleanup_queue_guards.push(queue);
|
|
||||||
}
|
}
|
||||||
for queue in cleanup_queue_guards.iter_mut() {
|
|
||||||
queue.clear();
|
|
||||||
}
|
}
|
||||||
self.cleanup_queue_len.store(0, Ordering::Relaxed);
|
self.cleanup_queue_len.store(0, Ordering::Relaxed);
|
||||||
drop(cleanup_queue_guards);
|
|
||||||
drop(cleanup_drain_guards);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn is_ip_active(&self, username: &str, ip: IpAddr) -> bool {
|
pub async fn is_ip_active(&self, username: &str, ip: IpAddr) -> bool {
|
||||||
|
|||||||
+4
-92
@@ -3,8 +3,6 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
|||||||
use std::sync::atomic::AtomicBool;
|
use std::sync::atomic::AtomicBool;
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
mod cleanup_invariants;
|
|
||||||
|
|
||||||
fn test_ipv4(oct1: u8, oct2: u8, oct3: u8, oct4: u8) -> IpAddr {
|
fn test_ipv4(oct1: u8, oct2: u8, oct3: u8, oct4: u8) -> IpAddr {
|
||||||
IpAddr::V4(Ipv4Addr::new(oct1, oct2, oct3, oct4))
|
IpAddr::V4(Ipv4Addr::new(oct1, oct2, oct3, oct4))
|
||||||
}
|
}
|
||||||
@@ -191,37 +189,6 @@ async fn test_clear_user_ips() {
|
|||||||
assert_eq!(tracker.get_active_ip_count("test_user").await, 0);
|
assert_eq!(tracker.get_active_ip_count("test_user").await, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn stale_incarnation_cleanup_cannot_release_recreated_user_ip() {
|
|
||||||
let tracker = UserIpTracker::new();
|
|
||||||
tracker.set_user_limit("test_user", 1).await;
|
|
||||||
let old_ip = test_ipv4(192, 168, 2, 1);
|
|
||||||
let current_ip = test_ipv4(192, 168, 2, 2);
|
|
||||||
let rejected_ip = test_ipv4(192, 168, 2, 3);
|
|
||||||
|
|
||||||
tracker
|
|
||||||
.check_and_add_for_incarnation("test_user", 1, old_ip)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
tracker.clear_user_ips_if_not_newer("test_user", 2).await;
|
|
||||||
tracker
|
|
||||||
.check_and_add_for_incarnation("test_user", 3, current_ip)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
tracker
|
|
||||||
.remove_ip_for_incarnation("test_user", 1, old_ip)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert!(tracker.is_ip_active("test_user", current_ip).await);
|
|
||||||
assert!(
|
|
||||||
tracker
|
|
||||||
.check_and_add_for_incarnation("test_user", 3, rejected_ip)
|
|
||||||
.await
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_is_ip_active() {
|
async fn test_is_ip_active() {
|
||||||
let tracker = UserIpTracker::new();
|
let tracker = UserIpTracker::new();
|
||||||
@@ -266,64 +233,6 @@ async fn test_load_limits_replaces_previous_map() {
|
|||||||
assert_eq!(tracker.get_user_limit("user2").await, Some(5));
|
assert_eq!(tracker.get_user_limit("user2").await, Some(5));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn stale_runtime_cannot_overwrite_newer_ip_policy() {
|
|
||||||
let tracker = UserIpTracker::new();
|
|
||||||
let mut newer = HashMap::new();
|
|
||||||
newer.insert("alice".to_string(), 5);
|
|
||||||
assert!(
|
|
||||||
tracker
|
|
||||||
.apply_policy_from_source(2, 7, &newer, UserMaxUniqueIpsMode::Combined, 90,)
|
|
||||||
.await
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut stale = HashMap::new();
|
|
||||||
stale.insert("alice".to_string(), 1);
|
|
||||||
assert!(
|
|
||||||
!tracker
|
|
||||||
.apply_policy_from_source(1, 1, &stale, UserMaxUniqueIpsMode::ActiveWindow, 1,)
|
|
||||||
.await
|
|
||||||
);
|
|
||||||
|
|
||||||
let policy = tracker.limit_policy.load_full();
|
|
||||||
assert_eq!(policy.source_generation, 2);
|
|
||||||
assert_eq!(policy.default_max_ips, 7);
|
|
||||||
assert_eq!(policy.max_ips["alice"], 5);
|
|
||||||
assert_eq!(policy.mode, UserMaxUniqueIpsMode::Combined);
|
|
||||||
assert_eq!(policy.window_secs, 90);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn active_runtime_can_publish_coherent_same_generation_ip_policy() {
|
|
||||||
let tracker = UserIpTracker::new();
|
|
||||||
assert!(
|
|
||||||
tracker
|
|
||||||
.apply_policy_from_source(
|
|
||||||
3,
|
|
||||||
1,
|
|
||||||
&HashMap::new(),
|
|
||||||
UserMaxUniqueIpsMode::ActiveWindow,
|
|
||||||
10,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
);
|
|
||||||
let mut limits = HashMap::new();
|
|
||||||
limits.insert("alice".to_string(), 4);
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
tracker
|
|
||||||
.apply_policy_from_source(3, 6, &limits, UserMaxUniqueIpsMode::TimeWindow, 30,)
|
|
||||||
.await
|
|
||||||
);
|
|
||||||
|
|
||||||
let policy = tracker.limit_policy.load_full();
|
|
||||||
assert_eq!(policy.source_generation, 3);
|
|
||||||
assert_eq!(policy.default_max_ips, 6);
|
|
||||||
assert_eq!(policy.max_ips["alice"], 4);
|
|
||||||
assert_eq!(policy.mode, UserMaxUniqueIpsMode::TimeWindow);
|
|
||||||
assert_eq!(policy.window_secs, 30);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
async fn concurrent_policy_replacement_never_exposes_partial_limit_map() {
|
async fn concurrent_policy_replacement_never_exposes_partial_limit_map() {
|
||||||
const USER_COUNT: usize = 4_096;
|
const USER_COUNT: usize = 4_096;
|
||||||
@@ -495,7 +404,10 @@ async fn test_compact_prunes_stale_recent_entries() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tracker.last_compact_epoch_secs.store(0, Ordering::Relaxed);
|
tracker.last_compact_epoch_secs.store(0, Ordering::Relaxed);
|
||||||
tracker.maybe_compact_empty_users().await;
|
tracker
|
||||||
|
.check_and_add("trigger-user", test_ipv4(10, 3, 0, 2))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let shard_idx = UserIpTracker::shard_idx(&stale_user);
|
let shard_idx = UserIpTracker::shard_idx(&stale_user);
|
||||||
let shard = tracker.shards[shard_idx].read().await;
|
let shard = tracker.shards[shard_idx].read().await;
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
use super::super::*;
|
|
||||||
use std::net::{IpAddr, Ipv4Addr};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
fn test_ipv4(oct1: u8, oct2: u8, oct3: u8, oct4: u8) -> IpAddr {
|
|
||||||
IpAddr::V4(Ipv4Addr::new(oct1, oct2, oct3, oct4))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn cancelled_cleanup_drain_restores_detached_batch() {
|
|
||||||
let tracker = Arc::new(UserIpTracker::new());
|
|
||||||
let user = "cancelled-cleanup-user";
|
|
||||||
let ip = test_ipv4(10, 2, 1, 1);
|
|
||||||
tracker.check_and_add(user, ip).await.unwrap();
|
|
||||||
tracker.enqueue_cleanup(user.to_string(), ip);
|
|
||||||
|
|
||||||
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
|
|
||||||
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
|
|
||||||
let held_tracker = Arc::clone(&tracker);
|
|
||||||
let held_user = user.to_string();
|
|
||||||
let holder = tokio::spawn(async move {
|
|
||||||
held_tracker
|
|
||||||
.hold_user_shard_for_tests(&held_user, entered_tx, release_rx)
|
|
||||||
.await;
|
|
||||||
});
|
|
||||||
entered_rx.await.unwrap();
|
|
||||||
|
|
||||||
let shard_idx = UserIpTracker::shard_idx(user);
|
|
||||||
let drain_tracker = Arc::clone(&tracker);
|
|
||||||
let drain = tokio::spawn(async move {
|
|
||||||
drain_tracker.drain_cleanup_shard(shard_idx).await;
|
|
||||||
});
|
|
||||||
tokio::time::timeout(Duration::from_secs(1), async {
|
|
||||||
while tracker.cleanup_queue_physical_entries_for_tests() != 0 {
|
|
||||||
tokio::task::yield_now().await;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("cleanup batch must detach before waiting for the IP shard");
|
|
||||||
|
|
||||||
drain.abort();
|
|
||||||
assert!(drain.await.unwrap_err().is_cancelled());
|
|
||||||
assert_eq!(tracker.cleanup_queue_len_for_tests(), 1);
|
|
||||||
assert_eq!(tracker.cleanup_queue_physical_entries_for_tests(), 1);
|
|
||||||
|
|
||||||
let _ = release_tx.send(());
|
|
||||||
holder.await.unwrap();
|
|
||||||
tracker.drain_cleanup_queue().await;
|
|
||||||
assert_eq!(tracker.cleanup_queue_len_for_tests(), 0);
|
|
||||||
assert_eq!(tracker.get_active_ip_count(user).await, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn clear_all_serializes_queue_reset_with_concurrent_enqueue() {
|
|
||||||
let tracker = Arc::new(UserIpTracker::new());
|
|
||||||
let first_shard_user = (0u64..)
|
|
||||||
.map(|index| format!("clear-race-{index}"))
|
|
||||||
.find(|user| UserIpTracker::shard_idx(user) == 0)
|
|
||||||
.unwrap();
|
|
||||||
let last_shard = USER_IP_TRACKER_SHARDS - 1;
|
|
||||||
let last_queue_guard = tracker.cleanup_shards[last_shard].queue.lock().unwrap();
|
|
||||||
|
|
||||||
let clear_tracker = Arc::clone(&tracker);
|
|
||||||
let clear = std::thread::spawn(move || {
|
|
||||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
runtime.block_on(clear_tracker.clear_all());
|
|
||||||
});
|
|
||||||
|
|
||||||
let wait_deadline = Instant::now() + Duration::from_secs(1);
|
|
||||||
loop {
|
|
||||||
match tracker.cleanup_shards[0].queue.try_lock() {
|
|
||||||
Ok(queue) => drop(queue),
|
|
||||||
Err(std::sync::TryLockError::WouldBlock) => break,
|
|
||||||
Err(std::sync::TryLockError::Poisoned(_)) => panic!("cleanup queue lock poisoned"),
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
Instant::now() < wait_deadline,
|
|
||||||
"clear_all did not reach queue reset"
|
|
||||||
);
|
|
||||||
std::thread::yield_now();
|
|
||||||
}
|
|
||||||
|
|
||||||
let (started_tx, started_rx) = std::sync::mpsc::channel();
|
|
||||||
let (completed_tx, completed_rx) = std::sync::mpsc::channel();
|
|
||||||
let enqueue_tracker = Arc::clone(&tracker);
|
|
||||||
let enqueue = std::thread::spawn(move || {
|
|
||||||
started_tx.send(()).unwrap();
|
|
||||||
enqueue_tracker.enqueue_cleanup(first_shard_user, test_ipv4(10, 2, 2, 1));
|
|
||||||
completed_tx.send(()).unwrap();
|
|
||||||
});
|
|
||||||
started_rx.recv().unwrap();
|
|
||||||
assert!(
|
|
||||||
completed_rx
|
|
||||||
.recv_timeout(Duration::from_millis(50))
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
|
|
||||||
drop(last_queue_guard);
|
|
||||||
clear.join().unwrap();
|
|
||||||
completed_rx.recv_timeout(Duration::from_secs(1)).unwrap();
|
|
||||||
enqueue.join().unwrap();
|
|
||||||
|
|
||||||
assert_eq!(tracker.cleanup_queue_len_for_tests(), 1);
|
|
||||||
assert_eq!(tracker.cleanup_queue_physical_entries_for_tests(), 1);
|
|
||||||
}
|
|
||||||
+49
-3
@@ -8,6 +8,8 @@
|
|||||||
// Infrastructure module used via CLI flags.
|
// Infrastructure module used via CLI flags.
|
||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
use crate::config::{LogRotation, LoggingConfig, LoggingDestination};
|
use crate::config::{LogRotation, LoggingConfig, LoggingDestination};
|
||||||
|
|
||||||
use tracing_subscriber::layer::SubscriberExt;
|
use tracing_subscriber::layer::SubscriberExt;
|
||||||
@@ -142,9 +144,31 @@ pub fn init_logging(
|
|||||||
}
|
}
|
||||||
|
|
||||||
LogDestination::File { options } => {
|
LogDestination::File { options } => {
|
||||||
let file_appender =
|
let (non_blocking, guard) = if options.max_size_bytes > 0
|
||||||
file::BoundedFileAppender::new(options.clone()).expect("Failed to open log file");
|
|| options.max_files > 0
|
||||||
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
|
|| options.max_age_secs > 0
|
||||||
|
{
|
||||||
|
let file_appender = file::BoundedFileAppender::new(options.clone())
|
||||||
|
.expect("Failed to open log file");
|
||||||
|
tracing_appender::non_blocking(file_appender)
|
||||||
|
} else if !matches!(options.rotation, LogRotation::Never) {
|
||||||
|
let path = Path::new(&options.path);
|
||||||
|
let dir = log_file_dir(path);
|
||||||
|
let prefix = log_file_name(path);
|
||||||
|
let file_appender = tracing_appender::rolling::RollingFileAppender::builder()
|
||||||
|
.rotation(to_tracing_rotation(options.rotation))
|
||||||
|
.filename_prefix(prefix)
|
||||||
|
.build(dir)
|
||||||
|
.expect("Failed to open log file");
|
||||||
|
tracing_appender::non_blocking(file_appender)
|
||||||
|
} else {
|
||||||
|
let file = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&options.path)
|
||||||
|
.expect("Failed to open log file");
|
||||||
|
tracing_appender::non_blocking(file)
|
||||||
|
};
|
||||||
|
|
||||||
let fmt_layer = fmt::Layer::default()
|
let fmt_layer = fmt::Layer::default()
|
||||||
.with_ansi(false)
|
.with_ansi(false)
|
||||||
@@ -161,6 +185,28 @@ pub fn init_logging(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn log_file_dir(path: &Path) -> &Path {
|
||||||
|
path.parent()
|
||||||
|
.filter(|parent| !parent.as_os_str().is_empty())
|
||||||
|
.unwrap_or_else(|| Path::new("."))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_file_name(path: &Path) -> &str {
|
||||||
|
path.file_name()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("telemt")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_tracing_rotation(rotation: LogRotation) -> tracing_appender::rolling::Rotation {
|
||||||
|
match rotation {
|
||||||
|
LogRotation::Never => tracing_appender::rolling::Rotation::NEVER,
|
||||||
|
LogRotation::Minutely => tracing_appender::rolling::Rotation::MINUTELY,
|
||||||
|
LogRotation::Hourly => tracing_appender::rolling::Rotation::HOURLY,
|
||||||
|
LogRotation::Daily => tracing_appender::rolling::Rotation::DAILY,
|
||||||
|
LogRotation::Weekly => tracing_appender::rolling::Rotation::WEEKLY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Syslog writer for tracing.
|
/// Syslog writer for tracing.
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
|
|||||||
+140
-184
@@ -1,26 +1,8 @@
|
|||||||
#[cfg(not(unix))]
|
use std::fs::{self, File, OpenOptions};
|
||||||
use std::fs::OpenOptions;
|
|
||||||
use std::fs::{self, File};
|
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
use std::ffi::OsString;
|
|
||||||
#[cfg(unix)]
|
|
||||||
use std::os::fd::OwnedFd;
|
|
||||||
#[cfg(unix)]
|
|
||||||
use std::os::unix::ffi::OsStringExt;
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
use nix::dir::Dir;
|
|
||||||
#[cfg(unix)]
|
|
||||||
use nix::fcntl::{OFlag, openat, renameat};
|
|
||||||
#[cfg(unix)]
|
|
||||||
use nix::sys::stat::Mode;
|
|
||||||
#[cfg(unix)]
|
|
||||||
use nix::unistd::{UnlinkatFlags, dup, unlinkat};
|
|
||||||
|
|
||||||
use chrono::{DateTime, Datelike, Duration as ChronoDuration, Utc};
|
use chrono::{DateTime, Datelike, Duration as ChronoDuration, Utc};
|
||||||
|
|
||||||
use crate::config::LogRotation;
|
use crate::config::LogRotation;
|
||||||
@@ -38,8 +20,6 @@ pub(crate) struct BoundedFileAppender {
|
|||||||
current_size: u64,
|
current_size: u64,
|
||||||
last_cleanup: DateTime<Utc>,
|
last_cleanup: DateTime<Utc>,
|
||||||
file: Option<File>,
|
file: Option<File>,
|
||||||
#[cfg(unix)]
|
|
||||||
dir_fd: OwnedFd,
|
|
||||||
now: Box<dyn Fn() -> DateTime<Utc> + Send + Sync>,
|
now: Box<dyn Fn() -> DateTime<Utc> + Send + Sync>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,11 +46,6 @@ impl BoundedFileAppender {
|
|||||||
|
|
||||||
let start = now();
|
let start = now();
|
||||||
let current_path = active_path_for(&dir, &base_name, options.rotation, &start);
|
let current_path = active_path_for(&dir, &base_name, options.rotation, &start);
|
||||||
#[cfg(unix)]
|
|
||||||
let dir_fd = crate::util::secure_fs::open_trusted_dir_nofollow_or_create(&dir, 0o750)?;
|
|
||||||
#[cfg(unix)]
|
|
||||||
let (file, current_size) = open_append_file(&dir_fd, ¤t_path)?;
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
let (file, current_size) = open_append_file(¤t_path)?;
|
let (file, current_size) = open_append_file(¤t_path)?;
|
||||||
let mut appender = Self {
|
let mut appender = Self {
|
||||||
options,
|
options,
|
||||||
@@ -80,8 +55,6 @@ impl BoundedFileAppender {
|
|||||||
current_size,
|
current_size,
|
||||||
last_cleanup: start,
|
last_cleanup: start,
|
||||||
file: Some(file),
|
file: Some(file),
|
||||||
#[cfg(unix)]
|
|
||||||
dir_fd,
|
|
||||||
now,
|
now,
|
||||||
};
|
};
|
||||||
appender.cleanup(&start);
|
appender.cleanup(&start);
|
||||||
@@ -106,8 +79,10 @@ impl BoundedFileAppender {
|
|||||||
|
|
||||||
fn rotate_for_size(&mut self, now: &DateTime<Utc>) -> io::Result<()> {
|
fn rotate_for_size(&mut self, now: &DateTime<Utc>) -> io::Result<()> {
|
||||||
self.close_current()?;
|
self.close_current()?;
|
||||||
|
if self.current_path.exists() {
|
||||||
let archive_path = self.archive_path(now);
|
let archive_path = self.archive_path(now);
|
||||||
self.rename_current_if_present(&archive_path)?;
|
fs::rename(&self.current_path, archive_path)?;
|
||||||
|
}
|
||||||
self.open_current()
|
self.open_current()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +95,7 @@ impl BoundedFileAppender {
|
|||||||
let stamp = now.format("%Y%m%d%H%M%S");
|
let stamp = now.format("%Y%m%d%H%M%S");
|
||||||
for seq in 0..1000 {
|
for seq in 0..1000 {
|
||||||
let candidate = self.dir.join(format!("{file_name}.{stamp}.{seq}"));
|
let candidate = self.dir.join(format!("{file_name}.{stamp}.{seq}"));
|
||||||
if !self.path_exists(&candidate) {
|
if !candidate.exists() {
|
||||||
return candidate;
|
return candidate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,9 +103,6 @@ impl BoundedFileAppender {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn open_current(&mut self) -> io::Result<()> {
|
fn open_current(&mut self) -> io::Result<()> {
|
||||||
#[cfg(unix)]
|
|
||||||
let (file, current_size) = open_append_file(&self.dir_fd, &self.current_path)?;
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
let (file, current_size) = open_append_file(&self.current_path)?;
|
let (file, current_size) = open_append_file(&self.current_path)?;
|
||||||
self.file = Some(file);
|
self.file = Some(file);
|
||||||
self.current_size = current_size;
|
self.current_size = current_size;
|
||||||
@@ -158,10 +130,40 @@ impl BoundedFileAppender {
|
|||||||
|
|
||||||
fn cleanup(&mut self, now: &DateTime<Utc>) {
|
fn cleanup(&mut self, now: &DateTime<Utc>) {
|
||||||
self.last_cleanup = now.clone();
|
self.last_cleanup = now.clone();
|
||||||
let Ok(mut candidates) = self.collect_candidates() else {
|
let Ok(entries) = fs::read_dir(&self.dir) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let mut candidates = Vec::new();
|
||||||
|
let prefix = format!("{}.", self.base_name);
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
let Ok(file_type) = entry.file_type() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !file_type.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_current = path == self.current_path;
|
||||||
|
let Some(name) = entry.file_name().to_str().map(|name| name.to_string()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !is_current && !name.starts_with(&prefix) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(metadata) = entry.metadata() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let modified = metadata.modified().unwrap_or(UNIX_EPOCH);
|
||||||
|
candidates.push(LogFileCandidate {
|
||||||
|
path,
|
||||||
|
modified,
|
||||||
|
is_current,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if self.options.max_age_secs > 0 {
|
if self.options.max_age_secs > 0 {
|
||||||
let cutoff = system_time_from_utc(now)
|
let cutoff = system_time_from_utc(now)
|
||||||
.checked_sub(Duration::from_secs(self.options.max_age_secs))
|
.checked_sub(Duration::from_secs(self.options.max_age_secs))
|
||||||
@@ -170,7 +172,7 @@ impl BoundedFileAppender {
|
|||||||
if candidate.is_current || candidate.modified >= cutoff {
|
if candidate.is_current || candidate.modified >= cutoff {
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
self.remove_candidate(candidate);
|
let _ = fs::remove_file(&candidate.path);
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -187,147 +189,11 @@ impl BoundedFileAppender {
|
|||||||
if total <= self.options.max_files {
|
if total <= self.options.max_files {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
self.remove_candidate(&candidate);
|
let _ = fs::remove_file(candidate.path);
|
||||||
total -= 1;
|
total -= 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn path_exists(&self, path: &Path) -> bool {
|
|
||||||
let Some(name) = path.file_name() else {
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
match openat(
|
|
||||||
&self.dir_fd,
|
|
||||||
name,
|
|
||||||
OFlag::O_RDONLY | OFlag::O_NONBLOCK | OFlag::O_NOFOLLOW | OFlag::O_CLOEXEC,
|
|
||||||
Mode::empty(),
|
|
||||||
) {
|
|
||||||
Ok(_) => true,
|
|
||||||
Err(nix::errno::Errno::ENOENT) => false,
|
|
||||||
Err(_) => true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
fn path_exists(&self, path: &Path) -> bool {
|
|
||||||
path.exists()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn rename_current_if_present(&self, archive_path: &Path) -> io::Result<()> {
|
|
||||||
let current_name = self.current_path.file_name().ok_or_else(|| {
|
|
||||||
io::Error::new(io::ErrorKind::InvalidInput, "log path has no file name")
|
|
||||||
})?;
|
|
||||||
let archive_name = archive_path.file_name().ok_or_else(|| {
|
|
||||||
io::Error::new(io::ErrorKind::InvalidInput, "archive path has no file name")
|
|
||||||
})?;
|
|
||||||
match renameat(&self.dir_fd, current_name, &self.dir_fd, archive_name) {
|
|
||||||
Ok(()) => Ok(()),
|
|
||||||
Err(nix::errno::Errno::ENOENT) => Ok(()),
|
|
||||||
Err(error) => Err(io::Error::from_raw_os_error(error as i32)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
fn rename_current_if_present(&self, archive_path: &Path) -> io::Result<()> {
|
|
||||||
if self.current_path.exists() {
|
|
||||||
fs::rename(&self.current_path, archive_path)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn collect_candidates(&self) -> io::Result<Vec<LogFileCandidate>> {
|
|
||||||
use std::os::unix::fs::MetadataExt;
|
|
||||||
|
|
||||||
let descriptor =
|
|
||||||
dup(&self.dir_fd).map_err(|error| io::Error::from_raw_os_error(error as i32))?;
|
|
||||||
let mut directory =
|
|
||||||
Dir::from_fd(descriptor).map_err(|error| io::Error::from_raw_os_error(error as i32))?;
|
|
||||||
let mut candidates = Vec::new();
|
|
||||||
let prefix = format!("{}.", self.base_name);
|
|
||||||
for entry in directory.iter().flatten() {
|
|
||||||
let bytes = entry.file_name().to_bytes();
|
|
||||||
if bytes == b"." || bytes == b".." {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let name = OsString::from_vec(bytes.to_vec());
|
|
||||||
let path = self.dir.join(&name);
|
|
||||||
let is_current = path == self.current_path;
|
|
||||||
let Some(name_text) = name.to_str() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if !is_current && !name_text.starts_with(&prefix) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Ok(descriptor) = openat(
|
|
||||||
&self.dir_fd,
|
|
||||||
name.as_os_str(),
|
|
||||||
OFlag::O_RDONLY | OFlag::O_NONBLOCK | OFlag::O_NOFOLLOW | OFlag::O_CLOEXEC,
|
|
||||||
Mode::empty(),
|
|
||||||
) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let file = File::from(descriptor);
|
|
||||||
let Ok(metadata) = file.metadata() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if !metadata.is_file() || metadata.nlink() != 1 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
candidates.push(LogFileCandidate {
|
|
||||||
path,
|
|
||||||
modified: metadata.modified().unwrap_or(UNIX_EPOCH),
|
|
||||||
is_current,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(candidates)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
fn collect_candidates(&self) -> io::Result<Vec<LogFileCandidate>> {
|
|
||||||
let mut candidates = Vec::new();
|
|
||||||
let prefix = format!("{}.", self.base_name);
|
|
||||||
for entry in fs::read_dir(&self.dir)?.flatten() {
|
|
||||||
let path = entry.path();
|
|
||||||
let Ok(file_type) = entry.file_type() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if !file_type.is_file() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let is_current = path == self.current_path;
|
|
||||||
let Some(name) = entry.file_name().to_str().map(str::to_string) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if !is_current && !name.starts_with(&prefix) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Ok(metadata) = entry.metadata() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
candidates.push(LogFileCandidate {
|
|
||||||
path,
|
|
||||||
modified: metadata.modified().unwrap_or(UNIX_EPOCH),
|
|
||||||
is_current,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(candidates)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn remove_candidate(&self, candidate: &LogFileCandidate) {
|
|
||||||
if let Some(name) = candidate.path.file_name() {
|
|
||||||
let _ = unlinkat(&self.dir_fd, name, UnlinkatFlags::NoRemoveDir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
fn remove_candidate(&self, candidate: &LogFileCandidate) {
|
|
||||||
let _ = fs::remove_file(&candidate.path);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Write for BoundedFileAppender {
|
impl Write for BoundedFileAppender {
|
||||||
@@ -367,17 +233,6 @@ struct LogFileCandidate {
|
|||||||
is_current: bool,
|
is_current: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn open_append_file(dir_fd: &OwnedFd, path: &Path) -> io::Result<(File, u64)> {
|
|
||||||
let name = path
|
|
||||||
.file_name()
|
|
||||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "log path has no file name"))?;
|
|
||||||
let file = crate::util::secure_fs::open_append_regular_at(dir_fd, name, 0o640)?;
|
|
||||||
let current_size = file.metadata()?.len();
|
|
||||||
Ok((file, current_size))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
fn open_append_file(path: &Path) -> io::Result<(File, u64)> {
|
fn open_append_file(path: &Path) -> io::Result<(File, u64)> {
|
||||||
let mut options = OpenOptions::new();
|
let mut options = OpenOptions::new();
|
||||||
options.create(true).append(true);
|
options.create(true).append(true);
|
||||||
@@ -436,4 +291,105 @@ fn system_time_from_utc(now: &DateTime<Utc>) -> SystemTime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests {
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn fixed_now() -> DateTime<Utc> {
|
||||||
|
DateTime::<Utc>::from(UNIX_EPOCH + Duration::from_secs(10))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn options(path: PathBuf) -> FileLogOptions {
|
||||||
|
FileLogOptions {
|
||||||
|
path: path.to_string_lossy().to_string(),
|
||||||
|
rotation: LogRotation::Never,
|
||||||
|
max_size_bytes: 0,
|
||||||
|
max_files: 0,
|
||||||
|
max_age_secs: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matching_logs(dir: &Path) -> Vec<PathBuf> {
|
||||||
|
let mut files: Vec<_> = fs::read_dir(dir)
|
||||||
|
.unwrap()
|
||||||
|
.flatten()
|
||||||
|
.map(|entry| entry.path())
|
||||||
|
.filter(|path| {
|
||||||
|
path.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.map(|name| name.starts_with("telemt.log"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
files.sort();
|
||||||
|
files
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn size_rotation_keeps_latest_write_in_active_file() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let path = dir.path().join("telemt.log");
|
||||||
|
let mut options = options(path.clone());
|
||||||
|
options.max_size_bytes = 6;
|
||||||
|
|
||||||
|
let mut appender = BoundedFileAppender::with_now(options, Box::new(fixed_now)).unwrap();
|
||||||
|
appender.write_all(b"abc\n").unwrap();
|
||||||
|
appender.write_all(b"def\n").unwrap();
|
||||||
|
appender.flush().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(fs::read_to_string(path).unwrap(), "def\n");
|
||||||
|
assert_eq!(matching_logs(dir.path()).len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn max_files_retention_removes_oldest_archives() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let path = dir.path().join("telemt.log");
|
||||||
|
let mut options = options(path);
|
||||||
|
options.max_size_bytes = 4;
|
||||||
|
options.max_files = 2;
|
||||||
|
|
||||||
|
let mut appender = BoundedFileAppender::with_now(options, Box::new(fixed_now)).unwrap();
|
||||||
|
for line in [b"aa\n", b"bb\n", b"cc\n", b"dd\n"] {
|
||||||
|
appender.write_all(line).unwrap();
|
||||||
|
}
|
||||||
|
appender.flush().unwrap();
|
||||||
|
|
||||||
|
assert!(matching_logs(dir.path()).len() <= 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn max_age_retention_removes_old_archives() {
|
||||||
|
use std::ffi::CString;
|
||||||
|
use std::os::unix::ffi::OsStrExt;
|
||||||
|
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let path = dir.path().join("telemt.log");
|
||||||
|
let old_archive = dir.path().join("telemt.log.20000101000000.0");
|
||||||
|
fs::write(&old_archive, "old").unwrap();
|
||||||
|
|
||||||
|
let c_path = CString::new(old_archive.as_os_str().as_bytes()).unwrap();
|
||||||
|
let times = [
|
||||||
|
libc::timespec {
|
||||||
|
tv_sec: 0,
|
||||||
|
tv_nsec: 0,
|
||||||
|
},
|
||||||
|
libc::timespec {
|
||||||
|
tv_sec: 0,
|
||||||
|
tv_nsec: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let rc = unsafe { libc::utimensat(libc::AT_FDCWD, c_path.as_ptr(), times.as_ptr(), 0) };
|
||||||
|
assert_eq!(rc, 0);
|
||||||
|
|
||||||
|
let mut options = options(path);
|
||||||
|
options.max_age_secs = 1;
|
||||||
|
let _appender = BoundedFileAppender::with_now(options, Box::new(fixed_now)).unwrap();
|
||||||
|
|
||||||
|
assert!(!old_archive.exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
use std::io::Write;
|
|
||||||
|
|
||||||
use tempfile::tempdir;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn fixed_now() -> DateTime<Utc> {
|
|
||||||
DateTime::<Utc>::from(UNIX_EPOCH + Duration::from_secs(10))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn options(path: PathBuf) -> FileLogOptions {
|
|
||||||
FileLogOptions {
|
|
||||||
path: path.to_string_lossy().to_string(),
|
|
||||||
rotation: LogRotation::Never,
|
|
||||||
max_size_bytes: 0,
|
|
||||||
max_files: 0,
|
|
||||||
max_age_secs: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn matching_logs(dir: &Path) -> Vec<PathBuf> {
|
|
||||||
let mut files: Vec<_> = fs::read_dir(dir)
|
|
||||||
.unwrap()
|
|
||||||
.flatten()
|
|
||||||
.map(|entry| entry.path())
|
|
||||||
.filter(|path| {
|
|
||||||
path.file_name()
|
|
||||||
.and_then(|name| name.to_str())
|
|
||||||
.map(|name| name.starts_with("telemt.log"))
|
|
||||||
.unwrap_or(false)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
files.sort();
|
|
||||||
files
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn size_rotation_keeps_latest_write_in_active_file() {
|
|
||||||
let dir = tempdir().unwrap();
|
|
||||||
let path = dir.path().join("telemt.log");
|
|
||||||
let mut options = options(path.clone());
|
|
||||||
options.max_size_bytes = 6;
|
|
||||||
|
|
||||||
let mut appender = BoundedFileAppender::with_now(options, Box::new(fixed_now)).unwrap();
|
|
||||||
appender.write_all(b"abc\n").unwrap();
|
|
||||||
appender.write_all(b"def\n").unwrap();
|
|
||||||
appender.flush().unwrap();
|
|
||||||
|
|
||||||
assert_eq!(fs::read_to_string(path).unwrap(), "def\n");
|
|
||||||
assert_eq!(matching_logs(dir.path()).len(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn max_files_retention_removes_oldest_archives() {
|
|
||||||
let dir = tempdir().unwrap();
|
|
||||||
let path = dir.path().join("telemt.log");
|
|
||||||
let mut options = options(path);
|
|
||||||
options.max_size_bytes = 4;
|
|
||||||
options.max_files = 2;
|
|
||||||
|
|
||||||
let mut appender = BoundedFileAppender::with_now(options, Box::new(fixed_now)).unwrap();
|
|
||||||
for line in [b"aa\n", b"bb\n", b"cc\n", b"dd\n"] {
|
|
||||||
appender.write_all(line).unwrap();
|
|
||||||
}
|
|
||||||
appender.flush().unwrap();
|
|
||||||
|
|
||||||
assert!(matching_logs(dir.path()).len() <= 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[test]
|
|
||||||
fn max_age_retention_removes_old_archives() {
|
|
||||||
use std::ffi::CString;
|
|
||||||
use std::os::unix::ffi::OsStrExt;
|
|
||||||
|
|
||||||
let dir = tempdir().unwrap();
|
|
||||||
let path = dir.path().join("telemt.log");
|
|
||||||
let old_archive = dir.path().join("telemt.log.20000101000000.0");
|
|
||||||
fs::write(&old_archive, "old").unwrap();
|
|
||||||
|
|
||||||
let c_path = CString::new(old_archive.as_os_str().as_bytes()).unwrap();
|
|
||||||
let times = [
|
|
||||||
libc::timespec {
|
|
||||||
tv_sec: 0,
|
|
||||||
tv_nsec: 0,
|
|
||||||
},
|
|
||||||
libc::timespec {
|
|
||||||
tv_sec: 0,
|
|
||||||
tv_nsec: 0,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
let rc = unsafe { libc::utimensat(libc::AT_FDCWD, c_path.as_ptr(), times.as_ptr(), 0) };
|
|
||||||
assert_eq!(rc, 0);
|
|
||||||
|
|
||||||
let mut options = options(path);
|
|
||||||
options.max_age_secs = 1;
|
|
||||||
let _appender = BoundedFileAppender::with_now(options, Box::new(fixed_now)).unwrap();
|
|
||||||
|
|
||||||
assert!(!old_archive.exists());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[test]
|
|
||||||
fn rotation_stays_bound_to_opened_directory_after_path_replacement() {
|
|
||||||
use std::os::unix::fs::symlink;
|
|
||||||
|
|
||||||
let root = tempdir().unwrap();
|
|
||||||
let original = root.path().join("logs");
|
|
||||||
let moved = root.path().join("logs-moved");
|
|
||||||
let redirect = root.path().join("redirect");
|
|
||||||
fs::create_dir(&original).unwrap();
|
|
||||||
fs::create_dir(&redirect).unwrap();
|
|
||||||
let mut options = options(original.join("telemt.log"));
|
|
||||||
options.max_size_bytes = 4;
|
|
||||||
let mut appender = BoundedFileAppender::with_now(options, Box::new(fixed_now)).unwrap();
|
|
||||||
appender.write_all(b"aa\n").unwrap();
|
|
||||||
fs::rename(&original, &moved).unwrap();
|
|
||||||
symlink(&redirect, &original).unwrap();
|
|
||||||
|
|
||||||
appender.write_all(b"bb\n").unwrap();
|
|
||||||
appender.flush().unwrap();
|
|
||||||
|
|
||||||
assert!(!matching_logs(&moved).is_empty());
|
|
||||||
assert!(matching_logs(&redirect).is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[test]
|
|
||||||
fn appender_rejects_group_writable_log_directory() {
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
|
|
||||||
let current = std::env::current_dir().unwrap();
|
|
||||||
let dir = tempfile::Builder::new()
|
|
||||||
.prefix("telemt-untrusted-log-")
|
|
||||||
.tempdir_in(current)
|
|
||||||
.unwrap();
|
|
||||||
fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o770)).unwrap();
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
BoundedFileAppender::with_now(options(dir.path().join("telemt.log")), Box::new(fixed_now),)
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+41
-28
@@ -74,7 +74,26 @@ pub(super) async fn bootstrap(
|
|||||||
data_path.as_deref(),
|
data_path.as_deref(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Err(e) = enter_runtime_directory(&runtime_base_dir) {
|
if !runtime_base_dir.exists()
|
||||||
|
&& let Err(e) = std::fs::create_dir_all(&runtime_base_dir)
|
||||||
|
{
|
||||||
|
eprintln!(
|
||||||
|
"[telemt] Can't create runtime directory {}: {}",
|
||||||
|
runtime_base_dir.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !runtime_base_dir.is_dir() {
|
||||||
|
eprintln!(
|
||||||
|
"[telemt] Runtime path exists but is not a directory: {}",
|
||||||
|
runtime_base_dir.display()
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = std::env::set_current_dir(&runtime_base_dir) {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[telemt] Can't use runtime directory {}: {}",
|
"[telemt] Can't use runtime directory {}: {}",
|
||||||
runtime_base_dir.display(),
|
runtime_base_dir.display(),
|
||||||
@@ -106,7 +125,7 @@ pub(super) async fn bootstrap(
|
|||||||
|
|
||||||
if config_path_explicit {
|
if config_path_explicit {
|
||||||
if let Some(serialized) = serialized.as_ref() {
|
if let Some(serialized) = serialized.as_ref() {
|
||||||
if let Err(write_error) = write_private_file(&config_path, serialized) {
|
if let Err(write_error) = std::fs::write(&config_path, serialized) {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[telemt] Error: failed to create explicit config at {}: {}",
|
"[telemt] Error: failed to create explicit config at {}: {}",
|
||||||
config_path.display(),
|
config_path.display(),
|
||||||
@@ -130,7 +149,7 @@ pub(super) async fn bootstrap(
|
|||||||
|
|
||||||
if let Some(serialized) = serialized.as_ref() {
|
if let Some(serialized) = serialized.as_ref() {
|
||||||
match std::fs::create_dir_all(&runtime_base_dir) {
|
match std::fs::create_dir_all(&runtime_base_dir) {
|
||||||
Ok(()) => match write_private_file(&runtime_config_path, serialized) {
|
Ok(()) => match std::fs::write(&runtime_config_path, serialized) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
config_path = runtime_config_path;
|
config_path = runtime_config_path;
|
||||||
eprintln!(
|
eprintln!(
|
||||||
@@ -157,7 +176,7 @@ pub(super) async fn bootstrap(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !persisted {
|
if !persisted {
|
||||||
match write_private_file(&fallback_config_path, serialized) {
|
match std::fs::write(&fallback_config_path, serialized) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
config_path = fallback_config_path;
|
config_path = fallback_config_path;
|
||||||
eprintln!(
|
eprintln!(
|
||||||
@@ -207,7 +226,24 @@ pub(super) async fn bootstrap(
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(e) = enter_runtime_directory(data_path) {
|
if data_path.exists() {
|
||||||
|
if !data_path.is_dir() {
|
||||||
|
eprintln!(
|
||||||
|
"[telemt] data_path exists but is not a directory: {}",
|
||||||
|
data_path.display()
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
} else if let Err(e) = std::fs::create_dir_all(data_path) {
|
||||||
|
eprintln!(
|
||||||
|
"[telemt] Can't create data_path {}: {}",
|
||||||
|
data_path.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = std::env::set_current_dir(data_path) {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[telemt] Can't use data_path {}: {}",
|
"[telemt] Can't use data_path {}: {}",
|
||||||
data_path.display(),
|
data_path.display(),
|
||||||
@@ -340,26 +376,3 @@ pub(super) async fn bootstrap(
|
|||||||
logging_guard,
|
logging_guard,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn enter_runtime_directory(path: &std::path::Path) -> std::io::Result<()> {
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
crate::util::secure_fs::chdir_nofollow_or_create(path, 0o750)
|
|
||||||
}
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
{
|
|
||||||
std::fs::create_dir_all(path)?;
|
|
||||||
std::env::set_current_dir(path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_private_file(path: &std::path::Path, contents: &str) -> std::io::Result<()> {
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
crate::util::secure_fs::atomic_replace(path, contents.as_bytes(), 0o600)
|
|
||||||
}
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
{
|
|
||||||
std::fs::write(path, contents)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -120,34 +120,6 @@ impl ProcessControlPlane {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registers work that must finish once accepted, even after shutdown cancellation starts.
|
|
||||||
pub(crate) fn spawn_completion<F>(&self, future: F) -> Result<(), F>
|
|
||||||
where
|
|
||||||
F: Future<Output = ()> + Send + 'static,
|
|
||||||
{
|
|
||||||
let Some(registration) = self.inner.admission.try_register() else {
|
|
||||||
return Err(future);
|
|
||||||
};
|
|
||||||
self.inner.tasks.spawn(future);
|
|
||||||
drop(registration);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Registers a cooperatively cancelled task whose cleanup future must finish.
|
|
||||||
pub(crate) fn spawn_cooperative<S, F>(&self, spawn: S) -> Result<(), S>
|
|
||||||
where
|
|
||||||
S: FnOnce(CancellationToken) -> F,
|
|
||||||
F: Future<Output = ()> + Send + 'static,
|
|
||||||
{
|
|
||||||
let Some(registration) = self.inner.admission.try_register() else {
|
|
||||||
return Err(spawn);
|
|
||||||
};
|
|
||||||
let cancellation = self.inner.cancellation.clone();
|
|
||||||
self.inner.tasks.spawn(spawn(cancellation));
|
|
||||||
drop(registration);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Closes task admission, cancels all owned work, and joins it within the deadline.
|
/// Closes task admission, cancels all owned work, and joins it within the deadline.
|
||||||
pub(crate) async fn shutdown(&self, timeout: Duration) -> bool {
|
pub(crate) async fn shutdown(&self, timeout: Duration) -> bool {
|
||||||
let deadline = tokio::time::Instant::now() + timeout;
|
let deadline = tokio::time::Instant::now() + timeout;
|
||||||
@@ -242,49 +214,4 @@ mod tests {
|
|||||||
|
|
||||||
assert!(scope.shutdown(Duration::from_secs(1)).await);
|
assert!(scope.shutdown(Duration::from_secs(1)).await);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn shutdown_waits_for_accepted_completion_without_cancelling_it() {
|
|
||||||
let scope = ProcessControlPlane::new();
|
|
||||||
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
|
|
||||||
let completed = Arc::new(AtomicBool::new(false));
|
|
||||||
let completed_task = completed.clone();
|
|
||||||
assert!(
|
|
||||||
scope
|
|
||||||
.spawn_completion(async move {
|
|
||||||
let _ = release_rx.await;
|
|
||||||
completed_task.store(true, Ordering::Release);
|
|
||||||
})
|
|
||||||
.is_ok()
|
|
||||||
);
|
|
||||||
|
|
||||||
let shutdown_scope = scope.clone();
|
|
||||||
let shutdown =
|
|
||||||
tokio::spawn(async move { shutdown_scope.shutdown(Duration::from_secs(1)).await });
|
|
||||||
tokio::task::yield_now().await;
|
|
||||||
assert!(!shutdown.is_finished());
|
|
||||||
assert!(!completed.load(Ordering::Acquire));
|
|
||||||
|
|
||||||
release_tx.send(()).unwrap();
|
|
||||||
assert!(shutdown.await.unwrap());
|
|
||||||
assert!(completed.load(Ordering::Acquire));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn cooperative_task_observes_cancellation_and_finishes_cleanup() {
|
|
||||||
let scope = ProcessControlPlane::new();
|
|
||||||
let completed = Arc::new(AtomicBool::new(false));
|
|
||||||
let completed_task = Arc::clone(&completed);
|
|
||||||
assert!(
|
|
||||||
scope
|
|
||||||
.spawn_cooperative(move |cancellation| async move {
|
|
||||||
cancellation.cancelled().await;
|
|
||||||
completed_task.store(true, Ordering::Release);
|
|
||||||
})
|
|
||||||
.is_ok()
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(scope.shutdown(Duration::from_secs(1)).await);
|
|
||||||
assert!(completed.load(Ordering::Acquire));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,11 +22,6 @@ use crate::tls_front::TlsFrontCache;
|
|||||||
use crate::transport::UpstreamManager;
|
use crate::transport::UpstreamManager;
|
||||||
use crate::transport::middle_proxy::MePool;
|
use crate::transport::middle_proxy::MePool;
|
||||||
|
|
||||||
// Cancellation guards preserve runtime ownership across preparation and drain futures.
|
|
||||||
mod lifecycle;
|
|
||||||
pub(crate) use lifecycle::RuntimeTaskScopePreparationGuard;
|
|
||||||
use lifecycle::SessionDrainCancellationGuard;
|
|
||||||
|
|
||||||
const SESSION_STOP_TIMEOUT: Duration = Duration::from_secs(5);
|
const SESSION_STOP_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
const BACKGROUND_STOP_TIMEOUT: Duration = Duration::from_secs(5);
|
const BACKGROUND_STOP_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
const SESSION_ADMISSION_CLOSED: usize = 1 << (usize::BITS - 1);
|
const SESSION_ADMISSION_CLOSED: usize = 1 << (usize::BITS - 1);
|
||||||
@@ -150,17 +145,12 @@ impl RuntimeTaskScope {
|
|||||||
self.cancel.clone()
|
self.cancel.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Synchronously closes task admission and signals every tracked task.
|
|
||||||
pub(crate) fn begin_stop(&self) {
|
|
||||||
self.admission.close();
|
|
||||||
self.cancel.cancel();
|
|
||||||
self.tracker.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cancels the scope and waits within the bounded background-task budget.
|
/// Cancels the scope and waits within the bounded background-task budget.
|
||||||
pub(crate) async fn stop(&self) {
|
pub(crate) async fn stop(&self) {
|
||||||
self.begin_stop();
|
self.admission.close();
|
||||||
self.admission.wait_for_registrations().await;
|
self.admission.wait_for_registrations().await;
|
||||||
|
self.cancel.cancel();
|
||||||
|
self.tracker.close();
|
||||||
let _ = tokio::time::timeout(BACKGROUND_STOP_TIMEOUT, self.tracker.wait()).await;
|
let _ = tokio::time::timeout(BACKGROUND_STOP_TIMEOUT, self.tracker.wait()).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -308,31 +298,24 @@ impl RuntimeGeneration {
|
|||||||
/// Waits for registered sessions and cancels them when the deadline expires.
|
/// Waits for registered sessions and cancels them when the deadline expires.
|
||||||
pub(crate) async fn drain_sessions(&self, timeout: Duration) -> bool {
|
pub(crate) async fn drain_sessions(&self, timeout: Duration) -> bool {
|
||||||
self.stop_accepting_sessions();
|
self.stop_accepting_sessions();
|
||||||
let mut cancellation_guard = SessionDrainCancellationGuard::new(self);
|
|
||||||
self.session_admission.wait_for_registrations().await;
|
self.session_admission.wait_for_registrations().await;
|
||||||
self.sessions.close();
|
self.sessions.close();
|
||||||
if tokio::time::timeout(timeout, self.sessions.wait())
|
if tokio::time::timeout(timeout, self.sessions.wait())
|
||||||
.await
|
.await
|
||||||
.is_ok()
|
.is_ok()
|
||||||
{
|
{
|
||||||
cancellation_guard.disarm();
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
cancellation_guard.disarm();
|
|
||||||
self.stop_sessions().await;
|
self.stop_sessions().await;
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
fn begin_stop_sessions(&self) {
|
|
||||||
self.stop_accepting_sessions();
|
|
||||||
self.session_cancel.cancel();
|
|
||||||
self.sessions.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cancels all sessions and waits within the bounded session-stop budget.
|
/// Cancels all sessions and waits within the bounded session-stop budget.
|
||||||
pub(crate) async fn stop_sessions(&self) {
|
pub(crate) async fn stop_sessions(&self) {
|
||||||
self.begin_stop_sessions();
|
self.stop_accepting_sessions();
|
||||||
self.session_admission.wait_for_registrations().await;
|
self.session_admission.wait_for_registrations().await;
|
||||||
|
self.session_cancel.cancel();
|
||||||
|
self.sessions.close();
|
||||||
let _ = tokio::time::timeout(SESSION_STOP_TIMEOUT, self.sessions.wait()).await;
|
let _ = tokio::time::timeout(SESSION_STOP_TIMEOUT, self.sessions.wait()).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,8 +335,6 @@ impl RuntimeGeneration {
|
|||||||
|
|
||||||
impl Drop for RuntimeGeneration {
|
impl Drop for RuntimeGeneration {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
self.background_tasks.begin_stop();
|
|
||||||
self.begin_stop_sessions();
|
|
||||||
if let Some(pool) = self.me_pool.as_ref() {
|
if let Some(pool) = self.me_pool.as_ref() {
|
||||||
pool.begin_shutdown();
|
pool.begin_shutdown();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,158 +0,0 @@
|
|||||||
use super::*;
|
|
||||||
|
|
||||||
/// Cancels tasks if runtime preparation exits before ownership reaches a generation.
|
|
||||||
#[must_use = "runtime preparation guards must be disarmed after ownership transfer"]
|
|
||||||
pub(crate) struct RuntimeTaskScopePreparationGuard {
|
|
||||||
scope: RuntimeTaskScope,
|
|
||||||
armed: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RuntimeTaskScopePreparationGuard {
|
|
||||||
/// Arms cancellation for a newly created, not-yet-published task scope.
|
|
||||||
pub(crate) fn new(scope: RuntimeTaskScope) -> Self {
|
|
||||||
Self { scope, armed: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Confirms that a runtime generation now owns the task scope.
|
|
||||||
pub(crate) fn disarm(mut self) {
|
|
||||||
self.armed = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for RuntimeTaskScopePreparationGuard {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
if self.armed {
|
|
||||||
self.scope.begin_stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) struct SessionDrainCancellationGuard<'a> {
|
|
||||||
generation: &'a RuntimeGeneration,
|
|
||||||
armed: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> SessionDrainCancellationGuard<'a> {
|
|
||||||
pub(super) fn new(generation: &'a RuntimeGeneration) -> Self {
|
|
||||||
Self {
|
|
||||||
generation,
|
|
||||||
armed: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn disarm(&mut self) {
|
|
||||||
self.armed = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for SessionDrainCancellationGuard<'_> {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
if self.armed {
|
|
||||||
self.generation.begin_stop_sessions();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
struct NotifyOnDrop(Arc<Notify>);
|
|
||||||
|
|
||||||
impl Drop for NotifyOnDrop {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.0.notify_one();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn preparation_guard_drop_cancels_scope_and_rejects_late_spawn() {
|
|
||||||
let scope = RuntimeTaskScope::new();
|
|
||||||
let guard = RuntimeTaskScopePreparationGuard::new(scope.clone());
|
|
||||||
drop(guard);
|
|
||||||
|
|
||||||
assert!(scope.cancellation_token().is_cancelled());
|
|
||||||
let ran = Arc::new(AtomicUsize::new(0));
|
|
||||||
let ran_task = Arc::clone(&ran);
|
|
||||||
scope.spawn(async move {
|
|
||||||
ran_task.fetch_add(1, Ordering::AcqRel);
|
|
||||||
});
|
|
||||||
tokio::task::yield_now().await;
|
|
||||||
assert_eq!(ran.load(Ordering::Acquire), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn preparation_guard_disarm_transfers_ownership() {
|
|
||||||
let scope = RuntimeTaskScope::new();
|
|
||||||
RuntimeTaskScopePreparationGuard::new(scope.clone()).disarm();
|
|
||||||
|
|
||||||
assert!(!scope.cancellation_token().is_cancelled());
|
|
||||||
scope.stop().await;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn aborted_scope_stop_still_cancels_children() {
|
|
||||||
let scope = RuntimeTaskScope::new();
|
|
||||||
let registration = scope.admission.try_register().unwrap();
|
|
||||||
let started = Arc::new(Notify::new());
|
|
||||||
let dropped = Arc::new(Notify::new());
|
|
||||||
let started_task = Arc::clone(&started);
|
|
||||||
let dropped_task = Arc::clone(&dropped);
|
|
||||||
scope.spawn(async move {
|
|
||||||
let _drop_signal = NotifyOnDrop(dropped_task);
|
|
||||||
started_task.notify_one();
|
|
||||||
std::future::pending::<()>().await;
|
|
||||||
});
|
|
||||||
started.notified().await;
|
|
||||||
|
|
||||||
let stop_scope = scope.clone();
|
|
||||||
let stop = tokio::spawn(async move {
|
|
||||||
stop_scope.stop().await;
|
|
||||||
});
|
|
||||||
scope.cancellation_token().cancelled().await;
|
|
||||||
stop.abort();
|
|
||||||
assert!(stop.await.unwrap_err().is_cancelled());
|
|
||||||
drop(registration);
|
|
||||||
|
|
||||||
tokio::time::timeout(Duration::from_secs(1), dropped.notified())
|
|
||||||
.await
|
|
||||||
.expect("scope cancellation must drop the tracked child");
|
|
||||||
assert!(scope.admission.try_register().is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn aborted_graceful_drain_forces_session_cancellation() {
|
|
||||||
let generation = test_runtime_generation(1, ProxyConfig::default());
|
|
||||||
let registration = generation.session_admission.try_register().unwrap();
|
|
||||||
let started = Arc::new(Notify::new());
|
|
||||||
let dropped = Arc::new(Notify::new());
|
|
||||||
let started_task = Arc::clone(&started);
|
|
||||||
let dropped_task = Arc::clone(&dropped);
|
|
||||||
assert!(generation.spawn_session(async move {
|
|
||||||
let _drop_signal = NotifyOnDrop(dropped_task);
|
|
||||||
started_task.notify_one();
|
|
||||||
std::future::pending::<()>().await;
|
|
||||||
}));
|
|
||||||
started.notified().await;
|
|
||||||
|
|
||||||
let drain_generation = Arc::clone(&generation);
|
|
||||||
let drain = tokio::spawn(async move {
|
|
||||||
drain_generation
|
|
||||||
.drain_sessions(Duration::from_secs(60))
|
|
||||||
.await
|
|
||||||
});
|
|
||||||
while generation.session_admission.state.load(Ordering::Acquire) & SESSION_ADMISSION_CLOSED
|
|
||||||
== 0
|
|
||||||
{
|
|
||||||
tokio::task::yield_now().await;
|
|
||||||
}
|
|
||||||
drain.abort();
|
|
||||||
assert!(drain.await.unwrap_err().is_cancelled());
|
|
||||||
assert!(generation.session_cancel.is_cancelled());
|
|
||||||
drop(registration);
|
|
||||||
|
|
||||||
tokio::time::timeout(Duration::from_secs(1), dropped.notified())
|
|
||||||
.await
|
|
||||||
.expect("aborted graceful drain must cancel existing sessions");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+599
-33
@@ -1,8 +1,20 @@
|
|||||||
|
#![allow(clippy::items_after_test_module)]
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tokio::sync::watch;
|
||||||
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
use crate::cli;
|
use crate::cli;
|
||||||
|
use crate::config::ProxyConfig;
|
||||||
use crate::logging::LogCliOptions;
|
use crate::logging::LogCliOptions;
|
||||||
|
use crate::transport::UpstreamManager;
|
||||||
|
use crate::transport::middle_proxy::{
|
||||||
|
ProxyConfigData, fetch_proxy_config_with_raw_via_upstream, load_proxy_config_cache,
|
||||||
|
save_proxy_config_cache,
|
||||||
|
};
|
||||||
|
|
||||||
const MAESTRO_COLOR: &str = "\x1b[92m";
|
const MAESTRO_COLOR: &str = "\x1b[92m";
|
||||||
const COLOR_RESET: &str = "\x1b[0m";
|
const COLOR_RESET: &str = "\x1b[0m";
|
||||||
@@ -35,20 +47,6 @@ pub(crate) fn resolve_runtime_config_path(
|
|||||||
startup_cwd: &Path,
|
startup_cwd: &Path,
|
||||||
config_path_explicit: bool,
|
config_path_explicit: bool,
|
||||||
) -> PathBuf {
|
) -> PathBuf {
|
||||||
let normalize = |path: PathBuf| {
|
|
||||||
let mut normalized = PathBuf::new();
|
|
||||||
for component in path.components() {
|
|
||||||
match component {
|
|
||||||
std::path::Component::CurDir => {}
|
|
||||||
std::path::Component::ParentDir => {
|
|
||||||
normalized.pop();
|
|
||||||
}
|
|
||||||
component => normalized.push(component.as_os_str()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
normalized
|
|
||||||
};
|
|
||||||
|
|
||||||
if config_path_explicit {
|
if config_path_explicit {
|
||||||
let raw = PathBuf::from(config_path_cli);
|
let raw = PathBuf::from(config_path_cli);
|
||||||
let absolute = if raw.is_absolute() {
|
let absolute = if raw.is_absolute() {
|
||||||
@@ -56,7 +54,7 @@ pub(crate) fn resolve_runtime_config_path(
|
|||||||
} else {
|
} else {
|
||||||
startup_cwd.join(raw)
|
startup_cwd.join(raw)
|
||||||
};
|
};
|
||||||
return normalize(absolute);
|
return absolute.canonicalize().unwrap_or(absolute);
|
||||||
}
|
}
|
||||||
|
|
||||||
let etc_telemt = std::path::Path::new("/etc/telemt");
|
let etc_telemt = std::path::Path::new("/etc/telemt");
|
||||||
@@ -68,7 +66,7 @@ pub(crate) fn resolve_runtime_config_path(
|
|||||||
];
|
];
|
||||||
for candidate in candidates {
|
for candidate in candidates {
|
||||||
if candidate.is_file() {
|
if candidate.is_file() {
|
||||||
return normalize(candidate);
|
return candidate.canonicalize().unwrap_or(candidate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,17 +103,7 @@ fn normalize_runtime_dir(path: &Path, startup_cwd: &Path) -> PathBuf {
|
|||||||
} else {
|
} else {
|
||||||
startup_cwd.join(path)
|
startup_cwd.join(path)
|
||||||
};
|
};
|
||||||
let mut normalized = PathBuf::new();
|
absolute.canonicalize().unwrap_or(absolute)
|
||||||
for component in absolute.components() {
|
|
||||||
match component {
|
|
||||||
std::path::Component::CurDir => {}
|
|
||||||
std::path::Component::ParentDir => {
|
|
||||||
normalized.pop();
|
|
||||||
}
|
|
||||||
component => normalized.push(component.as_os_str()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
normalized
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parsed CLI arguments.
|
/// Parsed CLI arguments.
|
||||||
@@ -326,10 +314,588 @@ fn print_help() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runtime reporting and startup snapshot helpers.
|
|
||||||
mod runtime;
|
|
||||||
|
|
||||||
pub(crate) use runtime::*;
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests {
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
expected_handshake_close_description, format_maestro_line, is_expected_handshake_eof,
|
||||||
|
peer_close_description, resolve_runtime_base_dir, resolve_runtime_config_path,
|
||||||
|
};
|
||||||
|
use crate::error::{ProxyError, StreamError};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maestro_line_formatter_respects_disabled_colors() {
|
||||||
|
let plain = format_maestro_line("boot", false);
|
||||||
|
assert_eq!(plain, "MAESTRO: boot");
|
||||||
|
assert!(!plain.contains('\x1b'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maestro_line_formatter_keeps_color_when_enabled() {
|
||||||
|
let colored = format_maestro_line("boot", true);
|
||||||
|
assert!(colored.contains("\x1b[92mMAESTRO\x1b[0m"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_runtime_config_path_anchors_relative_to_startup_cwd() {
|
||||||
|
let nonce = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let startup_cwd = std::env::temp_dir().join(format!("telemt_cfg_path_{nonce}"));
|
||||||
|
std::fs::create_dir_all(&startup_cwd).unwrap();
|
||||||
|
let target = startup_cwd.join("config.toml");
|
||||||
|
std::fs::write(&target, " ").unwrap();
|
||||||
|
|
||||||
|
let resolved = resolve_runtime_config_path("config.toml", &startup_cwd, true);
|
||||||
|
assert_eq!(resolved, target.canonicalize().unwrap());
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&target);
|
||||||
|
let _ = std::fs::remove_dir(&startup_cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_runtime_config_path_keeps_absolute_for_missing_file() {
|
||||||
|
let nonce = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let startup_cwd = std::env::temp_dir().join(format!("telemt_cfg_path_missing_{nonce}"));
|
||||||
|
std::fs::create_dir_all(&startup_cwd).unwrap();
|
||||||
|
|
||||||
|
let resolved = resolve_runtime_config_path("missing.toml", &startup_cwd, true);
|
||||||
|
assert_eq!(resolved, startup_cwd.join("missing.toml"));
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir(&startup_cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_runtime_config_path_uses_startup_candidates_when_not_explicit() {
|
||||||
|
let nonce = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let startup_cwd =
|
||||||
|
std::env::temp_dir().join(format!("telemt_cfg_startup_candidates_{nonce}"));
|
||||||
|
std::fs::create_dir_all(&startup_cwd).unwrap();
|
||||||
|
let telemt = startup_cwd.join("telemt.toml");
|
||||||
|
std::fs::write(&telemt, " ").unwrap();
|
||||||
|
|
||||||
|
let resolved = resolve_runtime_config_path("config.toml", &startup_cwd, false);
|
||||||
|
assert_eq!(resolved, telemt.canonicalize().unwrap());
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&telemt);
|
||||||
|
let _ = std::fs::remove_dir(&startup_cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_runtime_config_path_defaults_to_startup_config_when_none_found() {
|
||||||
|
let nonce = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let startup_cwd = std::env::temp_dir().join(format!("telemt_cfg_startup_default_{nonce}"));
|
||||||
|
std::fs::create_dir_all(&startup_cwd).unwrap();
|
||||||
|
|
||||||
|
let resolved = resolve_runtime_config_path("config.toml", &startup_cwd, false);
|
||||||
|
assert_eq!(resolved, startup_cwd.join("config.toml"));
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir(&startup_cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_runtime_base_dir_prefers_cli_data_path() {
|
||||||
|
let nonce = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let startup_cwd = std::env::temp_dir().join(format!("telemt_runtime_base_cwd_{nonce}"));
|
||||||
|
let data_path = std::env::temp_dir().join(format!("telemt_runtime_base_data_{nonce}"));
|
||||||
|
std::fs::create_dir_all(&startup_cwd).unwrap();
|
||||||
|
std::fs::create_dir_all(&data_path).unwrap();
|
||||||
|
|
||||||
|
let resolved = resolve_runtime_base_dir(
|
||||||
|
&startup_cwd.join("config.toml"),
|
||||||
|
&startup_cwd,
|
||||||
|
true,
|
||||||
|
Some(&data_path),
|
||||||
|
);
|
||||||
|
assert_eq!(resolved, data_path.canonicalize().unwrap());
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir(&data_path);
|
||||||
|
let _ = std::fs::remove_dir(&startup_cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_runtime_base_dir_uses_working_directory_before_explicit_config_parent() {
|
||||||
|
let nonce = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let startup_cwd = std::env::temp_dir().join(format!("telemt_runtime_base_start_{nonce}"));
|
||||||
|
let config_dir = std::env::temp_dir().join(format!("telemt_runtime_base_cfg_{nonce}"));
|
||||||
|
std::fs::create_dir_all(&startup_cwd).unwrap();
|
||||||
|
std::fs::create_dir_all(&config_dir).unwrap();
|
||||||
|
|
||||||
|
let resolved =
|
||||||
|
resolve_runtime_base_dir(&config_dir.join("telemt.toml"), &startup_cwd, true, None);
|
||||||
|
assert_eq!(resolved, startup_cwd.canonicalize().unwrap());
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir(&config_dir);
|
||||||
|
let _ = std::fs::remove_dir(&startup_cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_runtime_base_dir_uses_explicit_config_parent_from_root() {
|
||||||
|
let nonce = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let config_dir = std::env::temp_dir().join(format!("telemt_runtime_base_root_cfg_{nonce}"));
|
||||||
|
std::fs::create_dir_all(&config_dir).unwrap();
|
||||||
|
|
||||||
|
let resolved =
|
||||||
|
resolve_runtime_base_dir(&config_dir.join("telemt.toml"), Path::new("/"), true, None);
|
||||||
|
assert_eq!(resolved, config_dir.canonicalize().unwrap());
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir(&config_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_runtime_base_dir_uses_systemd_working_directory_before_etc() {
|
||||||
|
let nonce = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let startup_cwd = std::env::temp_dir().join(format!("telemt_runtime_base_systemd_{nonce}"));
|
||||||
|
std::fs::create_dir_all(&startup_cwd).unwrap();
|
||||||
|
|
||||||
|
let resolved =
|
||||||
|
resolve_runtime_base_dir(&startup_cwd.join("config.toml"), &startup_cwd, false, None);
|
||||||
|
assert_eq!(resolved, startup_cwd.canonicalize().unwrap());
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir(&startup_cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_runtime_base_dir_falls_back_to_etc_from_root() {
|
||||||
|
let resolved = resolve_runtime_base_dir(
|
||||||
|
Path::new("/etc/telemt/config.toml"),
|
||||||
|
Path::new("/"),
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert_eq!(resolved, PathBuf::from("/etc/telemt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expected_handshake_eof_matches_connection_reset() {
|
||||||
|
let err = ProxyError::Io(std::io::Error::from(std::io::ErrorKind::ConnectionReset));
|
||||||
|
assert!(is_expected_handshake_eof(&err));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expected_handshake_eof_matches_stream_io_unexpected_eof() {
|
||||||
|
let err = ProxyError::Stream(StreamError::Io(std::io::Error::from(
|
||||||
|
std::io::ErrorKind::UnexpectedEof,
|
||||||
|
)));
|
||||||
|
assert!(is_expected_handshake_eof(&err));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn peer_close_description_is_human_readable_for_all_peer_close_kinds() {
|
||||||
|
let cases = [
|
||||||
|
(
|
||||||
|
std::io::ErrorKind::ConnectionReset,
|
||||||
|
"Peer reset TCP connection (RST)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
std::io::ErrorKind::ConnectionAborted,
|
||||||
|
"Peer aborted TCP connection during transport",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
std::io::ErrorKind::BrokenPipe,
|
||||||
|
"Peer closed write side (broken pipe)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
std::io::ErrorKind::NotConnected,
|
||||||
|
"Socket was already closed by peer",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (kind, expected) in cases {
|
||||||
|
let err = ProxyError::Io(std::io::Error::from(kind));
|
||||||
|
assert_eq!(peer_close_description(&err), Some(expected));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handshake_close_description_is_human_readable_for_all_expected_kinds() {
|
||||||
|
let cases = [
|
||||||
|
(
|
||||||
|
ProxyError::Io(std::io::Error::from(std::io::ErrorKind::UnexpectedEof)),
|
||||||
|
"Peer closed before sending full 64-byte MTProto handshake",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ProxyError::Io(std::io::Error::from(std::io::ErrorKind::ConnectionReset)),
|
||||||
|
"Peer reset TCP connection during initial MTProto handshake",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ProxyError::Io(std::io::Error::from(std::io::ErrorKind::ConnectionAborted)),
|
||||||
|
"Peer aborted TCP connection during initial MTProto handshake",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ProxyError::Io(std::io::Error::from(std::io::ErrorKind::BrokenPipe)),
|
||||||
|
"Peer closed write side before MTProto handshake completed",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ProxyError::Io(std::io::Error::from(std::io::ErrorKind::NotConnected)),
|
||||||
|
"Handshake socket was already closed by peer",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ProxyError::Stream(StreamError::UnexpectedEof),
|
||||||
|
"Peer closed before sending full 64-byte MTProto handshake",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (err, expected) in cases {
|
||||||
|
assert_eq!(expected_handshake_close_description(&err), Some(expected));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn print_proxy_links(host: &str, port: u16, config: &ProxyConfig) {
|
||||||
|
print_maestro_line(format!("Proxy links ({host})"));
|
||||||
|
for user_name in config
|
||||||
|
.general
|
||||||
|
.links
|
||||||
|
.show
|
||||||
|
.resolve_users(&config.access.users)
|
||||||
|
{
|
||||||
|
if let Some(secret) = config.access.users.get(user_name) {
|
||||||
|
print_maestro_line(format!("User: {user_name}"));
|
||||||
|
if config.general.modes.classic {
|
||||||
|
print_maestro_line(format!(
|
||||||
|
"Classic: tg://proxy?server={host}&port={port}&secret={secret}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if config.general.modes.secure {
|
||||||
|
print_maestro_line(format!(
|
||||||
|
"DD: tg://proxy?server={host}&port={port}&secret=dd{secret}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if config.general.modes.tls {
|
||||||
|
let mut domains = Vec::with_capacity(1 + config.censorship.tls_domains.len());
|
||||||
|
domains.push(config.censorship.tls_domain.clone());
|
||||||
|
for d in &config.censorship.tls_domains {
|
||||||
|
if !domains.contains(d) {
|
||||||
|
domains.push(d.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for domain in domains {
|
||||||
|
let domain_hex = hex::encode(&domain);
|
||||||
|
print_maestro_line(format!(
|
||||||
|
"EE-TLS: tg://proxy?server={host}&port={port}&secret=ee{secret}{domain_hex}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
warn!(target: "telemt::links", "User '{}' in show_link not found", user_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prints WEB links only for profiles selected by the existing link policy.
|
||||||
|
pub(crate) fn print_web_proxy_links(config: &ProxyConfig) {
|
||||||
|
if !config.web.enabled || config.general.links.show.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(runtime) = config.web.runtime.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let shown = config
|
||||||
|
.general
|
||||||
|
.links
|
||||||
|
.show
|
||||||
|
.resolve_users(&config.access.users);
|
||||||
|
let mut heading_printed = false;
|
||||||
|
for profile in &runtime.profiles {
|
||||||
|
if !shown.iter().any(|user| user.as_str() == profile.user) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !heading_printed {
|
||||||
|
print_maestro_line("WEB proxy links");
|
||||||
|
heading_printed = true;
|
||||||
|
}
|
||||||
|
let Some(secret) = config.access.users.get(&profile.user) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let prefix = match profile.secret_mode {
|
||||||
|
crate::config::WebSecretMode::Plain => "",
|
||||||
|
crate::config::WebSecretMode::Dd => "dd",
|
||||||
|
};
|
||||||
|
print_maestro_line(format!(
|
||||||
|
"User: {} ({:?})",
|
||||||
|
profile.user, profile.secret_mode
|
||||||
|
));
|
||||||
|
print_maestro_line(format!(
|
||||||
|
"WEB: tg://webproxy?server={}&secret={prefix}{secret}",
|
||||||
|
profile.host,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn write_beobachten_snapshot(path: &str, payload: &str) -> std::io::Result<()> {
|
||||||
|
if let Some(parent) = std::path::Path::new(path).parent()
|
||||||
|
&& !parent.as_os_str().is_empty()
|
||||||
|
{
|
||||||
|
tokio::fs::create_dir_all(parent).await?;
|
||||||
|
}
|
||||||
|
tokio::fs::write(path, payload).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn unit_label(value: u64, singular: &'static str, plural: &'static str) -> &'static str {
|
||||||
|
if value == 1 { singular } else { plural }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn format_uptime(total_secs: u64) -> String {
|
||||||
|
const SECS_PER_MINUTE: u64 = 60;
|
||||||
|
const SECS_PER_HOUR: u64 = 60 * SECS_PER_MINUTE;
|
||||||
|
const SECS_PER_DAY: u64 = 24 * SECS_PER_HOUR;
|
||||||
|
const SECS_PER_MONTH: u64 = 30 * SECS_PER_DAY;
|
||||||
|
const SECS_PER_YEAR: u64 = 12 * SECS_PER_MONTH;
|
||||||
|
|
||||||
|
let mut remaining = total_secs;
|
||||||
|
let years = remaining / SECS_PER_YEAR;
|
||||||
|
remaining %= SECS_PER_YEAR;
|
||||||
|
let months = remaining / SECS_PER_MONTH;
|
||||||
|
remaining %= SECS_PER_MONTH;
|
||||||
|
let days = remaining / SECS_PER_DAY;
|
||||||
|
remaining %= SECS_PER_DAY;
|
||||||
|
let hours = remaining / SECS_PER_HOUR;
|
||||||
|
remaining %= SECS_PER_HOUR;
|
||||||
|
let minutes = remaining / SECS_PER_MINUTE;
|
||||||
|
let seconds = remaining % SECS_PER_MINUTE;
|
||||||
|
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
if total_secs > SECS_PER_YEAR {
|
||||||
|
parts.push(format!("{} {}", years, unit_label(years, "year", "years")));
|
||||||
|
}
|
||||||
|
if total_secs > SECS_PER_MONTH {
|
||||||
|
parts.push(format!(
|
||||||
|
"{} {}",
|
||||||
|
months,
|
||||||
|
unit_label(months, "month", "months")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if total_secs > SECS_PER_DAY {
|
||||||
|
parts.push(format!("{} {}", days, unit_label(days, "day", "days")));
|
||||||
|
}
|
||||||
|
if total_secs > SECS_PER_HOUR {
|
||||||
|
parts.push(format!("{} {}", hours, unit_label(hours, "hour", "hours")));
|
||||||
|
}
|
||||||
|
if total_secs > SECS_PER_MINUTE {
|
||||||
|
parts.push(format!(
|
||||||
|
"{} {}",
|
||||||
|
minutes,
|
||||||
|
unit_label(minutes, "minute", "minutes")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
parts.push(format!(
|
||||||
|
"{} {}",
|
||||||
|
seconds,
|
||||||
|
unit_label(seconds, "second", "seconds")
|
||||||
|
));
|
||||||
|
|
||||||
|
format!("{} / {} seconds", parts.join(", "), total_secs)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub(crate) async fn wait_until_admission_open(admission_rx: &mut watch::Receiver<bool>) -> bool {
|
||||||
|
loop {
|
||||||
|
if *admission_rx.borrow() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if admission_rx.changed().await.is_err() {
|
||||||
|
return *admission_rx.borrow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_expected_handshake_eof(err: &crate::error::ProxyError) -> bool {
|
||||||
|
expected_handshake_close_description(err).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn peer_close_description(err: &crate::error::ProxyError) -> Option<&'static str> {
|
||||||
|
fn from_kind(kind: std::io::ErrorKind) -> Option<&'static str> {
|
||||||
|
match kind {
|
||||||
|
std::io::ErrorKind::ConnectionReset => Some("Peer reset TCP connection (RST)"),
|
||||||
|
std::io::ErrorKind::ConnectionAborted => {
|
||||||
|
Some("Peer aborted TCP connection during transport")
|
||||||
|
}
|
||||||
|
std::io::ErrorKind::BrokenPipe => Some("Peer closed write side (broken pipe)"),
|
||||||
|
std::io::ErrorKind::NotConnected => Some("Socket was already closed by peer"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match err {
|
||||||
|
crate::error::ProxyError::Io(ioe) => from_kind(ioe.kind()),
|
||||||
|
crate::error::ProxyError::Stream(crate::error::StreamError::Io(ioe)) => {
|
||||||
|
from_kind(ioe.kind())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn expected_handshake_close_description(
|
||||||
|
err: &crate::error::ProxyError,
|
||||||
|
) -> Option<&'static str> {
|
||||||
|
fn from_kind(kind: std::io::ErrorKind) -> Option<&'static str> {
|
||||||
|
match kind {
|
||||||
|
std::io::ErrorKind::UnexpectedEof => {
|
||||||
|
Some("Peer closed before sending full 64-byte MTProto handshake")
|
||||||
|
}
|
||||||
|
std::io::ErrorKind::ConnectionReset => {
|
||||||
|
Some("Peer reset TCP connection during initial MTProto handshake")
|
||||||
|
}
|
||||||
|
std::io::ErrorKind::ConnectionAborted => {
|
||||||
|
Some("Peer aborted TCP connection during initial MTProto handshake")
|
||||||
|
}
|
||||||
|
std::io::ErrorKind::BrokenPipe => {
|
||||||
|
Some("Peer closed write side before MTProto handshake completed")
|
||||||
|
}
|
||||||
|
std::io::ErrorKind::NotConnected => Some("Handshake socket was already closed by peer"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match err {
|
||||||
|
crate::error::ProxyError::Io(ioe) => from_kind(ioe.kind()),
|
||||||
|
crate::error::ProxyError::Stream(crate::error::StreamError::UnexpectedEof) => {
|
||||||
|
Some("Peer closed before sending full 64-byte MTProto handshake")
|
||||||
|
}
|
||||||
|
crate::error::ProxyError::Stream(crate::error::StreamError::Io(ioe)) => {
|
||||||
|
from_kind(ioe.kind())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn load_startup_proxy_config_snapshot(
|
||||||
|
url: &str,
|
||||||
|
cache_path: Option<&str>,
|
||||||
|
me2dc_fallback: bool,
|
||||||
|
label: &'static str,
|
||||||
|
upstream: Option<std::sync::Arc<UpstreamManager>>,
|
||||||
|
) -> Option<ProxyConfigData> {
|
||||||
|
loop {
|
||||||
|
match fetch_proxy_config_with_raw_via_upstream(url, upstream.clone()).await {
|
||||||
|
Ok((cfg, raw)) => {
|
||||||
|
if !cfg.map.is_empty() {
|
||||||
|
if let Some(path) = cache_path
|
||||||
|
&& let Err(e) = save_proxy_config_cache(path, &raw).await
|
||||||
|
{
|
||||||
|
warn!(error = %e, path, snapshot = label, "Failed to store startup proxy-config cache");
|
||||||
|
}
|
||||||
|
return Some(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
warn!(
|
||||||
|
snapshot = label,
|
||||||
|
url, "Startup proxy-config is empty; trying disk cache"
|
||||||
|
);
|
||||||
|
if let Some(path) = cache_path {
|
||||||
|
match load_proxy_config_cache(path).await {
|
||||||
|
Ok(cached) if !cached.map.is_empty() => {
|
||||||
|
info!(
|
||||||
|
snapshot = label,
|
||||||
|
path,
|
||||||
|
proxy_for_lines = cached.proxy_for_lines,
|
||||||
|
"Loaded startup proxy-config from disk cache"
|
||||||
|
);
|
||||||
|
return Some(cached);
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
warn!(
|
||||||
|
snapshot = label,
|
||||||
|
path, "Startup proxy-config cache is empty; ignoring cache file"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(cache_err) => {
|
||||||
|
debug!(
|
||||||
|
snapshot = label,
|
||||||
|
path,
|
||||||
|
error = %cache_err,
|
||||||
|
"Startup proxy-config cache unavailable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if me2dc_fallback {
|
||||||
|
error!(
|
||||||
|
snapshot = label,
|
||||||
|
"Startup proxy-config unavailable and no saved config found; falling back to direct mode"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
warn!(
|
||||||
|
snapshot = label,
|
||||||
|
retry_in_secs = 2,
|
||||||
|
"Startup proxy-config unavailable and no saved config found; retrying because me2dc_fallback=false"
|
||||||
|
);
|
||||||
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
|
}
|
||||||
|
Err(fetch_err) => {
|
||||||
|
if let Some(path) = cache_path {
|
||||||
|
match load_proxy_config_cache(path).await {
|
||||||
|
Ok(cached) if !cached.map.is_empty() => {
|
||||||
|
info!(
|
||||||
|
snapshot = label,
|
||||||
|
path,
|
||||||
|
proxy_for_lines = cached.proxy_for_lines,
|
||||||
|
"Loaded startup proxy-config from disk cache"
|
||||||
|
);
|
||||||
|
return Some(cached);
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
warn!(
|
||||||
|
snapshot = label,
|
||||||
|
path, "Startup proxy-config cache is empty; ignoring cache file"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(cache_err) => {
|
||||||
|
debug!(
|
||||||
|
snapshot = label,
|
||||||
|
path,
|
||||||
|
error = %cache_err,
|
||||||
|
"Startup proxy-config cache unavailable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if me2dc_fallback {
|
||||||
|
error!(
|
||||||
|
snapshot = label,
|
||||||
|
error = %fetch_err,
|
||||||
|
"Startup proxy-config unavailable and no cached data; falling back to direct mode"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
warn!(
|
||||||
|
snapshot = label,
|
||||||
|
error = %fetch_err,
|
||||||
|
retry_in_secs = 2,
|
||||||
|
"Startup proxy-config unavailable; retrying because me2dc_fallback=false"
|
||||||
|
);
|
||||||
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,476 +0,0 @@
|
|||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use base64::Engine as _;
|
|
||||||
use tokio::sync::watch;
|
|
||||||
use tracing::{debug, error, info, warn};
|
|
||||||
|
|
||||||
use crate::config::ProxyConfig;
|
|
||||||
use crate::transport::UpstreamManager;
|
|
||||||
use crate::transport::middle_proxy::{
|
|
||||||
ProxyConfigData, fetch_proxy_config_with_raw_via_upstream, load_proxy_config_cache,
|
|
||||||
save_proxy_config_cache,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::print_maestro_line;
|
|
||||||
|
|
||||||
/// Prints configured MTProxy links through the direct MAESTRO output channel.
|
|
||||||
pub(crate) fn print_proxy_links(host: &str, port: u16, config: &ProxyConfig) {
|
|
||||||
print_maestro_line(format!("Proxy links ({host})"));
|
|
||||||
for user_name in config
|
|
||||||
.general
|
|
||||||
.links
|
|
||||||
.show
|
|
||||||
.resolve_users(&config.access.users)
|
|
||||||
{
|
|
||||||
if let Some(secret) = config.access.users.get(user_name) {
|
|
||||||
print_maestro_line(format!("User: {user_name}"));
|
|
||||||
if config.general.modes.classic {
|
|
||||||
print_maestro_line(format!(
|
|
||||||
"Classic: tg://proxy?server={host}&port={port}&secret={secret}"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if config.general.modes.secure {
|
|
||||||
print_maestro_line(format!(
|
|
||||||
"DD: tg://proxy?server={host}&port={port}&secret=dd{secret}"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if config.general.modes.tls {
|
|
||||||
let mut domains = Vec::with_capacity(1 + config.censorship.tls_domains.len());
|
|
||||||
domains.push(config.censorship.tls_domain.clone());
|
|
||||||
for d in &config.censorship.tls_domains {
|
|
||||||
if !domains.contains(d) {
|
|
||||||
domains.push(d.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for domain in domains {
|
|
||||||
let domain_hex = hex::encode(&domain);
|
|
||||||
print_maestro_line(format!(
|
|
||||||
"EE-TLS: tg://proxy?server={host}&port={port}&secret=ee{secret}{domain_hex}"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
warn!(target: "telemt::links", "User '{}' in show_link not found", user_name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Prints WEB links only for profiles selected by the existing link policy.
|
|
||||||
pub(crate) fn print_web_proxy_links(config: &ProxyConfig) {
|
|
||||||
if !config.web.enabled || config.general.links.show.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let Some(runtime) = config.web.runtime.as_ref() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let shown = config
|
|
||||||
.general
|
|
||||||
.links
|
|
||||||
.show
|
|
||||||
.resolve_users(&config.access.users);
|
|
||||||
let mut heading_printed = false;
|
|
||||||
for profile in &runtime.profiles {
|
|
||||||
if !shown.iter().any(|user| user.as_str() == profile.user) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if !heading_printed {
|
|
||||||
print_maestro_line("WEB proxy links");
|
|
||||||
heading_printed = true;
|
|
||||||
}
|
|
||||||
let Some(secret) = config.access.users.get(&profile.user) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Some(vhost) = config
|
|
||||||
.web
|
|
||||||
.vhosts
|
|
||||||
.iter()
|
|
||||||
.find(|vhost| vhost.host == profile.host)
|
|
||||||
else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
print_maestro_line(format!(
|
|
||||||
"User: {} ({:?})",
|
|
||||||
profile.user, profile.secret_mode
|
|
||||||
));
|
|
||||||
if let Some(link) =
|
|
||||||
format_web_proxy_link(&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.
|
|
||||||
pub(crate) async fn write_beobachten_snapshot(path: &str, payload: &str) -> std::io::Result<()> {
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
crate::util::secure_fs::atomic_replace_async(
|
|
||||||
std::path::PathBuf::from(path),
|
|
||||||
payload.as_bytes().to_vec(),
|
|
||||||
0o600,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
{
|
|
||||||
if let Some(parent) = std::path::Path::new(path).parent()
|
|
||||||
&& !parent.as_os_str().is_empty()
|
|
||||||
{
|
|
||||||
tokio::fs::create_dir_all(parent).await?;
|
|
||||||
}
|
|
||||||
tokio::fs::write(path, payload).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Selects a singular or plural display label for one integer value.
|
|
||||||
pub(crate) fn unit_label(value: u64, singular: &'static str, plural: &'static str) -> &'static str {
|
|
||||||
if value == 1 { singular } else { plural }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Formats process uptime into bounded human-readable units and exact seconds.
|
|
||||||
pub(crate) fn format_uptime(total_secs: u64) -> String {
|
|
||||||
const SECS_PER_MINUTE: u64 = 60;
|
|
||||||
const SECS_PER_HOUR: u64 = 60 * SECS_PER_MINUTE;
|
|
||||||
const SECS_PER_DAY: u64 = 24 * SECS_PER_HOUR;
|
|
||||||
const SECS_PER_MONTH: u64 = 30 * SECS_PER_DAY;
|
|
||||||
const SECS_PER_YEAR: u64 = 12 * SECS_PER_MONTH;
|
|
||||||
|
|
||||||
let mut remaining = total_secs;
|
|
||||||
let years = remaining / SECS_PER_YEAR;
|
|
||||||
remaining %= SECS_PER_YEAR;
|
|
||||||
let months = remaining / SECS_PER_MONTH;
|
|
||||||
remaining %= SECS_PER_MONTH;
|
|
||||||
let days = remaining / SECS_PER_DAY;
|
|
||||||
remaining %= SECS_PER_DAY;
|
|
||||||
let hours = remaining / SECS_PER_HOUR;
|
|
||||||
remaining %= SECS_PER_HOUR;
|
|
||||||
let minutes = remaining / SECS_PER_MINUTE;
|
|
||||||
let seconds = remaining % SECS_PER_MINUTE;
|
|
||||||
|
|
||||||
let mut parts = Vec::new();
|
|
||||||
if total_secs > SECS_PER_YEAR {
|
|
||||||
parts.push(format!("{} {}", years, unit_label(years, "year", "years")));
|
|
||||||
}
|
|
||||||
if total_secs > SECS_PER_MONTH {
|
|
||||||
parts.push(format!(
|
|
||||||
"{} {}",
|
|
||||||
months,
|
|
||||||
unit_label(months, "month", "months")
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if total_secs > SECS_PER_DAY {
|
|
||||||
parts.push(format!("{} {}", days, unit_label(days, "day", "days")));
|
|
||||||
}
|
|
||||||
if total_secs > SECS_PER_HOUR {
|
|
||||||
parts.push(format!("{} {}", hours, unit_label(hours, "hour", "hours")));
|
|
||||||
}
|
|
||||||
if total_secs > SECS_PER_MINUTE {
|
|
||||||
parts.push(format!(
|
|
||||||
"{} {}",
|
|
||||||
minutes,
|
|
||||||
unit_label(minutes, "minute", "minutes")
|
|
||||||
));
|
|
||||||
}
|
|
||||||
parts.push(format!(
|
|
||||||
"{} {}",
|
|
||||||
seconds,
|
|
||||||
unit_label(seconds, "second", "seconds")
|
|
||||||
));
|
|
||||||
|
|
||||||
format!("{} / {} seconds", parts.join(", "), total_secs)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
/// Waits until admission opens or its watch channel closes.
|
|
||||||
pub(crate) async fn wait_until_admission_open(admission_rx: &mut watch::Receiver<bool>) -> bool {
|
|
||||||
loop {
|
|
||||||
if *admission_rx.borrow() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if admission_rx.changed().await.is_err() {
|
|
||||||
return *admission_rx.borrow();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Classifies peer closure that is expected during an incomplete handshake.
|
|
||||||
pub(crate) fn is_expected_handshake_eof(err: &crate::error::ProxyError) -> bool {
|
|
||||||
expected_handshake_close_description(err).is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a stable diagnostic description for transport-level peer closure.
|
|
||||||
pub(crate) fn peer_close_description(err: &crate::error::ProxyError) -> Option<&'static str> {
|
|
||||||
fn from_kind(kind: std::io::ErrorKind) -> Option<&'static str> {
|
|
||||||
match kind {
|
|
||||||
std::io::ErrorKind::ConnectionReset => Some("Peer reset TCP connection (RST)"),
|
|
||||||
std::io::ErrorKind::ConnectionAborted => {
|
|
||||||
Some("Peer aborted TCP connection during transport")
|
|
||||||
}
|
|
||||||
std::io::ErrorKind::BrokenPipe => Some("Peer closed write side (broken pipe)"),
|
|
||||||
std::io::ErrorKind::NotConnected => Some("Socket was already closed by peer"),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match err {
|
|
||||||
crate::error::ProxyError::Io(ioe) => from_kind(ioe.kind()),
|
|
||||||
crate::error::ProxyError::Stream(crate::error::StreamError::Io(ioe)) => {
|
|
||||||
from_kind(ioe.kind())
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a stable diagnostic description for expected handshake closure.
|
|
||||||
pub(crate) fn expected_handshake_close_description(
|
|
||||||
err: &crate::error::ProxyError,
|
|
||||||
) -> Option<&'static str> {
|
|
||||||
fn from_kind(kind: std::io::ErrorKind) -> Option<&'static str> {
|
|
||||||
match kind {
|
|
||||||
std::io::ErrorKind::UnexpectedEof => {
|
|
||||||
Some("Peer closed before sending full 64-byte MTProto handshake")
|
|
||||||
}
|
|
||||||
std::io::ErrorKind::ConnectionReset => {
|
|
||||||
Some("Peer reset TCP connection during initial MTProto handshake")
|
|
||||||
}
|
|
||||||
std::io::ErrorKind::ConnectionAborted => {
|
|
||||||
Some("Peer aborted TCP connection during initial MTProto handshake")
|
|
||||||
}
|
|
||||||
std::io::ErrorKind::BrokenPipe => {
|
|
||||||
Some("Peer closed write side before MTProto handshake completed")
|
|
||||||
}
|
|
||||||
std::io::ErrorKind::NotConnected => Some("Handshake socket was already closed by peer"),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match err {
|
|
||||||
crate::error::ProxyError::Io(ioe) => from_kind(ioe.kind()),
|
|
||||||
crate::error::ProxyError::Stream(crate::error::StreamError::UnexpectedEof) => {
|
|
||||||
Some("Peer closed before sending full 64-byte MTProto handshake")
|
|
||||||
}
|
|
||||||
crate::error::ProxyError::Stream(crate::error::StreamError::Io(ioe)) => {
|
|
||||||
from_kind(ioe.kind())
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loads a non-empty startup endpoint snapshot with bounded cache fallback.
|
|
||||||
pub(crate) async fn load_startup_proxy_config_snapshot(
|
|
||||||
url: &str,
|
|
||||||
cache_path: Option<&str>,
|
|
||||||
me2dc_fallback: bool,
|
|
||||||
label: &'static str,
|
|
||||||
upstream: Option<std::sync::Arc<UpstreamManager>>,
|
|
||||||
) -> Option<ProxyConfigData> {
|
|
||||||
loop {
|
|
||||||
match fetch_proxy_config_with_raw_via_upstream(url, upstream.clone()).await {
|
|
||||||
Ok((cfg, raw)) => {
|
|
||||||
if !cfg.map.is_empty() {
|
|
||||||
if let Some(path) = cache_path
|
|
||||||
&& let Err(e) = save_proxy_config_cache(path, &raw).await
|
|
||||||
{
|
|
||||||
warn!(error = %e, path, snapshot = label, "Failed to store startup proxy-config cache");
|
|
||||||
}
|
|
||||||
return Some(cfg);
|
|
||||||
}
|
|
||||||
|
|
||||||
warn!(
|
|
||||||
snapshot = label,
|
|
||||||
url, "Startup proxy-config is empty; trying disk cache"
|
|
||||||
);
|
|
||||||
if let Some(path) = cache_path {
|
|
||||||
match load_proxy_config_cache(path).await {
|
|
||||||
Ok(cached) if !cached.map.is_empty() => {
|
|
||||||
info!(
|
|
||||||
snapshot = label,
|
|
||||||
path,
|
|
||||||
proxy_for_lines = cached.proxy_for_lines,
|
|
||||||
"Loaded startup proxy-config from disk cache"
|
|
||||||
);
|
|
||||||
return Some(cached);
|
|
||||||
}
|
|
||||||
Ok(_) => {
|
|
||||||
warn!(
|
|
||||||
snapshot = label,
|
|
||||||
path, "Startup proxy-config cache is empty; ignoring cache file"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(cache_err) => {
|
|
||||||
debug!(
|
|
||||||
snapshot = label,
|
|
||||||
path,
|
|
||||||
error = %cache_err,
|
|
||||||
"Startup proxy-config cache unavailable"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if me2dc_fallback {
|
|
||||||
error!(
|
|
||||||
snapshot = label,
|
|
||||||
"Startup proxy-config unavailable and no saved config found; falling back to direct mode"
|
|
||||||
);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
warn!(
|
|
||||||
snapshot = label,
|
|
||||||
retry_in_secs = 2,
|
|
||||||
"Startup proxy-config unavailable and no saved config found; retrying because me2dc_fallback=false"
|
|
||||||
);
|
|
||||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
|
||||||
}
|
|
||||||
Err(fetch_err) => {
|
|
||||||
if let Some(path) = cache_path {
|
|
||||||
match load_proxy_config_cache(path).await {
|
|
||||||
Ok(cached) if !cached.map.is_empty() => {
|
|
||||||
info!(
|
|
||||||
snapshot = label,
|
|
||||||
path,
|
|
||||||
proxy_for_lines = cached.proxy_for_lines,
|
|
||||||
"Loaded startup proxy-config from disk cache"
|
|
||||||
);
|
|
||||||
return Some(cached);
|
|
||||||
}
|
|
||||||
Ok(_) => {
|
|
||||||
warn!(
|
|
||||||
snapshot = label,
|
|
||||||
path, "Startup proxy-config cache is empty; ignoring cache file"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(cache_err) => {
|
|
||||||
debug!(
|
|
||||||
snapshot = label,
|
|
||||||
path,
|
|
||||||
error = %cache_err,
|
|
||||||
"Startup proxy-config cache unavailable"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if me2dc_fallback {
|
|
||||||
error!(
|
|
||||||
snapshot = label,
|
|
||||||
error = %fetch_err,
|
|
||||||
"Startup proxy-config unavailable and no cached data; falling back to direct mode"
|
|
||||||
);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
warn!(
|
|
||||||
snapshot = label,
|
|
||||||
error = %fetch_err,
|
|
||||||
retry_in_secs = 2,
|
|
||||||
"Startup proxy-config unavailable; retrying because me2dc_fallback=false"
|
|
||||||
);
|
|
||||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn path_web_proxy_link_round_trips_through_the_tdesktop_grammar() {
|
|
||||||
for (mode, expected_secret) in [
|
|
||||||
(WebSecretMode::Plain, hex::decode(SECRET).unwrap()),
|
|
||||||
(
|
|
||||||
WebSecretMode::Dd,
|
|
||||||
[vec![0xdd], hex::decode(SECRET).unwrap()].concat(),
|
|
||||||
),
|
|
||||||
] {
|
|
||||||
let link = format_web_proxy_link("proxy.example.com", "MixedCase/a_b-9", SECRET, mode)
|
|
||||||
.unwrap();
|
|
||||||
let parsed = url::Url::parse(&link).unwrap();
|
|
||||||
let query = parsed
|
|
||||||
.query_pairs()
|
|
||||||
.collect::<std::collections::BTreeMap<_, _>>();
|
|
||||||
assert_eq!(
|
|
||||||
query["server"].as_ref(),
|
|
||||||
"proxy.example.com/MixedCase/a_b-9"
|
|
||||||
);
|
|
||||||
let marked = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
|
||||||
.decode(query["secret"].as_bytes())
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(marked.first(), Some(&0x70));
|
|
||||||
assert_eq!(&marked[1..], expected_secret.as_slice());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use super::{
|
|
||||||
expected_handshake_close_description, format_maestro_line, is_expected_handshake_eof,
|
|
||||||
peer_close_description, resolve_runtime_base_dir, resolve_runtime_config_path,
|
|
||||||
};
|
|
||||||
use crate::error::{ProxyError, StreamError};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn maestro_line_formatter_respects_disabled_colors() {
|
|
||||||
let plain = format_maestro_line("boot", false);
|
|
||||||
assert_eq!(plain, "MAESTRO: boot");
|
|
||||||
assert!(!plain.contains('\x1b'));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn maestro_line_formatter_keeps_color_when_enabled() {
|
|
||||||
let colored = format_maestro_line("boot", true);
|
|
||||||
assert!(colored.contains("\x1b[92mMAESTRO\x1b[0m"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_runtime_config_path_anchors_relative_to_startup_cwd() {
|
|
||||||
let nonce = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_nanos();
|
|
||||||
let startup_cwd = std::env::temp_dir().join(format!("telemt_cfg_path_{nonce}"));
|
|
||||||
std::fs::create_dir_all(&startup_cwd).unwrap();
|
|
||||||
let target = startup_cwd.join("config.toml");
|
|
||||||
std::fs::write(&target, " ").unwrap();
|
|
||||||
|
|
||||||
let resolved = resolve_runtime_config_path("config.toml", &startup_cwd, true);
|
|
||||||
assert_eq!(resolved, target.canonicalize().unwrap());
|
|
||||||
|
|
||||||
let _ = std::fs::remove_file(&target);
|
|
||||||
let _ = std::fs::remove_dir(&startup_cwd);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_runtime_config_path_keeps_absolute_for_missing_file() {
|
|
||||||
let nonce = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_nanos();
|
|
||||||
let startup_cwd = std::env::temp_dir().join(format!("telemt_cfg_path_missing_{nonce}"));
|
|
||||||
std::fs::create_dir_all(&startup_cwd).unwrap();
|
|
||||||
|
|
||||||
let resolved = resolve_runtime_config_path("missing.toml", &startup_cwd, true);
|
|
||||||
assert_eq!(resolved, startup_cwd.join("missing.toml"));
|
|
||||||
|
|
||||||
let _ = std::fs::remove_dir(&startup_cwd);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[test]
|
|
||||||
fn runtime_paths_preserve_symlinks_for_descriptor_validation() {
|
|
||||||
use std::os::unix::fs::symlink;
|
|
||||||
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let real_dir = dir.path().join("real");
|
|
||||||
let linked_dir = dir.path().join("linked");
|
|
||||||
std::fs::create_dir(&real_dir).unwrap();
|
|
||||||
std::fs::write(real_dir.join("config.toml"), " ").unwrap();
|
|
||||||
symlink(&real_dir, &linked_dir).unwrap();
|
|
||||||
let linked_config = linked_dir.join("config.toml");
|
|
||||||
|
|
||||||
let config = resolve_runtime_config_path(linked_config.to_str().unwrap(), dir.path(), true);
|
|
||||||
let runtime = resolve_runtime_base_dir(&linked_config, dir.path(), true, Some(&linked_dir));
|
|
||||||
|
|
||||||
assert_eq!(config, linked_config);
|
|
||||||
assert_eq!(runtime, linked_dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_runtime_config_path_uses_startup_candidates_when_not_explicit() {
|
|
||||||
let nonce = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_nanos();
|
|
||||||
let startup_cwd = std::env::temp_dir().join(format!("telemt_cfg_startup_candidates_{nonce}"));
|
|
||||||
std::fs::create_dir_all(&startup_cwd).unwrap();
|
|
||||||
let telemt = startup_cwd.join("telemt.toml");
|
|
||||||
std::fs::write(&telemt, " ").unwrap();
|
|
||||||
|
|
||||||
let resolved = resolve_runtime_config_path("config.toml", &startup_cwd, false);
|
|
||||||
assert_eq!(resolved, telemt.canonicalize().unwrap());
|
|
||||||
|
|
||||||
let _ = std::fs::remove_file(&telemt);
|
|
||||||
let _ = std::fs::remove_dir(&startup_cwd);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_runtime_config_path_defaults_to_startup_config_when_none_found() {
|
|
||||||
let nonce = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_nanos();
|
|
||||||
let startup_cwd = std::env::temp_dir().join(format!("telemt_cfg_startup_default_{nonce}"));
|
|
||||||
std::fs::create_dir_all(&startup_cwd).unwrap();
|
|
||||||
|
|
||||||
let resolved = resolve_runtime_config_path("config.toml", &startup_cwd, false);
|
|
||||||
assert_eq!(resolved, startup_cwd.join("config.toml"));
|
|
||||||
|
|
||||||
let _ = std::fs::remove_dir(&startup_cwd);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_runtime_base_dir_prefers_cli_data_path() {
|
|
||||||
let nonce = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_nanos();
|
|
||||||
let startup_cwd = std::env::temp_dir().join(format!("telemt_runtime_base_cwd_{nonce}"));
|
|
||||||
let data_path = std::env::temp_dir().join(format!("telemt_runtime_base_data_{nonce}"));
|
|
||||||
std::fs::create_dir_all(&startup_cwd).unwrap();
|
|
||||||
std::fs::create_dir_all(&data_path).unwrap();
|
|
||||||
|
|
||||||
let resolved = resolve_runtime_base_dir(
|
|
||||||
&startup_cwd.join("config.toml"),
|
|
||||||
&startup_cwd,
|
|
||||||
true,
|
|
||||||
Some(&data_path),
|
|
||||||
);
|
|
||||||
assert_eq!(resolved, data_path.canonicalize().unwrap());
|
|
||||||
|
|
||||||
let _ = std::fs::remove_dir(&data_path);
|
|
||||||
let _ = std::fs::remove_dir(&startup_cwd);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_runtime_base_dir_uses_working_directory_before_explicit_config_parent() {
|
|
||||||
let nonce = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_nanos();
|
|
||||||
let startup_cwd = std::env::temp_dir().join(format!("telemt_runtime_base_start_{nonce}"));
|
|
||||||
let config_dir = std::env::temp_dir().join(format!("telemt_runtime_base_cfg_{nonce}"));
|
|
||||||
std::fs::create_dir_all(&startup_cwd).unwrap();
|
|
||||||
std::fs::create_dir_all(&config_dir).unwrap();
|
|
||||||
|
|
||||||
let resolved =
|
|
||||||
resolve_runtime_base_dir(&config_dir.join("telemt.toml"), &startup_cwd, true, None);
|
|
||||||
assert_eq!(resolved, startup_cwd.canonicalize().unwrap());
|
|
||||||
|
|
||||||
let _ = std::fs::remove_dir(&config_dir);
|
|
||||||
let _ = std::fs::remove_dir(&startup_cwd);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_runtime_base_dir_uses_explicit_config_parent_from_root() {
|
|
||||||
let nonce = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_nanos();
|
|
||||||
let config_dir = std::env::temp_dir().join(format!("telemt_runtime_base_root_cfg_{nonce}"));
|
|
||||||
std::fs::create_dir_all(&config_dir).unwrap();
|
|
||||||
|
|
||||||
let resolved =
|
|
||||||
resolve_runtime_base_dir(&config_dir.join("telemt.toml"), Path::new("/"), true, None);
|
|
||||||
assert_eq!(resolved, config_dir.canonicalize().unwrap());
|
|
||||||
|
|
||||||
let _ = std::fs::remove_dir(&config_dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_runtime_base_dir_uses_systemd_working_directory_before_etc() {
|
|
||||||
let nonce = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_nanos();
|
|
||||||
let startup_cwd = std::env::temp_dir().join(format!("telemt_runtime_base_systemd_{nonce}"));
|
|
||||||
std::fs::create_dir_all(&startup_cwd).unwrap();
|
|
||||||
|
|
||||||
let resolved =
|
|
||||||
resolve_runtime_base_dir(&startup_cwd.join("config.toml"), &startup_cwd, false, None);
|
|
||||||
assert_eq!(resolved, startup_cwd.canonicalize().unwrap());
|
|
||||||
|
|
||||||
let _ = std::fs::remove_dir(&startup_cwd);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_runtime_base_dir_falls_back_to_etc_from_root() {
|
|
||||||
let resolved = resolve_runtime_base_dir(
|
|
||||||
Path::new("/etc/telemt/config.toml"),
|
|
||||||
Path::new("/"),
|
|
||||||
false,
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
assert_eq!(resolved, PathBuf::from("/etc/telemt"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn expected_handshake_eof_matches_connection_reset() {
|
|
||||||
let err = ProxyError::Io(std::io::Error::from(std::io::ErrorKind::ConnectionReset));
|
|
||||||
assert!(is_expected_handshake_eof(&err));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn expected_handshake_eof_matches_stream_io_unexpected_eof() {
|
|
||||||
let err = ProxyError::Stream(StreamError::Io(std::io::Error::from(
|
|
||||||
std::io::ErrorKind::UnexpectedEof,
|
|
||||||
)));
|
|
||||||
assert!(is_expected_handshake_eof(&err));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn peer_close_description_is_human_readable_for_all_peer_close_kinds() {
|
|
||||||
let cases = [
|
|
||||||
(
|
|
||||||
std::io::ErrorKind::ConnectionReset,
|
|
||||||
"Peer reset TCP connection (RST)",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
std::io::ErrorKind::ConnectionAborted,
|
|
||||||
"Peer aborted TCP connection during transport",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
std::io::ErrorKind::BrokenPipe,
|
|
||||||
"Peer closed write side (broken pipe)",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
std::io::ErrorKind::NotConnected,
|
|
||||||
"Socket was already closed by peer",
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (kind, expected) in cases {
|
|
||||||
let err = ProxyError::Io(std::io::Error::from(kind));
|
|
||||||
assert_eq!(peer_close_description(&err), Some(expected));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn handshake_close_description_is_human_readable_for_all_expected_kinds() {
|
|
||||||
let cases = [
|
|
||||||
(
|
|
||||||
ProxyError::Io(std::io::Error::from(std::io::ErrorKind::UnexpectedEof)),
|
|
||||||
"Peer closed before sending full 64-byte MTProto handshake",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
ProxyError::Io(std::io::Error::from(std::io::ErrorKind::ConnectionReset)),
|
|
||||||
"Peer reset TCP connection during initial MTProto handshake",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
ProxyError::Io(std::io::Error::from(std::io::ErrorKind::ConnectionAborted)),
|
|
||||||
"Peer aborted TCP connection during initial MTProto handshake",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
ProxyError::Io(std::io::Error::from(std::io::ErrorKind::BrokenPipe)),
|
|
||||||
"Peer closed write side before MTProto handshake completed",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
ProxyError::Io(std::io::Error::from(std::io::ErrorKind::NotConnected)),
|
|
||||||
"Handshake socket was already closed by peer",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
ProxyError::Stream(StreamError::UnexpectedEof),
|
|
||||||
"Peer closed before sending full 64-byte MTProto handshake",
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (err, expected) in cases {
|
|
||||||
assert_eq!(expected_handshake_close_description(&err), Some(expected));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,5 @@
|
|||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
#[cfg(unix)]
|
|
||||||
use std::io::{Error as IoError, ErrorKind};
|
|
||||||
use std::net::{IpAddr, SocketAddr};
|
use std::net::{IpAddr, SocketAddr};
|
||||||
#[cfg(unix)]
|
|
||||||
use std::os::unix::fs::{FileTypeExt, MetadataExt};
|
|
||||||
#[cfg(unix)]
|
|
||||||
use std::os::unix::net::UnixStream as StdUnixStream;
|
|
||||||
#[cfg(unix)]
|
|
||||||
use std::path::Path;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use socket2::Socket;
|
use socket2::Socket;
|
||||||
@@ -20,8 +12,6 @@ use crate::config::{ListenerTransport, ProxyConfig};
|
|||||||
use crate::startup::{COMPONENT_LISTENERS_BIND, StartupTracker};
|
use crate::startup::{COMPONENT_LISTENERS_BIND, StartupTracker};
|
||||||
use crate::transport::find_listener_processes;
|
use crate::transport::find_listener_processes;
|
||||||
use crate::transport::socket::{activate_listener_socket, bind_listener_socket};
|
use crate::transport::socket::{activate_listener_socket, bind_listener_socket};
|
||||||
#[cfg(unix)]
|
|
||||||
use crate::util::secure_fs::AnchoredPath;
|
|
||||||
|
|
||||||
use super::plan::{ListenerBindSpec, listener_bind_plan};
|
use super::plan::{ListenerBindSpec, listener_bind_plan};
|
||||||
use crate::maestro::helpers::{print_proxy_links, print_web_proxy_links};
|
use crate::maestro::helpers::{print_proxy_links, print_web_proxy_links};
|
||||||
@@ -197,64 +187,6 @@ fn print_configured_links(
|
|||||||
print_proxy_links(host, port, config);
|
print_proxy_links(host, port, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn unix_path_identity(metadata: &std::fs::Metadata) -> (u64, u64) {
|
|
||||||
(metadata.dev(), metadata.ino())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn remove_stale_unix_socket(path: &Path) -> std::io::Result<()> {
|
|
||||||
let metadata = match std::fs::symlink_metadata(path) {
|
|
||||||
Ok(metadata) => metadata,
|
|
||||||
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
|
|
||||||
Err(error) => return Err(error),
|
|
||||||
};
|
|
||||||
if !metadata.file_type().is_socket() {
|
|
||||||
return Err(IoError::new(
|
|
||||||
ErrorKind::AlreadyExists,
|
|
||||||
format!(
|
|
||||||
"refusing to remove non-socket Unix listener path {}",
|
|
||||||
path.display()
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
match StdUnixStream::connect(path) {
|
|
||||||
Ok(_) => {
|
|
||||||
return Err(IoError::new(
|
|
||||||
ErrorKind::AddrInUse,
|
|
||||||
format!("Unix listener {} is already active", path.display()),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Err(error) if error.kind() == ErrorKind::ConnectionRefused => {}
|
|
||||||
Err(error) => return Err(error),
|
|
||||||
}
|
|
||||||
let current = std::fs::symlink_metadata(path)?;
|
|
||||||
if !current.file_type().is_socket()
|
|
||||||
|| unix_path_identity(¤t) != unix_path_identity(&metadata)
|
|
||||||
{
|
|
||||||
return Err(IoError::new(
|
|
||||||
ErrorKind::AlreadyExists,
|
|
||||||
format!(
|
|
||||||
"Unix listener path {} changed during cleanup",
|
|
||||||
path.display()
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
std::fs::remove_file(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn verify_bound_unix_socket(path: &Path, expected: (u64, u64)) -> std::io::Result<()> {
|
|
||||||
let metadata = std::fs::symlink_metadata(path)?;
|
|
||||||
if metadata.file_type().is_socket() && unix_path_identity(&metadata) == expected {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
Err(IoError::new(
|
|
||||||
ErrorKind::AlreadyExists,
|
|
||||||
format!("Unix listener path {} was replaced", path.display()),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Binds every eligible configured listener or fails without a partial inventory.
|
/// Binds every eligible configured listener or fails without a partial inventory.
|
||||||
pub(crate) async fn bind_listeners(
|
pub(crate) async fn bind_listeners(
|
||||||
config: &Arc<ProxyConfig>,
|
config: &Arc<ProxyConfig>,
|
||||||
@@ -285,45 +217,27 @@ pub(crate) async fn bind_listeners(
|
|||||||
let mut unix_listener_out = None;
|
let mut unix_listener_out = None;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
if let Some(unix_path) = &config.server.listen_unix_sock {
|
if let Some(unix_path) = &config.server.listen_unix_sock {
|
||||||
let unix_path = Path::new(unix_path);
|
let _ = tokio::fs::remove_file(unix_path).await;
|
||||||
let anchored_path = AnchoredPath::open_trusted_parent(unix_path)?;
|
|
||||||
remove_stale_unix_socket(unix_path)?;
|
|
||||||
let unix_listener = UnixListener::bind(unix_path)?;
|
let unix_listener = UnixListener::bind(unix_path)?;
|
||||||
let socket_metadata = std::fs::symlink_metadata(unix_path)?;
|
|
||||||
if !socket_metadata.file_type().is_socket() {
|
|
||||||
return Err(IoError::new(
|
|
||||||
ErrorKind::AlreadyExists,
|
|
||||||
format!("Unix listener path {} was replaced", unix_path.display()),
|
|
||||||
)
|
|
||||||
.into());
|
|
||||||
}
|
|
||||||
let socket_identity = unix_path_identity(&socket_metadata);
|
|
||||||
if let Some(perm_str) = &config.server.listen_unix_sock_perm {
|
if let Some(perm_str) = &config.server.listen_unix_sock_perm {
|
||||||
match u32::from_str_radix(perm_str.trim_start_matches('0'), 8) {
|
match u32::from_str_radix(perm_str.trim_start_matches('0'), 8) {
|
||||||
Ok(mode) => {
|
Ok(mode) => {
|
||||||
use nix::sys::stat::{FchmodatFlags, Mode, fchmodat};
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let permissions = std::fs::Permissions::from_mode(mode);
|
||||||
verify_bound_unix_socket(unix_path, socket_identity)?;
|
if let Err(error_value) = std::fs::set_permissions(unix_path, permissions) {
|
||||||
if let Err(error_value) = fchmodat(
|
|
||||||
anchored_path.parent(),
|
|
||||||
anchored_path.name(),
|
|
||||||
Mode::from_bits_truncate(mode),
|
|
||||||
FchmodatFlags::NoFollowSymlink,
|
|
||||||
) {
|
|
||||||
error!(
|
error!(
|
||||||
path = %unix_path.display(),
|
path = %unix_path,
|
||||||
permissions = %perm_str,
|
permissions = %perm_str,
|
||||||
error = %error_value,
|
error = %error_value,
|
||||||
"Failed to set Unix socket permissions"
|
"Failed to set Unix socket permissions"
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
verify_bound_unix_socket(unix_path, socket_identity)?;
|
info!(path = %unix_path, permissions = %perm_str, "Listening on Unix socket");
|
||||||
info!(path = %unix_path.display(), permissions = %perm_str, "Listening on Unix socket");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(error_value) => {
|
Err(error_value) => {
|
||||||
warn!(
|
warn!(
|
||||||
path = %unix_path.display(),
|
path = %unix_path,
|
||||||
permissions = %perm_str,
|
permissions = %perm_str,
|
||||||
error = %error_value,
|
error = %error_value,
|
||||||
"Invalid Unix socket permissions; keeping umask-derived mode"
|
"Invalid Unix socket permissions; keeping umask-derived mode"
|
||||||
@@ -331,7 +245,7 @@ pub(crate) async fn bind_listeners(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
info!(path = %unix_path.display(), "Listening on Unix socket");
|
info!(path = %unix_path, "Listening on Unix socket");
|
||||||
}
|
}
|
||||||
unix_listener_out = Some(unix_listener);
|
unix_listener_out = Some(unix_listener);
|
||||||
}
|
}
|
||||||
@@ -357,54 +271,3 @@ pub(crate) async fn bind_listeners(
|
|||||||
unix_listener: unix_listener_out,
|
unix_listener: unix_listener_out,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(test, unix))]
|
|
||||||
mod tests {
|
|
||||||
use std::os::unix::fs::symlink;
|
|
||||||
use std::os::unix::net::UnixListener as StdUnixListener;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unix_socket_cleanup_refuses_regular_file_and_symlink() {
|
|
||||||
let directory = tempfile::tempdir().unwrap();
|
|
||||||
let regular = directory.path().join("regular");
|
|
||||||
let link = directory.path().join("listener.sock");
|
|
||||||
std::fs::write(®ular, b"preserve").unwrap();
|
|
||||||
symlink(®ular, &link).unwrap();
|
|
||||||
|
|
||||||
assert!(remove_stale_unix_socket(®ular).is_err());
|
|
||||||
assert!(remove_stale_unix_socket(&link).is_err());
|
|
||||||
assert_eq!(std::fs::read(®ular).unwrap(), b"preserve");
|
|
||||||
assert!(
|
|
||||||
std::fs::symlink_metadata(&link)
|
|
||||||
.unwrap()
|
|
||||||
.file_type()
|
|
||||||
.is_symlink()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unix_socket_cleanup_removes_only_stale_socket() {
|
|
||||||
let directory = tempfile::tempdir().unwrap();
|
|
||||||
let path = directory.path().join("listener.sock");
|
|
||||||
let listener = StdUnixListener::bind(&path).unwrap();
|
|
||||||
drop(listener);
|
|
||||||
|
|
||||||
remove_stale_unix_socket(&path).unwrap();
|
|
||||||
|
|
||||||
assert!(!path.exists());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unix_socket_cleanup_preserves_live_listener() {
|
|
||||||
let directory = tempfile::tempdir().unwrap();
|
|
||||||
let path = directory.path().join("listener.sock");
|
|
||||||
let _listener = StdUnixListener::bind(&path).unwrap();
|
|
||||||
|
|
||||||
let error = remove_stale_unix_socket(&path).unwrap_err();
|
|
||||||
|
|
||||||
assert_eq!(error.kind(), ErrorKind::AddrInUse);
|
|
||||||
assert!(path.exists());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+16
-74
@@ -3,8 +3,8 @@ use std::net::{IpAddr, SocketAddr};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use arc_swap::ArcSwap;
|
use arc_swap::ArcSwap;
|
||||||
use tokio::sync::{RwLock, Semaphore, watch};
|
use tokio::sync::{RwLock, watch};
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info};
|
||||||
|
|
||||||
use crate::api;
|
use crate::api;
|
||||||
use crate::ip_tracker::UserIpTracker;
|
use crate::ip_tracker::UserIpTracker;
|
||||||
@@ -12,9 +12,6 @@ use crate::network::probe::{decide_network_capabilities, log_probe_result, run_p
|
|||||||
use crate::proxy::direct_buffer_budget::{DirectBufferBudget, resolve_direct_buffer_hard_limit};
|
use crate::proxy::direct_buffer_budget::{DirectBufferBudget, resolve_direct_buffer_hard_limit};
|
||||||
use crate::proxy::route_mode::{RelayRouteMode, RouteRuntimeController};
|
use crate::proxy::route_mode::{RelayRouteMode, RouteRuntimeController};
|
||||||
use crate::proxy::shared_state::ProxySharedState;
|
use crate::proxy::shared_state::ProxySharedState;
|
||||||
use crate::proxy::traffic_limiter::TrafficLimiter;
|
|
||||||
use crate::proxy::user_admission::UserAdmissionAuthority;
|
|
||||||
use crate::proxy::user_connection_authority::UserConnectionAuthority;
|
|
||||||
use crate::startup::{COMPONENT_API_BOOTSTRAP, COMPONENT_NETWORK_PROBE};
|
use crate::startup::{COMPONENT_API_BOOTSTRAP, COMPONENT_NETWORK_PROBE};
|
||||||
use crate::stats::telemetry::TelemetryPolicy;
|
use crate::stats::telemetry::TelemetryPolicy;
|
||||||
use crate::stats::{QuotaStore, Stats};
|
use crate::stats::{QuotaStore, Stats};
|
||||||
@@ -40,7 +37,7 @@ pub(super) async fn run_telemt_core(
|
|||||||
process_started_at,
|
process_started_at,
|
||||||
process_started_at_epoch_secs,
|
process_started_at_epoch_secs,
|
||||||
startup_tracker,
|
startup_tracker,
|
||||||
mut config,
|
config,
|
||||||
config_path,
|
config_path,
|
||||||
has_rust_log,
|
has_rust_log,
|
||||||
effective_log_level,
|
effective_log_level,
|
||||||
@@ -48,22 +45,11 @@ pub(super) async fn run_telemt_core(
|
|||||||
logging_guard: _logging_guard,
|
logging_guard: _logging_guard,
|
||||||
} = bootstrap::bootstrap(privilege_drop_requested).await?;
|
} = bootstrap::bootstrap(privilege_drop_requested).await?;
|
||||||
|
|
||||||
if privilege_drop_requested && config.server.conntrack_control.inline_conntrack_control {
|
|
||||||
warn!("Inline conntrack control is disabled when process privileges are dropped");
|
|
||||||
config.server.conntrack_control.inline_conntrack_control = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let quota_store = Arc::new(QuotaStore::default());
|
let quota_store = Arc::new(QuotaStore::default());
|
||||||
let connection_authority = Arc::new(UserConnectionAuthority::default());
|
let stats = Arc::new(Stats::with_quota_store(quota_store.clone()));
|
||||||
let stats = Arc::new(Stats::with_process_authorities(
|
|
||||||
quota_store.clone(),
|
|
||||||
connection_authority,
|
|
||||||
));
|
|
||||||
let tls_full_cert_budget = Arc::new(TlsFullCertBudget::new());
|
let tls_full_cert_budget = Arc::new(TlsFullCertBudget::new());
|
||||||
let process_control_plane = control_plane::ProcessControlPlane::new();
|
let process_control_plane = control_plane::ProcessControlPlane::new();
|
||||||
let runtime_task_scope = generation::RuntimeTaskScope::new();
|
let runtime_task_scope = generation::RuntimeTaskScope::new();
|
||||||
let runtime_task_scope_guard =
|
|
||||||
generation::RuntimeTaskScopePreparationGuard::new(runtime_task_scope.clone());
|
|
||||||
stats.apply_telemetry_policy(TelemetryPolicy::from_config(&config.general.telemetry));
|
stats.apply_telemetry_policy(TelemetryPolicy::from_config(&config.general.telemetry));
|
||||||
let quota_state_path = config.general.quota_state_path.clone();
|
let quota_state_path = config.general.quota_state_path.clone();
|
||||||
let quota_state =
|
let quota_state =
|
||||||
@@ -85,11 +71,14 @@ pub(super) async fn run_telemt_core(
|
|||||||
.with_dns_overrides(&config.network.dns_overrides)?,
|
.with_dns_overrides(&config.network.dns_overrides)?,
|
||||||
);
|
);
|
||||||
let ip_tracker = Arc::new(UserIpTracker::new());
|
let ip_tracker = Arc::new(UserIpTracker::new());
|
||||||
let _ = ip_tracker
|
ip_tracker
|
||||||
.apply_policy_from_source(
|
.load_limits(
|
||||||
1,
|
|
||||||
config.access.user_max_unique_ips_global_each,
|
config.access.user_max_unique_ips_global_each,
|
||||||
&config.access.user_max_unique_ips,
|
&config.access.user_max_unique_ips,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
ip_tracker
|
||||||
|
.set_limit_policy(
|
||||||
config.access.user_max_unique_ips_mode,
|
config.access.user_max_unique_ips_mode,
|
||||||
config.access.user_max_unique_ips_window_secs,
|
config.access.user_max_unique_ips_window_secs,
|
||||||
)
|
)
|
||||||
@@ -112,36 +101,18 @@ pub(super) async fn run_telemt_core(
|
|||||||
let direct_buffer_hard_limit =
|
let direct_buffer_hard_limit =
|
||||||
resolve_direct_buffer_hard_limit(config.general.direct_relay_buffer_budget_max_bytes).await;
|
resolve_direct_buffer_hard_limit(config.general.direct_relay_buffer_budget_max_bytes).await;
|
||||||
let direct_buffer_budget = DirectBufferBudget::new(direct_buffer_hard_limit);
|
let direct_buffer_budget = DirectBufferBudget::new(direct_buffer_hard_limit);
|
||||||
direct_buffer_budget.activate_controller(1);
|
|
||||||
info!(
|
info!(
|
||||||
hard_limit_bytes = direct_buffer_hard_limit,
|
hard_limit_bytes = direct_buffer_hard_limit,
|
||||||
configured_override_bytes = config.general.direct_relay_buffer_budget_max_bytes,
|
configured_override_bytes = config.general.direct_relay_buffer_budget_max_bytes,
|
||||||
"Direct relay buffer budget initialized"
|
"Direct relay buffer budget initialized"
|
||||||
);
|
);
|
||||||
let user_admission = UserAdmissionAuthority::new_with_quota_store(quota_store.clone());
|
let shared_state =
|
||||||
let traffic_limiter = TrafficLimiter::new();
|
ProxySharedState::new_with_direct_buffer_budget(direct_buffer_budget.clone());
|
||||||
let _ = traffic_limiter.apply_policy_from_source(
|
shared_state.apply_user_enabled_config(&config.access.user_enabled);
|
||||||
1,
|
shared_state.traffic_limiter.apply_policy(
|
||||||
config.access.user_rate_limits.clone(),
|
config.access.user_rate_limits.clone(),
|
||||||
config.access.cidr_rate_limits.clone(),
|
config.access.cidr_rate_limits.clone(),
|
||||||
);
|
);
|
||||||
let shared_state = ProxySharedState::new_with_process_authorities(
|
|
||||||
direct_buffer_budget.clone(),
|
|
||||||
traffic_limiter,
|
|
||||||
user_admission,
|
|
||||||
);
|
|
||||||
let _ = shared_state.activate_user_config_source(
|
|
||||||
1,
|
|
||||||
None,
|
|
||||||
&config.access.users,
|
|
||||||
&config.access.user_enabled,
|
|
||||||
);
|
|
||||||
let max_connections_limit = if config.server.max_connections == 0 {
|
|
||||||
Semaphore::MAX_PERMITS
|
|
||||||
} else {
|
|
||||||
config.server.max_connections as usize
|
|
||||||
};
|
|
||||||
let max_connections = Arc::new(Semaphore::new(max_connections_limit));
|
|
||||||
let web_trace = WebTraceStore::new(config.web.debug.clone(), &config.web.limits);
|
let web_trace = WebTraceStore::new(config.web.debug.clone(), &config.web.limits);
|
||||||
let web_runtime_control = WebRuntimeControl::new();
|
let web_runtime_control = WebRuntimeControl::new();
|
||||||
|
|
||||||
@@ -323,7 +294,6 @@ pub(super) async fn run_telemt_core(
|
|||||||
ip_tracker.clone(),
|
ip_tracker.clone(),
|
||||||
shared_state.clone(),
|
shared_state.clone(),
|
||||||
direct_buffer_budget,
|
direct_buffer_budget,
|
||||||
max_connections,
|
|
||||||
route_runtime.clone(),
|
route_runtime.clone(),
|
||||||
api_me_pool.clone(),
|
api_me_pool.clone(),
|
||||||
runtime_task_scope.clone(),
|
runtime_task_scope.clone(),
|
||||||
@@ -354,7 +324,6 @@ pub(super) async fn run_telemt_core(
|
|||||||
runtime.max_connections,
|
runtime.max_connections,
|
||||||
runtime_task_scope,
|
runtime_task_scope,
|
||||||
);
|
);
|
||||||
runtime_task_scope_guard.disarm();
|
|
||||||
let active_runtime = Arc::new(ArcSwap::from(runtime_generation));
|
let active_runtime = Arc::new(ArcSwap::from(runtime_generation));
|
||||||
let bound = listeners::bind_listeners(
|
let bound = listeners::bind_listeners(
|
||||||
&runtime.config,
|
&runtime.config,
|
||||||
@@ -372,26 +341,9 @@ pub(super) async fn run_telemt_core(
|
|||||||
.await
|
.await
|
||||||
.map_err(std::io::Error::other)?;
|
.map_err(std::io::Error::other)?;
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
let conntrack_firewall = {
|
|
||||||
let authority = crate::conntrack_control::FirewallAuthority::spawn(&process_control_plane)
|
|
||||||
.map_err(std::io::Error::other)?;
|
|
||||||
if !authority
|
|
||||||
.publish_initial(1, runtime.config.clone(), stats.clone())
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
warn!(
|
|
||||||
"Initial conntrack firewall reconciliation failed; background retries remain active"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some(authority)
|
|
||||||
};
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
let conntrack_firewall = None::<crate::conntrack_control::FirewallAuthority>;
|
|
||||||
|
|
||||||
drop_after_bind();
|
drop_after_bind();
|
||||||
|
|
||||||
if let Err(error) = runtime_tasks::spawn_metrics_if_configured(
|
runtime_tasks::spawn_metrics_if_configured(
|
||||||
&runtime.config,
|
&runtime.config,
|
||||||
&startup_tracker,
|
&startup_tracker,
|
||||||
active_runtime.clone(),
|
active_runtime.clone(),
|
||||||
@@ -399,15 +351,7 @@ pub(super) async fn run_telemt_core(
|
|||||||
tls_full_cert_budget.clone(),
|
tls_full_cert_budget.clone(),
|
||||||
process_control_plane.clone(),
|
process_control_plane.clone(),
|
||||||
)
|
)
|
||||||
.await
|
.await?;
|
||||||
{
|
|
||||||
if let Some(conntrack_firewall) = &conntrack_firewall
|
|
||||||
&& !conntrack_firewall.shutdown_and_clear().await
|
|
||||||
{
|
|
||||||
warn!("Conntrack firewall cleanup failed after metrics startup error");
|
|
||||||
}
|
|
||||||
return Err(error.into());
|
|
||||||
}
|
|
||||||
|
|
||||||
runtime_watch_tx.send_replace(Some(active_runtime.load_full().watch_state()));
|
runtime_watch_tx.send_replace(Some(active_runtime.load_full().watch_state()));
|
||||||
active_runtime_tx.send_replace(Some(active_runtime.clone()));
|
active_runtime_tx.send_replace(Some(active_runtime.clone()));
|
||||||
@@ -431,7 +375,6 @@ pub(super) async fn run_telemt_core(
|
|||||||
runtime_watch_tx,
|
runtime_watch_tx,
|
||||||
listener_manager,
|
listener_manager,
|
||||||
web_trace,
|
web_trace,
|
||||||
conntrack_firewall.clone(),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
shutdown::spawn_signal_handlers(
|
shutdown::spawn_signal_handlers(
|
||||||
@@ -444,7 +387,6 @@ pub(super) async fn run_telemt_core(
|
|||||||
active_runtime,
|
active_runtime,
|
||||||
quota_state,
|
quota_state,
|
||||||
reload_supervisor,
|
reload_supervisor,
|
||||||
conntrack_firewall,
|
|
||||||
process_control_plane,
|
process_control_plane,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ use tokio::sync::watch;
|
|||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use crate::conntrack_control::FirewallAuthority;
|
|
||||||
use crate::stats::QuotaStore;
|
use crate::stats::QuotaStore;
|
||||||
use crate::tls_front::cache::TlsFullCertBudget;
|
use crate::tls_front::cache::TlsFullCertBudget;
|
||||||
use crate::web::trace::WebTraceStore;
|
use crate::web::trace::WebTraceStore;
|
||||||
@@ -34,7 +33,6 @@ pub(crate) struct ReloadSupervisor {
|
|||||||
runtime_watch_tx: watch::Sender<Option<RuntimeWatchState>>,
|
runtime_watch_tx: watch::Sender<Option<RuntimeWatchState>>,
|
||||||
listener_manager: Arc<Mutex<ListenerManager>>,
|
listener_manager: Arc<Mutex<ListenerManager>>,
|
||||||
web_trace: Arc<WebTraceStore>,
|
web_trace: Arc<WebTraceStore>,
|
||||||
conntrack_firewall: Option<FirewallAuthority>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Process-owned handle that quiesces reloads before shutdown snapshots the runtime.
|
/// Process-owned handle that quiesces reloads before shutdown snapshots the runtime.
|
||||||
@@ -108,7 +106,6 @@ impl ReloadSupervisor {
|
|||||||
runtime_watch_tx: watch::Sender<Option<RuntimeWatchState>>,
|
runtime_watch_tx: watch::Sender<Option<RuntimeWatchState>>,
|
||||||
listener_manager: ListenerManager,
|
listener_manager: ListenerManager,
|
||||||
web_trace: Arc<WebTraceStore>,
|
web_trace: Arc<WebTraceStore>,
|
||||||
conntrack_firewall: Option<FirewallAuthority>,
|
|
||||||
) -> ReloadSupervisorHandle {
|
) -> ReloadSupervisorHandle {
|
||||||
let listener_manager = Arc::new(Mutex::new(listener_manager));
|
let listener_manager = Arc::new(Mutex::new(listener_manager));
|
||||||
let supervisor = Self {
|
let supervisor = Self {
|
||||||
@@ -123,7 +120,6 @@ impl ReloadSupervisor {
|
|||||||
runtime_watch_tx,
|
runtime_watch_tx,
|
||||||
listener_manager: listener_manager.clone(),
|
listener_manager: listener_manager.clone(),
|
||||||
web_trace,
|
web_trace,
|
||||||
conntrack_firewall,
|
|
||||||
};
|
};
|
||||||
let control = supervisor.control.clone();
|
let control = supervisor.control.clone();
|
||||||
let shutdown = CancellationToken::new();
|
let shutdown = CancellationToken::new();
|
||||||
@@ -179,14 +175,8 @@ impl ReloadSupervisor {
|
|||||||
resolved.effective,
|
resolved.effective,
|
||||||
&self.config_path,
|
&self.config_path,
|
||||||
self.quota_store.clone(),
|
self.quota_store.clone(),
|
||||||
old_runtime.stats.connection_authority(),
|
|
||||||
self.runtime_log_filter.clone(),
|
self.runtime_log_filter.clone(),
|
||||||
self.tls_full_cert_budget.clone(),
|
self.tls_full_cert_budget.clone(),
|
||||||
old_runtime.proxy_shared.user_admission(),
|
|
||||||
old_runtime.ip_tracker.clone(),
|
|
||||||
old_runtime.proxy_shared.traffic_limiter.clone(),
|
|
||||||
old_runtime.proxy_shared.direct_buffer_budget.clone(),
|
|
||||||
old_runtime.max_connections.clone(),
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -287,7 +277,6 @@ impl ReloadSupervisor {
|
|||||||
generation: new_runtime,
|
generation: new_runtime,
|
||||||
detected_ips,
|
detected_ips,
|
||||||
config_watcher_activation,
|
config_watcher_activation,
|
||||||
user_admission_epoch,
|
|
||||||
} = prepared;
|
} = prepared;
|
||||||
let pending_listener_transition = if let Some(listener_transition) = listener_transition {
|
let pending_listener_transition = if let Some(listener_transition) = listener_transition {
|
||||||
match self
|
match self
|
||||||
@@ -309,48 +298,11 @@ impl ReloadSupervisor {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
let config = new_runtime.config();
|
|
||||||
let _ = new_runtime.proxy_shared.activate_user_config_source(
|
|
||||||
new_runtime.id,
|
|
||||||
Some(user_admission_epoch),
|
|
||||||
&config.access.users,
|
|
||||||
&config.access.user_enabled,
|
|
||||||
);
|
|
||||||
let _ = new_runtime
|
|
||||||
.ip_tracker
|
|
||||||
.apply_policy_from_source(
|
|
||||||
new_runtime.id,
|
|
||||||
config.access.user_max_unique_ips_global_each,
|
|
||||||
&config.access.user_max_unique_ips,
|
|
||||||
config.access.user_max_unique_ips_mode,
|
|
||||||
config.access.user_max_unique_ips_window_secs,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let _ = new_runtime
|
|
||||||
.proxy_shared
|
|
||||||
.traffic_limiter
|
|
||||||
.apply_policy_from_source(
|
|
||||||
new_runtime.id,
|
|
||||||
config.access.user_rate_limits.clone(),
|
|
||||||
config.access.cidr_rate_limits.clone(),
|
|
||||||
);
|
|
||||||
new_runtime
|
|
||||||
.proxy_shared
|
|
||||||
.direct_buffer_budget
|
|
||||||
.activate_controller(new_runtime.id);
|
|
||||||
let replaced = {
|
let replaced = {
|
||||||
let listener_manager = self.listener_manager.lock().await;
|
let listener_manager = self.listener_manager.lock().await;
|
||||||
old_runtime.stop_accepting_sessions();
|
old_runtime.stop_accepting_sessions();
|
||||||
listener_manager.activate_runtime_generation(new_runtime.clone())
|
listener_manager.activate_runtime_generation(new_runtime.clone())
|
||||||
};
|
};
|
||||||
let conntrack_firewall_published = match &self.conntrack_firewall {
|
|
||||||
Some(conntrack_firewall) => conntrack_firewall.publish(
|
|
||||||
new_runtime.id,
|
|
||||||
new_runtime.config(),
|
|
||||||
new_runtime.stats.clone(),
|
|
||||||
),
|
|
||||||
None => true,
|
|
||||||
};
|
|
||||||
self.web_trace
|
self.web_trace
|
||||||
.apply_policy(new_runtime.id, &new_runtime.config().web.debug);
|
.apply_policy(new_runtime.id, &new_runtime.config().web.debug);
|
||||||
config_watcher_activation.send_replace(true);
|
config_watcher_activation.send_replace(true);
|
||||||
@@ -365,12 +317,6 @@ impl ReloadSupervisor {
|
|||||||
.apply_reload(&new_runtime.config().general.log_level);
|
.apply_reload(&new_runtime.config().general.log_level);
|
||||||
self.runtime_watch_tx
|
self.runtime_watch_tx
|
||||||
.send_replace(Some(new_runtime.watch_state()));
|
.send_replace(Some(new_runtime.watch_state()));
|
||||||
if !conntrack_firewall_published {
|
|
||||||
let warning =
|
|
||||||
"conntrack firewall reconciler is unavailable after runtime activation".to_string();
|
|
||||||
warn!(reload_id = command.reload_id, warning = %warning);
|
|
||||||
self.control.add_warning(command.reload_id, warning).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
reload_id = command.reload_id,
|
reload_id = command.reload_id,
|
||||||
|
|||||||
@@ -23,12 +23,10 @@ fn runtime_log_filter() -> RuntimeLogFilter {
|
|||||||
|
|
||||||
fn prepared_runtime(generation: Arc<RuntimeGeneration>) -> PreparedRuntime {
|
fn prepared_runtime(generation: Arc<RuntimeGeneration>) -> PreparedRuntime {
|
||||||
let (config_watcher_activation, _activation_rx) = watch::channel(false);
|
let (config_watcher_activation, _activation_rx) = watch::channel(false);
|
||||||
let user_admission_epoch = generation.proxy_shared.user_admission().epoch();
|
|
||||||
PreparedRuntime {
|
PreparedRuntime {
|
||||||
generation,
|
generation,
|
||||||
detected_ips: (None, None),
|
detected_ips: (None, None),
|
||||||
config_watcher_activation,
|
config_watcher_activation,
|
||||||
user_admission_epoch,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +59,6 @@ async fn fixture(request: ReloadRequest) -> ReloadFixture {
|
|||||||
runtime_watch_tx,
|
runtime_watch_tx,
|
||||||
listener_manager,
|
listener_manager,
|
||||||
web_trace,
|
web_trace,
|
||||||
conntrack_firewall: None,
|
|
||||||
});
|
});
|
||||||
let command = ReloadCommand {
|
let command = ReloadCommand {
|
||||||
reload_id: accepted.reload_id,
|
reload_id: accepted.reload_id,
|
||||||
@@ -276,7 +273,6 @@ async fn quiesce_joins_idle_supervisor_and_rejects_later_submissions() {
|
|||||||
runtime.config().web.debug.clone(),
|
runtime.config().web.debug.clone(),
|
||||||
&runtime.config().web.limits,
|
&runtime.config().web.limits,
|
||||||
),
|
),
|
||||||
None,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
tokio::time::timeout(Duration::from_secs(1), handle.quiesce())
|
tokio::time::timeout(Duration::from_secs(1), handle.quiesce())
|
||||||
|
|||||||
@@ -11,12 +11,11 @@ use crate::config::{
|
|||||||
use crate::crypto::SecureRandom;
|
use crate::crypto::SecureRandom;
|
||||||
use crate::ip_tracker::UserIpTracker;
|
use crate::ip_tracker::UserIpTracker;
|
||||||
use crate::network::probe::{decide_network_capabilities, run_probe};
|
use crate::network::probe::{decide_network_capabilities, run_probe};
|
||||||
use crate::proxy::direct_buffer_budget::{DirectBufferBudget, run_direct_buffer_budget_controller};
|
use crate::proxy::direct_buffer_budget::{
|
||||||
|
DirectBufferBudget, resolve_direct_buffer_hard_limit, run_direct_buffer_budget_controller,
|
||||||
|
};
|
||||||
use crate::proxy::route_mode::{RelayRouteMode, RouteRuntimeController};
|
use crate::proxy::route_mode::{RelayRouteMode, RouteRuntimeController};
|
||||||
use crate::proxy::shared_state::ProxySharedState;
|
use crate::proxy::shared_state::ProxySharedState;
|
||||||
use crate::proxy::traffic_limiter::TrafficLimiter;
|
|
||||||
use crate::proxy::user_admission::UserAdmissionAuthority;
|
|
||||||
use crate::proxy::user_connection_authority::UserConnectionAuthority;
|
|
||||||
use crate::startup::StartupTracker;
|
use crate::startup::StartupTracker;
|
||||||
use crate::stats::beobachten::BeobachtenStore;
|
use crate::stats::beobachten::BeobachtenStore;
|
||||||
use crate::stats::telemetry::TelemetryPolicy;
|
use crate::stats::telemetry::TelemetryPolicy;
|
||||||
@@ -27,7 +26,7 @@ use crate::transport::UpstreamManager;
|
|||||||
use crate::transport::middle_proxy::MePool;
|
use crate::transport::middle_proxy::MePool;
|
||||||
|
|
||||||
use super::admission;
|
use super::admission;
|
||||||
use super::generation::{RuntimeGeneration, RuntimeTaskScope, RuntimeTaskScopePreparationGuard};
|
use super::generation::{RuntimeGeneration, RuntimeTaskScope};
|
||||||
use super::listeners::listener_rebind_supported;
|
use super::listeners::listener_rebind_supported;
|
||||||
use super::runtime_tasks::RuntimeLogFilter;
|
use super::runtime_tasks::RuntimeLogFilter;
|
||||||
use super::{me_startup, runtime_tasks, tls_bootstrap};
|
use super::{me_startup, runtime_tasks, tls_bootstrap};
|
||||||
@@ -40,8 +39,6 @@ pub(crate) struct PreparedRuntime {
|
|||||||
pub(crate) detected_ips: (Option<IpAddr>, Option<IpAddr>),
|
pub(crate) detected_ips: (Option<IpAddr>, Option<IpAddr>),
|
||||||
/// Gate opened only after the candidate becomes the active generation.
|
/// Gate opened only after the candidate becomes the active generation.
|
||||||
pub(crate) config_watcher_activation: watch::Sender<bool>,
|
pub(crate) config_watcher_activation: watch::Sender<bool>,
|
||||||
/// User-authority epoch captured before candidate construction.
|
|
||||||
pub(crate) user_admission_epoch: u64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn prepare_runtime(
|
pub(crate) async fn prepare_runtime(
|
||||||
@@ -49,16 +46,9 @@ pub(crate) async fn prepare_runtime(
|
|||||||
config: ProxyConfig,
|
config: ProxyConfig,
|
||||||
config_path: &Path,
|
config_path: &Path,
|
||||||
quota_store: Arc<QuotaStore>,
|
quota_store: Arc<QuotaStore>,
|
||||||
connection_authority: Arc<UserConnectionAuthority>,
|
|
||||||
runtime_log_filter: RuntimeLogFilter,
|
runtime_log_filter: RuntimeLogFilter,
|
||||||
tls_full_cert_budget: Arc<TlsFullCertBudget>,
|
tls_full_cert_budget: Arc<TlsFullCertBudget>,
|
||||||
user_admission: Arc<UserAdmissionAuthority>,
|
|
||||||
ip_tracker: Arc<UserIpTracker>,
|
|
||||||
traffic_limiter: Arc<TrafficLimiter>,
|
|
||||||
direct_buffer_budget: Arc<DirectBufferBudget>,
|
|
||||||
max_connections: Arc<Semaphore>,
|
|
||||||
) -> Result<PreparedRuntime, String> {
|
) -> Result<PreparedRuntime, String> {
|
||||||
let user_admission_epoch = user_admission.epoch();
|
|
||||||
config
|
config
|
||||||
.validate_web_decoy_listener_separation()
|
.validate_web_decoy_listener_separation()
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
@@ -68,11 +58,7 @@ pub(crate) async fn prepare_runtime(
|
|||||||
.as_secs();
|
.as_secs();
|
||||||
let startup_tracker = Arc::new(StartupTracker::new(started_at_epoch_secs));
|
let startup_tracker = Arc::new(StartupTracker::new(started_at_epoch_secs));
|
||||||
let task_scope = RuntimeTaskScope::new();
|
let task_scope = RuntimeTaskScope::new();
|
||||||
let task_scope_guard = RuntimeTaskScopePreparationGuard::new(task_scope.clone());
|
let stats = Arc::new(Stats::with_quota_store(quota_store));
|
||||||
let stats = Arc::new(Stats::with_process_authorities(
|
|
||||||
quota_store,
|
|
||||||
connection_authority,
|
|
||||||
));
|
|
||||||
stats.apply_telemetry_policy(TelemetryPolicy::from_config(&config.general.telemetry));
|
stats.apply_telemetry_policy(TelemetryPolicy::from_config(&config.general.telemetry));
|
||||||
|
|
||||||
let upstream_manager = Arc::new(
|
let upstream_manager = Arc::new(
|
||||||
@@ -89,10 +75,29 @@ pub(crate) async fn prepare_runtime(
|
|||||||
.with_dns_overrides(&config.network.dns_overrides)
|
.with_dns_overrides(&config.network.dns_overrides)
|
||||||
.map_err(|error| format!("DNS override preparation failed: {}", error))?,
|
.map_err(|error| format!("DNS override preparation failed: {}", error))?,
|
||||||
);
|
);
|
||||||
let proxy_shared = ProxySharedState::new_with_process_authorities(
|
let ip_tracker = Arc::new(UserIpTracker::new());
|
||||||
direct_buffer_budget.clone(),
|
ip_tracker
|
||||||
traffic_limiter,
|
.load_limits(
|
||||||
user_admission,
|
config.access.user_max_unique_ips_global_each,
|
||||||
|
&config.access.user_max_unique_ips,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
ip_tracker
|
||||||
|
.set_limit_policy(
|
||||||
|
config.access.user_max_unique_ips_mode,
|
||||||
|
config.access.user_max_unique_ips_window_secs,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let hard_limit =
|
||||||
|
resolve_direct_buffer_hard_limit(config.general.direct_relay_buffer_budget_max_bytes).await;
|
||||||
|
let direct_buffer_budget = DirectBufferBudget::new(hard_limit);
|
||||||
|
let proxy_shared =
|
||||||
|
ProxySharedState::new_with_direct_buffer_budget(direct_buffer_budget.clone());
|
||||||
|
proxy_shared.apply_user_enabled_config(&config.access.user_enabled);
|
||||||
|
proxy_shared.traffic_limiter.apply_policy(
|
||||||
|
config.access.user_rate_limits.clone(),
|
||||||
|
config.access.cidr_rate_limits.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let probe = run_probe(
|
let probe = run_probe(
|
||||||
@@ -173,9 +178,14 @@ pub(crate) async fn prepare_runtime(
|
|||||||
Duration::from_secs(config.access.replay_window_secs),
|
Duration::from_secs(config.access.replay_window_secs),
|
||||||
));
|
));
|
||||||
let buffer_pool = Arc::new(BufferPool::with_config(64 * 1024, 4096));
|
let buffer_pool = Arc::new(BufferPool::with_config(64 * 1024, 4096));
|
||||||
|
let max_connections_limit = if config.server.max_connections == 0 {
|
||||||
|
Semaphore::MAX_PERMITS
|
||||||
|
} else {
|
||||||
|
config.server.max_connections as usize
|
||||||
|
};
|
||||||
|
let max_connections = Arc::new(Semaphore::new(max_connections_limit));
|
||||||
let (config_watcher_activation, config_watcher_activation_rx) = watch::channel(false);
|
let (config_watcher_activation, config_watcher_activation_rx) = watch::channel(false);
|
||||||
let watches = runtime_tasks::spawn_runtime_tasks(
|
let watches = runtime_tasks::spawn_runtime_tasks(
|
||||||
generation_id,
|
|
||||||
&config,
|
&config,
|
||||||
config_path,
|
config_path,
|
||||||
&probe,
|
&probe,
|
||||||
@@ -271,12 +281,10 @@ pub(crate) async fn prepare_runtime(
|
|||||||
conntrack_scope.cancellation_token(),
|
conntrack_scope.cancellation_token(),
|
||||||
));
|
));
|
||||||
task_scope.spawn(run_direct_buffer_budget_controller(
|
task_scope.spawn(run_direct_buffer_budget_controller(
|
||||||
generation_id,
|
|
||||||
direct_buffer_budget,
|
direct_buffer_budget,
|
||||||
buffer_pool.clone(),
|
buffer_pool.clone(),
|
||||||
stats.clone(),
|
stats.clone(),
|
||||||
proxy_shared.clone(),
|
proxy_shared.clone(),
|
||||||
max_connections.clone(),
|
|
||||||
config.server.max_connections,
|
config.server.max_connections,
|
||||||
));
|
));
|
||||||
let generation = RuntimeGeneration::new(
|
let generation = RuntimeGeneration::new(
|
||||||
@@ -298,13 +306,11 @@ pub(crate) async fn prepare_runtime(
|
|||||||
max_connections,
|
max_connections,
|
||||||
task_scope,
|
task_scope,
|
||||||
);
|
);
|
||||||
task_scope_guard.disarm();
|
|
||||||
drop(admission_tx);
|
drop(admission_tx);
|
||||||
|
|
||||||
Ok(PreparedRuntime {
|
Ok(PreparedRuntime {
|
||||||
generation,
|
generation,
|
||||||
config_watcher_activation,
|
config_watcher_activation,
|
||||||
user_admission_epoch,
|
|
||||||
detected_ips: (
|
detected_ips: (
|
||||||
probe.detected_ipv4.map(IpAddr::V4),
|
probe.detected_ipv4.map(IpAddr::V4),
|
||||||
probe.detected_ipv6.map(IpAddr::V6),
|
probe.detected_ipv6.map(IpAddr::V6),
|
||||||
@@ -400,23 +406,6 @@ pub(crate) fn resolve_reload_config(
|
|||||||
effective.server.metrics_listen = old.server.metrics_listen.clone();
|
effective.server.metrics_listen = old.server.metrics_listen.clone();
|
||||||
effective.server.metrics_port = old.server.metrics_port;
|
effective.server.metrics_port = old.server.metrics_port;
|
||||||
}
|
}
|
||||||
if old.server.max_connections != desired.server.max_connections {
|
|
||||||
fields.push("server.max_connections".to_string());
|
|
||||||
effective.server.max_connections = old.server.max_connections;
|
|
||||||
}
|
|
||||||
if serde_json::to_value(&old.server.conntrack_control).ok()
|
|
||||||
!= serde_json::to_value(&desired.server.conntrack_control).ok()
|
|
||||||
{
|
|
||||||
fields.push("server.conntrack_control".to_string());
|
|
||||||
effective.server.conntrack_control = old.server.conntrack_control.clone();
|
|
||||||
}
|
|
||||||
if old.general.direct_relay_buffer_budget_max_bytes
|
|
||||||
!= desired.general.direct_relay_buffer_budget_max_bytes
|
|
||||||
{
|
|
||||||
fields.push("general.direct_relay_buffer_budget_max_bytes".to_string());
|
|
||||||
effective.general.direct_relay_buffer_budget_max_bytes =
|
|
||||||
old.general.direct_relay_buffer_budget_max_bytes;
|
|
||||||
}
|
|
||||||
if old.general.quota_state_path != desired.general.quota_state_path {
|
if old.general.quota_state_path != desired.general.quota_state_path {
|
||||||
fields.push("general.quota_state_path".to_string());
|
fields.push("general.quota_state_path".to_string());
|
||||||
effective.general.quota_state_path = old.general.quota_state_path.clone();
|
effective.general.quota_state_path = old.general.quota_state_path.clone();
|
||||||
|
|||||||
@@ -94,70 +94,6 @@ fn global_mss_profiles_are_deferred_with_the_listener_socket_group() {
|
|||||||
assert!(!resolved.runtime_changed);
|
assert!(!resolved.runtime_changed);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn process_wide_connection_and_direct_buffer_envelopes_are_restart_only() {
|
|
||||||
let old = ProxyConfig::default();
|
|
||||||
let mut desired = old.clone();
|
|
||||||
desired.server.max_connections = old.server.max_connections.saturating_add(1);
|
|
||||||
desired.general.direct_relay_buffer_budget_max_bytes = old
|
|
||||||
.general
|
|
||||||
.direct_relay_buffer_budget_max_bytes
|
|
||||||
.saturating_add(4 * 1024);
|
|
||||||
|
|
||||||
let resolved = resolve_reload_config(&old, &desired).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
resolved.deferred_process_fields,
|
|
||||||
vec![
|
|
||||||
"server.max_connections".to_string(),
|
|
||||||
"general.direct_relay_buffer_budget_max_bytes".to_string(),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
resolved.effective.server.max_connections,
|
|
||||||
old.server.max_connections
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
resolved
|
|
||||||
.effective
|
|
||||||
.general
|
|
||||||
.direct_relay_buffer_budget_max_bytes,
|
|
||||||
old.general.direct_relay_buffer_budget_max_bytes
|
|
||||||
);
|
|
||||||
assert!(!resolved.runtime_changed);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn conntrack_control_policy_is_restart_only_as_one_process_owned_unit() {
|
|
||||||
let old = ProxyConfig::default();
|
|
||||||
let mut desired = old.clone();
|
|
||||||
desired.server.conntrack_control.inline_conntrack_control =
|
|
||||||
!old.server.conntrack_control.inline_conntrack_control;
|
|
||||||
desired.server.conntrack_control.mode = crate::config::ConntrackMode::Notrack;
|
|
||||||
desired.server.conntrack_control.backend = crate::config::ConntrackBackend::Iptables;
|
|
||||||
desired.server.conntrack_control.profile = crate::config::ConntrackPressureProfile::Aggressive;
|
|
||||||
desired.server.conntrack_control.hybrid_listener_ips = vec!["192.0.2.10".parse().unwrap()];
|
|
||||||
desired.server.conntrack_control.pressure_high_watermark_pct = 90;
|
|
||||||
desired.server.conntrack_control.pressure_low_watermark_pct = 40;
|
|
||||||
desired.server.conntrack_control.delete_budget_per_sec = old
|
|
||||||
.server
|
|
||||||
.conntrack_control
|
|
||||||
.delete_budget_per_sec
|
|
||||||
.saturating_add(1);
|
|
||||||
|
|
||||||
let resolved = resolve_reload_config(&old, &desired).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
resolved.deferred_process_fields,
|
|
||||||
vec!["server.conntrack_control".to_string()]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::to_value(&resolved.effective.server.conntrack_control).unwrap(),
|
|
||||||
serde_json::to_value(&old.server.conntrack_control).unwrap()
|
|
||||||
);
|
|
||||||
assert!(!resolved.runtime_changed);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mixed_reload_retains_process_state_and_applies_runtime_state() {
|
fn mixed_reload_retains_process_state_and_applies_runtime_state() {
|
||||||
let old = ProxyConfig::default();
|
let old = ProxyConfig::default();
|
||||||
@@ -295,28 +231,6 @@ fn web_decoy_fasttrack_mode_is_deferred_without_runtime_publication() {
|
|||||||
assert!(!resolved.runtime_changed);
|
assert!(!resolved.runtime_changed);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn base_path_change_is_runtime_owned_and_rebuilds_route_identity() {
|
|
||||||
let old = web_config_with_fasttrack("off");
|
|
||||||
let old_runtime = old.web.runtime.as_ref().unwrap();
|
|
||||||
let old_capability = old_runtime.vhosts["proxy.example.com"].capabilities[0];
|
|
||||||
let mut desired = old.clone();
|
|
||||||
desired.web.vhosts[0].base_path = "MixedCase/path".to_string();
|
|
||||||
|
|
||||||
let resolved = resolve_reload_config(&old, &desired).unwrap();
|
|
||||||
|
|
||||||
assert!(resolved.deferred_process_fields.is_empty());
|
|
||||||
assert!(resolved.runtime_changed);
|
|
||||||
assert_eq!(resolved.effective.web.vhosts[0].base_path, "MixedCase/path");
|
|
||||||
let runtime = resolved.effective.web.runtime.as_ref().unwrap();
|
|
||||||
let vhost = &runtime.vhosts["proxy.example.com"];
|
|
||||||
assert_eq!(vhost.base, "/MixedCase/path/");
|
|
||||||
assert_ne!(vhost.capabilities[0], old_capability);
|
|
||||||
assert_eq!(vhost.capabilities[0], vhost.profiles[0].capability);
|
|
||||||
assert_eq!(runtime.capabilities.as_ref(), vhost.capabilities.as_ref());
|
|
||||||
assert!(!runtime.capabilities.contains(&old_capability));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn enabling_learning_is_deferred_when_retained_capacity_is_too_small() {
|
fn enabling_learning_is_deferred_when_retained_capacity_is_too_small() {
|
||||||
let mut old = ProxyConfig::default();
|
let mut old = ProxyConfig::default();
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ pub(super) async fn prepare_runtime(
|
|||||||
ip_tracker: Arc<UserIpTracker>,
|
ip_tracker: Arc<UserIpTracker>,
|
||||||
shared_state: Arc<ProxySharedState>,
|
shared_state: Arc<ProxySharedState>,
|
||||||
direct_buffer_budget: Arc<DirectBufferBudget>,
|
direct_buffer_budget: Arc<DirectBufferBudget>,
|
||||||
max_connections: Arc<Semaphore>,
|
|
||||||
route_runtime: Arc<RouteRuntimeController>,
|
route_runtime: Arc<RouteRuntimeController>,
|
||||||
api_me_pool: Arc<RwLock<Option<Arc<MePool>>>>,
|
api_me_pool: Arc<RwLock<Option<Arc<MePool>>>>,
|
||||||
runtime_task_scope: RuntimeTaskScope,
|
runtime_task_scope: RuntimeTaskScope,
|
||||||
@@ -70,6 +69,13 @@ pub(super) async fn prepare_runtime(
|
|||||||
let beobachten = Arc::new(BeobachtenStore::new());
|
let beobachten = Arc::new(BeobachtenStore::new());
|
||||||
let rng = Arc::new(SecureRandom::new());
|
let rng = Arc::new(SecureRandom::new());
|
||||||
|
|
||||||
|
let max_connections_limit = if config.server.max_connections == 0 {
|
||||||
|
Semaphore::MAX_PERMITS
|
||||||
|
} else {
|
||||||
|
config.server.max_connections as usize
|
||||||
|
};
|
||||||
|
let max_connections = Arc::new(Semaphore::new(max_connections_limit));
|
||||||
|
|
||||||
let me2dc_fallback = config.general.me2dc_fallback;
|
let me2dc_fallback = config.general.me2dc_fallback;
|
||||||
let me_init_retry_attempts = config.general.me_init_retry_attempts;
|
let me_init_retry_attempts = config.general.me_init_retry_attempts;
|
||||||
if use_middle_proxy && !decision.ipv4_me && !decision.ipv6_me {
|
if use_middle_proxy && !decision.ipv4_me && !decision.ipv6_me {
|
||||||
@@ -223,7 +229,6 @@ pub(super) async fn prepare_runtime(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let runtime_watches = runtime_tasks::spawn_runtime_tasks(
|
let runtime_watches = runtime_tasks::spawn_runtime_tasks(
|
||||||
1,
|
|
||||||
&config,
|
&config,
|
||||||
config_path,
|
config_path,
|
||||||
probe,
|
probe,
|
||||||
@@ -340,12 +345,10 @@ pub(super) async fn prepare_runtime(
|
|||||||
conntrack_scope.cancellation_token(),
|
conntrack_scope.cancellation_token(),
|
||||||
));
|
));
|
||||||
runtime_task_scope.spawn(run_direct_buffer_budget_controller(
|
runtime_task_scope.spawn(run_direct_buffer_budget_controller(
|
||||||
1,
|
|
||||||
direct_buffer_budget,
|
direct_buffer_budget,
|
||||||
buffer_pool.clone(),
|
buffer_pool.clone(),
|
||||||
stats,
|
stats,
|
||||||
shared_state,
|
shared_state,
|
||||||
max_connections.clone(),
|
|
||||||
config.server.max_connections,
|
config.server.max_connections,
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ impl RuntimeLogFilter {
|
|||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub(crate) async fn spawn_runtime_tasks(
|
pub(crate) async fn spawn_runtime_tasks(
|
||||||
generation_id: u64,
|
|
||||||
config: &Arc<ProxyConfig>,
|
config: &Arc<ProxyConfig>,
|
||||||
config_path: &Path,
|
config_path: &Path,
|
||||||
probe: &NetworkProbe,
|
probe: &NetworkProbe,
|
||||||
@@ -138,9 +137,7 @@ pub(crate) async fn spawn_runtime_tasks(
|
|||||||
|
|
||||||
let ip_tracker_maintenance = ip_tracker.clone();
|
let ip_tracker_maintenance = ip_tracker.clone();
|
||||||
task_scope.spawn(async move {
|
task_scope.spawn(async move {
|
||||||
ip_tracker_maintenance
|
ip_tracker_maintenance.run_periodic_maintenance().await;
|
||||||
.run_periodic_maintenance(generation_id)
|
|
||||||
.await;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let detected_ip_v4: Option<IpAddr> = probe.detected_ipv4.map(IpAddr::V4);
|
let detected_ip_v4: Option<IpAddr> = probe.detected_ipv4.map(IpAddr::V4);
|
||||||
@@ -199,7 +196,20 @@ pub(crate) async fn spawn_runtime_tasks(
|
|||||||
let ip_tracker_policy = ip_tracker.clone();
|
let ip_tracker_policy = ip_tracker.clone();
|
||||||
let mut config_rx_ip_limits = config_rx.clone();
|
let mut config_rx_ip_limits = config_rx.clone();
|
||||||
task_scope.spawn(async move {
|
task_scope.spawn(async move {
|
||||||
let mut previous = config_rx_ip_limits.borrow().access.clone();
|
let mut prev_limits = config_rx_ip_limits
|
||||||
|
.borrow()
|
||||||
|
.access
|
||||||
|
.user_max_unique_ips
|
||||||
|
.clone();
|
||||||
|
let mut prev_global_each = config_rx_ip_limits
|
||||||
|
.borrow()
|
||||||
|
.access
|
||||||
|
.user_max_unique_ips_global_each;
|
||||||
|
let mut prev_mode = config_rx_ip_limits.borrow().access.user_max_unique_ips_mode;
|
||||||
|
let mut prev_window = config_rx_ip_limits
|
||||||
|
.borrow()
|
||||||
|
.access
|
||||||
|
.user_max_unique_ips_window_secs;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
if config_rx_ip_limits.changed().await.is_err() {
|
if config_rx_ip_limits.changed().await.is_err() {
|
||||||
@@ -207,28 +217,39 @@ pub(crate) async fn spawn_runtime_tasks(
|
|||||||
}
|
}
|
||||||
let cfg = config_rx_ip_limits.borrow_and_update().clone();
|
let cfg = config_rx_ip_limits.borrow_and_update().clone();
|
||||||
|
|
||||||
if previous.user_max_unique_ips != cfg.access.user_max_unique_ips
|
if prev_limits != cfg.access.user_max_unique_ips
|
||||||
|| previous.user_max_unique_ips_global_each
|
|| prev_global_each != cfg.access.user_max_unique_ips_global_each
|
||||||
!= cfg.access.user_max_unique_ips_global_each
|
|
||||||
|| previous.user_max_unique_ips_mode != cfg.access.user_max_unique_ips_mode
|
|
||||||
|| previous.user_max_unique_ips_window_secs
|
|
||||||
!= cfg.access.user_max_unique_ips_window_secs
|
|
||||||
{
|
{
|
||||||
let _ = ip_tracker_policy
|
ip_tracker_policy
|
||||||
.apply_policy_from_source(
|
.load_limits(
|
||||||
generation_id,
|
|
||||||
cfg.access.user_max_unique_ips_global_each,
|
cfg.access.user_max_unique_ips_global_each,
|
||||||
&cfg.access.user_max_unique_ips,
|
&cfg.access.user_max_unique_ips,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
prev_limits = cfg.access.user_max_unique_ips.clone();
|
||||||
|
prev_global_each = cfg.access.user_max_unique_ips_global_each;
|
||||||
|
}
|
||||||
|
|
||||||
|
if prev_mode != cfg.access.user_max_unique_ips_mode
|
||||||
|
|| prev_window != cfg.access.user_max_unique_ips_window_secs
|
||||||
|
{
|
||||||
|
ip_tracker_policy
|
||||||
|
.set_limit_policy(
|
||||||
cfg.access.user_max_unique_ips_mode,
|
cfg.access.user_max_unique_ips_mode,
|
||||||
cfg.access.user_max_unique_ips_window_secs,
|
cfg.access.user_max_unique_ips_window_secs,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
previous = cfg.access.clone();
|
prev_mode = cfg.access.user_max_unique_ips_mode;
|
||||||
|
prev_window = cfg.access.user_max_unique_ips_window_secs;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let limiter = shared_state.traffic_limiter.clone();
|
let limiter = shared_state.traffic_limiter.clone();
|
||||||
|
limiter.apply_policy(
|
||||||
|
config.access.user_rate_limits.clone(),
|
||||||
|
config.access.cidr_rate_limits.clone(),
|
||||||
|
);
|
||||||
let mut config_rx_rate_limits = config_rx.clone();
|
let mut config_rx_rate_limits = config_rx.clone();
|
||||||
task_scope.spawn(async move {
|
task_scope.spawn(async move {
|
||||||
let mut prev_user_limits = config_rx_rate_limits
|
let mut prev_user_limits = config_rx_rate_limits
|
||||||
@@ -249,8 +270,7 @@ pub(crate) async fn spawn_runtime_tasks(
|
|||||||
if prev_user_limits != cfg.access.user_rate_limits
|
if prev_user_limits != cfg.access.user_rate_limits
|
||||||
|| prev_cidr_limits != cfg.access.cidr_rate_limits
|
|| prev_cidr_limits != cfg.access.cidr_rate_limits
|
||||||
{
|
{
|
||||||
let _ = limiter.apply_policy_from_source(
|
limiter.apply_policy(
|
||||||
generation_id,
|
|
||||||
cfg.access.user_rate_limits.clone(),
|
cfg.access.user_rate_limits.clone(),
|
||||||
cfg.access.cidr_rate_limits.clone(),
|
cfg.access.cidr_rate_limits.clone(),
|
||||||
);
|
);
|
||||||
@@ -268,14 +288,8 @@ pub(crate) async fn spawn_runtime_tasks(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let cfg = config_rx_user_enabled.borrow_and_update().clone();
|
let cfg = config_rx_user_enabled.borrow_and_update().clone();
|
||||||
let Some(cancelled_users) = shared_user_enabled.apply_user_config_from_source(
|
for user in shared_user_enabled.apply_user_enabled_config(&cfg.access.user_enabled) {
|
||||||
generation_id,
|
let cancelled = shared_user_enabled.cancel_user_sessions(&user);
|
||||||
&cfg.access.users,
|
|
||||||
&cfg.access.user_enabled,
|
|
||||||
) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
for (user, cancelled) in cancelled_users {
|
|
||||||
if cancelled > 0 {
|
if cancelled > 0 {
|
||||||
info!(
|
info!(
|
||||||
user = %user,
|
user = %user,
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ use super::control_plane::ProcessControlPlane;
|
|||||||
use super::generation::RuntimeGeneration;
|
use super::generation::RuntimeGeneration;
|
||||||
use super::helpers::{format_uptime, unit_label};
|
use super::helpers::{format_uptime, unit_label};
|
||||||
use super::reload_supervisor::ReloadSupervisorHandle;
|
use super::reload_supervisor::ReloadSupervisorHandle;
|
||||||
use crate::conntrack_control::FirewallAuthority;
|
|
||||||
use crate::quota_state::QuotaStateOwner;
|
use crate::quota_state::QuotaStateOwner;
|
||||||
use crate::stats::Stats;
|
use crate::stats::Stats;
|
||||||
use crate::synlimit_control;
|
use crate::synlimit_control;
|
||||||
@@ -55,7 +54,6 @@ pub(crate) async fn wait_for_shutdown(
|
|||||||
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
||||||
quota_state: Arc<QuotaStateOwner>,
|
quota_state: Arc<QuotaStateOwner>,
|
||||||
reload_supervisor: ReloadSupervisorHandle,
|
reload_supervisor: ReloadSupervisorHandle,
|
||||||
conntrack_firewall: Option<FirewallAuthority>,
|
|
||||||
process_control_plane: ProcessControlPlane,
|
process_control_plane: ProcessControlPlane,
|
||||||
) {
|
) {
|
||||||
let signal = wait_for_shutdown_signal().await;
|
let signal = wait_for_shutdown_signal().await;
|
||||||
@@ -65,7 +63,6 @@ pub(crate) async fn wait_for_shutdown(
|
|||||||
active_runtime,
|
active_runtime,
|
||||||
quota_state,
|
quota_state,
|
||||||
reload_supervisor,
|
reload_supervisor,
|
||||||
conntrack_firewall,
|
|
||||||
process_control_plane,
|
process_control_plane,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -98,7 +95,6 @@ async fn perform_shutdown(
|
|||||||
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
||||||
quota_state: Arc<QuotaStateOwner>,
|
quota_state: Arc<QuotaStateOwner>,
|
||||||
reload_supervisor: ReloadSupervisorHandle,
|
reload_supervisor: ReloadSupervisorHandle,
|
||||||
conntrack_firewall: Option<FirewallAuthority>,
|
|
||||||
process_control_plane: ProcessControlPlane,
|
process_control_plane: ProcessControlPlane,
|
||||||
) {
|
) {
|
||||||
let shutdown_started_at = Instant::now();
|
let shutdown_started_at = Instant::now();
|
||||||
@@ -130,12 +126,6 @@ async fn perform_shutdown(
|
|||||||
warn!("ME shutdown: pool lifecycle deadline expired");
|
warn!("ME shutdown: pool lifecycle deadline expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(conntrack_firewall) = conntrack_firewall
|
|
||||||
&& !conntrack_firewall.shutdown_and_clear().await
|
|
||||||
{
|
|
||||||
warn!("Conntrack firewall cleanup did not complete successfully");
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(error) = synlimit_control::clear_synlimit_rules_all_backends().await {
|
if let Err(error) = synlimit_control::clear_synlimit_rules_all_backends().await {
|
||||||
warn!(error = %error, "Failed to clear SYN limiter rules during shutdown");
|
warn!(error = %error, "Failed to clear SYN limiter rules during shutdown");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ mod protocol;
|
|||||||
mod proxy;
|
mod proxy;
|
||||||
mod quota_state;
|
mod quota_state;
|
||||||
mod service;
|
mod service;
|
||||||
mod slot_budget;
|
|
||||||
mod startup;
|
mod startup;
|
||||||
mod stats;
|
mod stats;
|
||||||
mod stream;
|
mod stream;
|
||||||
|
|||||||
@@ -236,10 +236,6 @@ async fn handle<B>(
|
|||||||
let config = runtime.config();
|
let config = runtime.config();
|
||||||
|
|
||||||
if req.uri().path() == "/metrics" {
|
if req.uri().path() == "/metrics" {
|
||||||
let me_hardswap = match runtime.current_me_pool().await {
|
|
||||||
Some(pool) => Some(pool.api_hardswap_snapshot().await),
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
let body = render_metrics(
|
let body = render_metrics(
|
||||||
stats,
|
stats,
|
||||||
shared_state,
|
shared_state,
|
||||||
@@ -248,7 +244,6 @@ async fn handle<B>(
|
|||||||
tls_cache,
|
tls_cache,
|
||||||
tls_full_cert_budget,
|
tls_full_cert_budget,
|
||||||
web_publication,
|
web_publication,
|
||||||
me_hardswap.as_ref(),
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let resp = Response::builder()
|
let resp = Response::builder()
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ mod me_lifecycle;
|
|||||||
mod me_buffers;
|
mod me_buffers;
|
||||||
// ME writer selection, KDF, and hardswap metrics.
|
// ME writer selection, KDF, and hardswap metrics.
|
||||||
mod me_policy;
|
mod me_policy;
|
||||||
// Live hardswap ownership and replacement progress metrics.
|
|
||||||
mod me_hardswap;
|
|
||||||
// Adaptive-floor and writer-cap metrics.
|
// Adaptive-floor and writer-cap metrics.
|
||||||
mod me_floor;
|
mod me_floor;
|
||||||
// Desync, pool recovery, and refill metrics.
|
// Desync, pool recovery, and refill metrics.
|
||||||
@@ -29,7 +27,6 @@ pub(super) async fn render_metrics(
|
|||||||
tls_cache: Option<&TlsFrontCache>,
|
tls_cache: Option<&TlsFrontCache>,
|
||||||
tls_full_cert_budget: &TlsFullCertBudget,
|
tls_full_cert_budget: &TlsFullCertBudget,
|
||||||
web_publication: &crate::web::control::WebRuntimePublication,
|
web_publication: &crate::web::control::WebRuntimePublication,
|
||||||
me_hardswap: Option<&crate::transport::middle_proxy::MeApiHardswapSnapshot>,
|
|
||||||
) -> String {
|
) -> String {
|
||||||
let mut out = String::with_capacity(4096);
|
let mut out = String::with_capacity(4096);
|
||||||
let telemetry = stats.telemetry_policy();
|
let telemetry = stats.telemetry_policy();
|
||||||
@@ -65,7 +62,6 @@ pub(super) async fn render_metrics(
|
|||||||
me_allows_debug,
|
me_allows_debug,
|
||||||
);
|
);
|
||||||
me_policy::render(&mut out, stats, me_allows_normal, me_allows_debug);
|
me_policy::render(&mut out, stats, me_allows_normal, me_allows_debug);
|
||||||
me_hardswap::render(&mut out, me_hardswap, me_allows_normal);
|
|
||||||
me_floor::render(&mut out, stats, config, me_allows_normal);
|
me_floor::render(&mut out, stats, config, me_allows_normal);
|
||||||
me_recovery::render(&mut out, stats, me_allows_normal, me_allows_debug);
|
me_recovery::render(&mut out, stats, me_allows_normal, me_allows_debug);
|
||||||
users::render(
|
users::render(
|
||||||
|
|||||||
@@ -266,53 +266,6 @@ pub(super) fn render(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"# HELP telemt_conntrack_rule_reconcile_total Conntrack firewall reconciliations by result"
|
|
||||||
);
|
|
||||||
let _ = writeln!(out, "# TYPE telemt_conntrack_rule_reconcile_total counter");
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"telemt_conntrack_rule_reconcile_total{{result=\"success\"}} {}",
|
|
||||||
if core_enabled {
|
|
||||||
stats.get_conntrack_rule_reconcile_success_total()
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
);
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"telemt_conntrack_rule_reconcile_total{{result=\"error\"}} {}",
|
|
||||||
if core_enabled {
|
|
||||||
stats.get_conntrack_rule_reconcile_error_total()
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
);
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"# HELP telemt_conntrack_rule_rollback_total Conntrack firewall rollbacks by result"
|
|
||||||
);
|
|
||||||
let _ = writeln!(out, "# TYPE telemt_conntrack_rule_rollback_total counter");
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"telemt_conntrack_rule_rollback_total{{result=\"success\"}} {}",
|
|
||||||
if core_enabled {
|
|
||||||
stats.get_conntrack_rule_rollback_success_total()
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
);
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"telemt_conntrack_rule_rollback_total{{result=\"error\"}} {}",
|
|
||||||
if core_enabled {
|
|
||||||
stats.get_conntrack_rule_rollback_error_total()
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
out,
|
out,
|
||||||
"# HELP telemt_conntrack_event_queue_depth Pending close events in conntrack control queue"
|
"# HELP telemt_conntrack_event_queue_depth Pending close events in conntrack control queue"
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
use std::fmt::Write;
|
|
||||||
|
|
||||||
use crate::transport::middle_proxy::MeApiHardswapSnapshot;
|
|
||||||
|
|
||||||
/// Renders fixed-cardinality hardswap and writer-replacement gauges.
|
|
||||||
pub(super) fn render(out: &mut String, snapshot: Option<&MeApiHardswapSnapshot>, enabled: bool) {
|
|
||||||
let snapshot = enabled.then_some(snapshot).flatten();
|
|
||||||
let pending = snapshot.is_some_and(|value| value.pending);
|
|
||||||
let pending_age_secs = snapshot
|
|
||||||
.and_then(|value| value.pending_age_secs)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let pending_writers_current = snapshot
|
|
||||||
.map(|value| value.pending_writers_current)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let pending_writer_deficit = snapshot
|
|
||||||
.map(|value| value.pending_writer_deficit)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let pending_missing_dc_groups = snapshot
|
|
||||||
.map(|value| value.pending_missing_dc_groups)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let pending_map_current = snapshot
|
|
||||||
.and_then(|value| value.pending_map_current)
|
|
||||||
.is_some_and(|value| value);
|
|
||||||
let orphan_warm_writers_current = snapshot
|
|
||||||
.map(|value| value.orphan_warm_writers_current)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let replacement_preparing_current = snapshot
|
|
||||||
.map(|value| value.replacement_preparing_current)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let replacement_retiring_current = snapshot
|
|
||||||
.map(|value| value.replacement_retiring_current)
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"# HELP telemt_me_hardswap_pending Whether an ME hardswap generation is pending"
|
|
||||||
);
|
|
||||||
let _ = writeln!(out, "# TYPE telemt_me_hardswap_pending gauge");
|
|
||||||
let _ = writeln!(out, "telemt_me_hardswap_pending {}", usize::from(pending));
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"# HELP telemt_me_hardswap_pending_age_seconds Age of the pending ME hardswap generation"
|
|
||||||
);
|
|
||||||
let _ = writeln!(out, "# TYPE telemt_me_hardswap_pending_age_seconds gauge");
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"telemt_me_hardswap_pending_age_seconds {pending_age_secs}"
|
|
||||||
);
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"# HELP telemt_me_hardswap_pending_writers_current Authoritative warm writers in the pending generation"
|
|
||||||
);
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"# TYPE telemt_me_hardswap_pending_writers_current gauge"
|
|
||||||
);
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"telemt_me_hardswap_pending_writers_current {pending_writers_current}"
|
|
||||||
);
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"# HELP telemt_me_hardswap_pending_writer_deficit Writers missing from the pending generation floor"
|
|
||||||
);
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"# TYPE telemt_me_hardswap_pending_writer_deficit gauge"
|
|
||||||
);
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"telemt_me_hardswap_pending_writer_deficit {pending_writer_deficit}"
|
|
||||||
);
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"# HELP telemt_me_hardswap_pending_missing_dc_groups Desired DC-family groups below the pending-generation floor"
|
|
||||||
);
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"# TYPE telemt_me_hardswap_pending_missing_dc_groups gauge"
|
|
||||||
);
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"telemt_me_hardswap_pending_missing_dc_groups {pending_missing_dc_groups}"
|
|
||||||
);
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"# HELP telemt_me_hardswap_pending_map_current Whether the pending generation targets the current endpoint map"
|
|
||||||
);
|
|
||||||
let _ = writeln!(out, "# TYPE telemt_me_hardswap_pending_map_current gauge");
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"telemt_me_hardswap_pending_map_current {}",
|
|
||||||
usize::from(pending_map_current)
|
|
||||||
);
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"# HELP telemt_me_hardswap_orphan_warm_writers_current Warm writers not owned by the pending hardswap generation"
|
|
||||||
);
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"# TYPE telemt_me_hardswap_orphan_warm_writers_current gauge"
|
|
||||||
);
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"telemt_me_hardswap_orphan_warm_writers_current {orphan_warm_writers_current}"
|
|
||||||
);
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"# HELP telemt_me_writer_replacement_current ME writer replacements by transaction phase"
|
|
||||||
);
|
|
||||||
let _ = writeln!(out, "# TYPE telemt_me_writer_replacement_current gauge");
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"telemt_me_writer_replacement_current{{state=\"preparing\"}} {replacement_preparing_current}"
|
|
||||||
);
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"telemt_me_writer_replacement_current{{state=\"retiring\"}} {replacement_retiring_current}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn renders_bounded_hardswap_and_replacement_state() {
|
|
||||||
let snapshot = MeApiHardswapSnapshot {
|
|
||||||
pending: true,
|
|
||||||
pending_age_secs: Some(42),
|
|
||||||
pending_writers_current: 3,
|
|
||||||
pending_writer_deficit: 4,
|
|
||||||
pending_missing_dc_groups: 2,
|
|
||||||
pending_map_current: Some(true),
|
|
||||||
orphan_warm_writers_current: 1,
|
|
||||||
replacement_preparing_current: 5,
|
|
||||||
replacement_retiring_current: 6,
|
|
||||||
};
|
|
||||||
let mut out = String::new();
|
|
||||||
|
|
||||||
render(&mut out, Some(&snapshot), true);
|
|
||||||
|
|
||||||
assert!(out.contains("telemt_me_hardswap_pending 1"));
|
|
||||||
assert!(out.contains("telemt_me_hardswap_pending_age_seconds 42"));
|
|
||||||
assert!(out.contains("telemt_me_hardswap_pending_writer_deficit 4"));
|
|
||||||
assert!(out.contains("telemt_me_writer_replacement_current{state=\"preparing\"} 5"));
|
|
||||||
assert!(out.contains("telemt_me_writer_replacement_current{state=\"retiring\"} 6"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -118,49 +118,6 @@ pub(super) fn render(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"# HELP telemt_rate_limiter_cas_retry_exhausted_total Traffic limiter operations that exhausted their bounded CAS attempt budget"
|
|
||||||
);
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"# TYPE telemt_rate_limiter_cas_retry_exhausted_total counter"
|
|
||||||
);
|
|
||||||
for (scope, direction, reserve, refund) in [
|
|
||||||
(
|
|
||||||
"user",
|
|
||||||
"up",
|
|
||||||
limiter_metrics.user_reserve_cas_retry_exhausted_up_total,
|
|
||||||
limiter_metrics.user_refund_cas_retry_exhausted_up_total,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"user",
|
|
||||||
"down",
|
|
||||||
limiter_metrics.user_reserve_cas_retry_exhausted_down_total,
|
|
||||||
limiter_metrics.user_refund_cas_retry_exhausted_down_total,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"cidr",
|
|
||||||
"up",
|
|
||||||
limiter_metrics.cidr_reserve_cas_retry_exhausted_up_total,
|
|
||||||
limiter_metrics.cidr_refund_cas_retry_exhausted_up_total,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"cidr",
|
|
||||||
"down",
|
|
||||||
limiter_metrics.cidr_reserve_cas_retry_exhausted_down_total,
|
|
||||||
limiter_metrics.cidr_refund_cas_retry_exhausted_down_total,
|
|
||||||
),
|
|
||||||
] {
|
|
||||||
for (operation, value) in [("reserve", reserve), ("refund", refund)] {
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"telemt_rate_limiter_cas_retry_exhausted_total{{scope=\"{scope}\",direction=\"{direction}\",operation=\"{operation}\"}} {}",
|
|
||||||
if core_enabled { value } else { 0 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
out,
|
out,
|
||||||
"# HELP telemt_rate_limiter_active_leases Active relay leases under rate limiting by scope"
|
"# HELP telemt_rate_limiter_active_leases Active relay leases under rate limiting by scope"
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ pub(super) async fn render(
|
|||||||
out,
|
out,
|
||||||
"telemt_user_connections_current{{user=\"{}\"}} {}",
|
"telemt_user_connections_current{{user=\"{}\"}} {}",
|
||||||
user,
|
user,
|
||||||
stats.get_process_user_curr_connects(user)
|
s.curr_connects.load(std::sync::atomic::Ordering::Relaxed)
|
||||||
);
|
);
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
out,
|
out,
|
||||||
|
|||||||
@@ -3,22 +3,10 @@ use http_body_util::BodyExt;
|
|||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
|
|
||||||
use crate::stats::telemetry::TelemetryPolicy;
|
|
||||||
use crate::tls_front::types::{
|
use crate::tls_front::types::{
|
||||||
CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsCertPayload, TlsProfileSource,
|
CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsCertPayload, TlsProfileSource,
|
||||||
};
|
};
|
||||||
|
|
||||||
const CAS_CONTENTION_SERIES: [(&str, &str, &str, u64); 8] = [
|
|
||||||
("user", "up", "reserve", 1),
|
|
||||||
("user", "down", "reserve", 2),
|
|
||||||
("user", "up", "refund", 3),
|
|
||||||
("user", "down", "refund", 4),
|
|
||||||
("cidr", "up", "reserve", 5),
|
|
||||||
("cidr", "down", "reserve", 6),
|
|
||||||
("cidr", "up", "refund", 7),
|
|
||||||
("cidr", "down", "refund", 8),
|
|
||||||
];
|
|
||||||
|
|
||||||
fn test_web_publication() -> crate::web::control::WebRuntimePublication {
|
fn test_web_publication() -> crate::web::control::WebRuntimePublication {
|
||||||
let control = crate::web::control::WebRuntimeControl::new();
|
let control = crate::web::control::WebRuntimeControl::new();
|
||||||
control.subscribe().borrow().clone()
|
control.subscribe().borrow().clone()
|
||||||
@@ -30,9 +18,6 @@ async fn test_render_metrics_format() {
|
|||||||
let shared_state = ProxySharedState::new();
|
let shared_state = ProxySharedState::new();
|
||||||
let tracker = UserIpTracker::new();
|
let tracker = UserIpTracker::new();
|
||||||
let mut config = ProxyConfig::default();
|
let mut config = ProxyConfig::default();
|
||||||
shared_state
|
|
||||||
.traffic_limiter
|
|
||||||
.set_cas_contention_metrics_for_test([1, 2, 3, 4, 5, 6, 7, 8]);
|
|
||||||
config
|
config
|
||||||
.access
|
.access
|
||||||
.user_max_unique_ips
|
.user_max_unique_ips
|
||||||
@@ -43,10 +28,6 @@ async fn test_render_metrics_format() {
|
|||||||
stats.increment_connects_bad_with_class("tls_handshake_bad_client");
|
stats.increment_connects_bad_with_class("tls_handshake_bad_client");
|
||||||
stats.increment_handshake_timeouts();
|
stats.increment_handshake_timeouts();
|
||||||
stats.increment_handshake_failure_class("timeout");
|
stats.increment_handshake_failure_class("timeout");
|
||||||
stats.increment_conntrack_rule_reconcile_success_total();
|
|
||||||
stats.increment_conntrack_rule_reconcile_error_total();
|
|
||||||
stats.increment_conntrack_rule_rollback_success_total();
|
|
||||||
stats.increment_conntrack_rule_rollback_error_total();
|
|
||||||
shared_state
|
shared_state
|
||||||
.handshake
|
.handshake
|
||||||
.auth_expensive_checks_total
|
.auth_expensive_checks_total
|
||||||
@@ -88,10 +69,6 @@ async fn test_render_metrics_format() {
|
|||||||
stats.increment_me_endpoint_quarantine_draining_suppressed_total();
|
stats.increment_me_endpoint_quarantine_draining_suppressed_total();
|
||||||
stats.increment_user_connects("alice");
|
stats.increment_user_connects("alice");
|
||||||
stats.increment_user_curr_connects("alice");
|
stats.increment_user_curr_connects("alice");
|
||||||
let _connection_permit = stats
|
|
||||||
.connection_authority()
|
|
||||||
.try_acquire("alice", None)
|
|
||||||
.unwrap();
|
|
||||||
stats.add_user_octets_from("alice", 1024);
|
stats.add_user_octets_from("alice", 1024);
|
||||||
stats.add_user_octets_to("alice", 2048);
|
stats.add_user_octets_to("alice", 2048);
|
||||||
stats.increment_user_msgs_from("alice");
|
stats.increment_user_msgs_from("alice");
|
||||||
@@ -110,7 +87,6 @@ async fn test_render_metrics_format() {
|
|||||||
None,
|
None,
|
||||||
&TlsFullCertBudget::new(),
|
&TlsFullCertBudget::new(),
|
||||||
&test_web_publication(),
|
&test_web_publication(),
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -127,10 +103,6 @@ async fn test_render_metrics_format() {
|
|||||||
);
|
);
|
||||||
assert!(output.contains("telemt_handshake_timeouts_total 1"));
|
assert!(output.contains("telemt_handshake_timeouts_total 1"));
|
||||||
assert!(output.contains("telemt_handshake_failures_by_class_total{class=\"timeout\"} 1"));
|
assert!(output.contains("telemt_handshake_failures_by_class_total{class=\"timeout\"} 1"));
|
||||||
assert!(output.contains("telemt_conntrack_rule_reconcile_total{result=\"success\"} 1"));
|
|
||||||
assert!(output.contains("telemt_conntrack_rule_reconcile_total{result=\"error\"} 1"));
|
|
||||||
assert!(output.contains("telemt_conntrack_rule_rollback_total{result=\"success\"} 1"));
|
|
||||||
assert!(output.contains("telemt_conntrack_rule_rollback_total{result=\"error\"} 1"));
|
|
||||||
assert!(output.contains("telemt_auth_expensive_checks_total 9"));
|
assert!(output.contains("telemt_auth_expensive_checks_total 9"));
|
||||||
assert!(output.contains("telemt_auth_budget_exhausted_total 2"));
|
assert!(output.contains("telemt_auth_budget_exhausted_total 2"));
|
||||||
assert!(output.contains("telemt_upstream_connect_attempt_total 2"));
|
assert!(output.contains("telemt_upstream_connect_attempt_total 2"));
|
||||||
@@ -179,17 +151,6 @@ async fn test_render_metrics_format() {
|
|||||||
assert!(output.contains("telemt_ip_tracker_users{scope=\"active\"} 1"));
|
assert!(output.contains("telemt_ip_tracker_users{scope=\"active\"} 1"));
|
||||||
assert!(output.contains("telemt_ip_tracker_entries{scope=\"active\"} 1"));
|
assert!(output.contains("telemt_ip_tracker_entries{scope=\"active\"} 1"));
|
||||||
assert!(output.contains("telemt_ip_tracker_cleanup_queue_len 0"));
|
assert!(output.contains("telemt_ip_tracker_cleanup_queue_len 0"));
|
||||||
for (scope, direction, operation, value) in CAS_CONTENTION_SERIES {
|
|
||||||
assert!(output.contains(&format!(
|
|
||||||
"telemt_rate_limiter_cas_retry_exhausted_total{{scope=\"{scope}\",direction=\"{direction}\",operation=\"{operation}\"}} {value}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
assert_eq!(
|
|
||||||
output
|
|
||||||
.matches("telemt_rate_limiter_cas_retry_exhausted_total{")
|
|
||||||
.count(),
|
|
||||||
8
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -262,7 +223,6 @@ async fn test_render_tls_front_profile_health() {
|
|||||||
Some(&cache),
|
Some(&cache),
|
||||||
&TlsFullCertBudget::new(),
|
&TlsFullCertBudget::new(),
|
||||||
&test_web_publication(),
|
&test_web_publication(),
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -333,7 +293,6 @@ async fn process_tls_budget_metrics_survive_a_generation_without_tls_cache() {
|
|||||||
None,
|
None,
|
||||||
budget.as_ref(),
|
budget.as_ref(),
|
||||||
&test_web_publication(),
|
&test_web_publication(),
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -344,13 +303,6 @@ async fn process_tls_budget_metrics_survive_a_generation_without_tls_cache() {
|
|||||||
async fn test_render_empty_stats() {
|
async fn test_render_empty_stats() {
|
||||||
let stats = Stats::new();
|
let stats = Stats::new();
|
||||||
let shared_state = ProxySharedState::new();
|
let shared_state = ProxySharedState::new();
|
||||||
stats.apply_telemetry_policy(TelemetryPolicy {
|
|
||||||
core_enabled: false,
|
|
||||||
..TelemetryPolicy::default()
|
|
||||||
});
|
|
||||||
shared_state
|
|
||||||
.traffic_limiter
|
|
||||||
.set_cas_contention_metrics_for_test([1, 2, 3, 4, 5, 6, 7, 8]);
|
|
||||||
let tracker = UserIpTracker::new();
|
let tracker = UserIpTracker::new();
|
||||||
let config = ProxyConfig::default();
|
let config = ProxyConfig::default();
|
||||||
let output = render_metrics(
|
let output = render_metrics(
|
||||||
@@ -361,7 +313,6 @@ async fn test_render_empty_stats() {
|
|||||||
None,
|
None,
|
||||||
&TlsFullCertBudget::new(),
|
&TlsFullCertBudget::new(),
|
||||||
&test_web_publication(),
|
&test_web_publication(),
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert!(output.contains("telemt_connections_total 0"));
|
assert!(output.contains("telemt_connections_total 0"));
|
||||||
@@ -371,11 +322,6 @@ async fn test_render_empty_stats() {
|
|||||||
assert!(output.contains("telemt_auth_budget_exhausted_total 0"));
|
assert!(output.contains("telemt_auth_budget_exhausted_total 0"));
|
||||||
assert!(output.contains("telemt_user_unique_ips_current{user="));
|
assert!(output.contains("telemt_user_unique_ips_current{user="));
|
||||||
assert!(output.contains("telemt_user_unique_ips_recent_window{user="));
|
assert!(output.contains("telemt_user_unique_ips_recent_window{user="));
|
||||||
for (scope, direction, operation, _) in CAS_CONTENTION_SERIES {
|
|
||||||
assert!(output.contains(&format!(
|
|
||||||
"telemt_rate_limiter_cas_retry_exhausted_total{{scope=\"{scope}\",direction=\"{direction}\",operation=\"{operation}\"}} 0"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -400,7 +346,6 @@ async fn test_render_uses_global_each_unique_ip_limit() {
|
|||||||
None,
|
None,
|
||||||
&TlsFullCertBudget::new(),
|
&TlsFullCertBudget::new(),
|
||||||
&test_web_publication(),
|
&test_web_publication(),
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -422,7 +367,6 @@ async fn test_render_has_type_annotations() {
|
|||||||
None,
|
None,
|
||||||
&TlsFullCertBudget::new(),
|
&TlsFullCertBudget::new(),
|
||||||
&test_web_publication(),
|
&test_web_publication(),
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert!(output.contains("# TYPE telemt_uptime_seconds gauge"));
|
assert!(output.contains("# TYPE telemt_uptime_seconds gauge"));
|
||||||
|
|||||||
+28
-166
@@ -14,9 +14,7 @@ use crate::proxy::handshake::HandshakeSuccess;
|
|||||||
use crate::proxy::middle_relay::{handle_via_middle_proxy, handle_via_middle_proxy_with_conntrack};
|
use crate::proxy::middle_relay::{handle_via_middle_proxy, handle_via_middle_proxy_with_conntrack};
|
||||||
use crate::proxy::route_mode::{RelayRouteMode, RouteRuntimeController};
|
use crate::proxy::route_mode::{RelayRouteMode, RouteRuntimeController};
|
||||||
use crate::proxy::shared_state::{ConntrackClosePolicy, ProxySharedState};
|
use crate::proxy::shared_state::{ConntrackClosePolicy, ProxySharedState};
|
||||||
use crate::proxy::user_admission::UserIncarnation;
|
use crate::stats::Stats;
|
||||||
use crate::proxy::user_connection_authority::UserConnectionPermit;
|
|
||||||
use crate::stats::{Stats, UserConnectionObservation, UserQuotaHandle};
|
|
||||||
use crate::stream::{BufferPool, CryptoReader, CryptoWriter};
|
use crate::stream::{BufferPool, CryptoReader, CryptoWriter};
|
||||||
use crate::transport::UpstreamManager;
|
use crate::transport::UpstreamManager;
|
||||||
use crate::transport::middle_proxy::MePool;
|
use crate::transport::middle_proxy::MePool;
|
||||||
@@ -61,21 +59,13 @@ where
|
|||||||
W: AsyncWrite + Unpin + Send + 'static,
|
W: AsyncWrite + Unpin + Send + 'static,
|
||||||
{
|
{
|
||||||
let user = success.user.clone();
|
let user = success.user.clone();
|
||||||
let Some(credential_id) = deps.config.runtime_user_credential_id(&user) else {
|
if !deps.shared.is_user_enabled(&user) {
|
||||||
warn!(user = %user, "Authenticated user is absent from the runtime credential snapshot");
|
|
||||||
return Err(ProxyError::UserDisabled { user });
|
|
||||||
};
|
|
||||||
let Some(user_incarnation) = deps
|
|
||||||
.shared
|
|
||||||
.authenticated_user_incarnation(&user, credential_id)
|
|
||||||
else {
|
|
||||||
warn!(user = %user, "Disabled user rejected");
|
warn!(user = %user, "Disabled user rejected");
|
||||||
return Err(ProxyError::UserDisabled { user });
|
return Err(ProxyError::UserDisabled { user });
|
||||||
};
|
}
|
||||||
|
|
||||||
let user_reservation = acquire_user_connection_reservation_for_incarnation(
|
let user_reservation = acquire_user_connection_reservation(
|
||||||
&user,
|
&user,
|
||||||
user_incarnation,
|
|
||||||
&deps.config,
|
&deps.config,
|
||||||
Arc::clone(&deps.stats),
|
Arc::clone(&deps.stats),
|
||||||
peer_addr,
|
peer_addr,
|
||||||
@@ -86,24 +76,10 @@ where
|
|||||||
warn!(user = %user, error = %error, "User admission check failed");
|
warn!(user = %user, error = %error, "User admission check failed");
|
||||||
error
|
error
|
||||||
})?;
|
})?;
|
||||||
let quota_handle = user_reservation.quota_handle();
|
|
||||||
|
|
||||||
let route_snapshot = deps.route_runtime.snapshot();
|
let route_snapshot = deps.route_runtime.snapshot();
|
||||||
let session_id = deps.rng.u64();
|
let session_id = deps.rng.u64();
|
||||||
let Some(user_session) = deps
|
let user_session = deps.shared.register_user_session(&user, session_id);
|
||||||
.shared
|
|
||||||
.register_authenticated_user_session(&user, credential_id)
|
|
||||||
else {
|
|
||||||
user_reservation.release_deferred();
|
|
||||||
warn!(user = %user, "Disabled user rejected during final admission");
|
|
||||||
return Err(ProxyError::UserDisabled { user });
|
|
||||||
};
|
|
||||||
if user_session.incarnation() != user_incarnation {
|
|
||||||
drop(user_session);
|
|
||||||
user_reservation.release_deferred();
|
|
||||||
warn!(user = %user, "User incarnation changed during admission");
|
|
||||||
return Err(ProxyError::UserDisabled { user });
|
|
||||||
}
|
|
||||||
let session_cancel = user_session.token();
|
let session_cancel = user_session.token();
|
||||||
let selected_me_pool = if deps.config.general.use_middle_proxy
|
let selected_me_pool = if deps.config.general.use_middle_proxy
|
||||||
&& matches!(route_snapshot.mode, RelayRouteMode::Middle)
|
&& matches!(route_snapshot.mode, RelayRouteMode::Middle)
|
||||||
@@ -139,7 +115,6 @@ where
|
|||||||
session_id,
|
session_id,
|
||||||
session_cancel.clone(),
|
session_cancel.clone(),
|
||||||
Arc::clone(&deps.shared),
|
Arc::clone(&deps.shared),
|
||||||
quota_handle.clone(),
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
} else {
|
} else {
|
||||||
@@ -159,7 +134,6 @@ where
|
|||||||
session_cancel.clone(),
|
session_cancel.clone(),
|
||||||
Arc::clone(&deps.shared),
|
Arc::clone(&deps.shared),
|
||||||
ConntrackClosePolicy::Suppress,
|
ConntrackClosePolicy::Suppress,
|
||||||
quota_handle.clone(),
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -175,7 +149,6 @@ where
|
|||||||
local_addr,
|
local_addr,
|
||||||
session_cancel.clone(),
|
session_cancel.clone(),
|
||||||
conntrack_close_policy,
|
conntrack_close_policy,
|
||||||
quota_handle.clone(),
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -190,7 +163,6 @@ where
|
|||||||
local_addr,
|
local_addr,
|
||||||
session_cancel,
|
session_cancel,
|
||||||
conntrack_close_policy,
|
conntrack_close_policy,
|
||||||
quota_handle,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
};
|
};
|
||||||
@@ -208,7 +180,6 @@ async fn run_direct<R, W>(
|
|||||||
local_addr: SocketAddr,
|
local_addr: SocketAddr,
|
||||||
session_cancel: tokio_util::sync::CancellationToken,
|
session_cancel: tokio_util::sync::CancellationToken,
|
||||||
conntrack_close_policy: ConntrackClosePolicy,
|
conntrack_close_policy: ConntrackClosePolicy,
|
||||||
quota_handle: UserQuotaHandle,
|
|
||||||
) -> Result<()>
|
) -> Result<()>
|
||||||
where
|
where
|
||||||
R: AsyncRead + Unpin + Send + 'static,
|
R: AsyncRead + Unpin + Send + 'static,
|
||||||
@@ -230,7 +201,6 @@ where
|
|||||||
session_cancel,
|
session_cancel,
|
||||||
Arc::clone(&deps.shared),
|
Arc::clone(&deps.shared),
|
||||||
conntrack_close_policy,
|
conntrack_close_policy,
|
||||||
quota_handle,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -239,60 +209,11 @@ where
|
|||||||
/// Owns one authenticated user's connection and source-IP admission slots.
|
/// Owns one authenticated user's connection and source-IP admission slots.
|
||||||
pub(crate) struct UserConnectionReservation {
|
pub(crate) struct UserConnectionReservation {
|
||||||
stats: Arc<Stats>,
|
stats: Arc<Stats>,
|
||||||
quota_handle: UserQuotaHandle,
|
ip_tracker: Arc<UserIpTracker>,
|
||||||
_connection_permit: UserConnectionPermit,
|
|
||||||
_stats_observation: Option<UserConnectionObservation>,
|
|
||||||
ip_permit: Option<UserIpPermit>,
|
|
||||||
released: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct UserIpPermit {
|
|
||||||
tracker: Arc<UserIpTracker>,
|
|
||||||
owner: Option<UserIpOwner>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct UserIpOwner {
|
|
||||||
user: String,
|
user: String,
|
||||||
incarnation: UserIncarnation,
|
|
||||||
ip: IpAddr,
|
ip: IpAddr,
|
||||||
}
|
tracks_ip: bool,
|
||||||
|
active: bool,
|
||||||
impl UserIpPermit {
|
|
||||||
fn new(
|
|
||||||
tracker: Arc<UserIpTracker>,
|
|
||||||
user: String,
|
|
||||||
incarnation: UserIncarnation,
|
|
||||||
ip: IpAddr,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
tracker,
|
|
||||||
owner: Some(UserIpOwner {
|
|
||||||
user,
|
|
||||||
incarnation,
|
|
||||||
ip,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn release(mut self) {
|
|
||||||
let Some(owner) = self.owner.as_ref() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
self.tracker
|
|
||||||
.remove_ip_for_incarnation(&owner.user, owner.incarnation, owner.ip)
|
|
||||||
.await;
|
|
||||||
self.owner = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for UserIpPermit {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let Some(owner) = self.owner.take() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
self.tracker
|
|
||||||
.enqueue_cleanup_for_incarnation(owner.user, owner.incarnation, owner.ip);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UserConnectionReservation {
|
impl UserConnectionReservation {
|
||||||
@@ -304,73 +225,40 @@ impl UserConnectionReservation {
|
|||||||
ip: IpAddr,
|
ip: IpAddr,
|
||||||
tracks_ip: bool,
|
tracks_ip: bool,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let quota_handle = stats.current_user_quota_handle(&user);
|
Self {
|
||||||
let connection_permit = stats
|
|
||||||
.connection_authority()
|
|
||||||
.try_acquire(&user, None)
|
|
||||||
.expect("unlimited test connection permit must be available");
|
|
||||||
let stats_observation = stats.observe_user_current_connection(&user);
|
|
||||||
Self::new_for_incarnation(
|
|
||||||
stats,
|
stats,
|
||||||
ip_tracker,
|
ip_tracker,
|
||||||
user,
|
user,
|
||||||
ip,
|
ip,
|
||||||
0,
|
|
||||||
quota_handle,
|
|
||||||
connection_permit,
|
|
||||||
stats_observation,
|
|
||||||
tracks_ip,
|
tracks_ip,
|
||||||
)
|
active: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a reservation fenced to one authenticated user incarnation.
|
|
||||||
pub(crate) fn new_for_incarnation(
|
|
||||||
stats: Arc<Stats>,
|
|
||||||
ip_tracker: Arc<UserIpTracker>,
|
|
||||||
user: String,
|
|
||||||
ip: IpAddr,
|
|
||||||
incarnation: UserIncarnation,
|
|
||||||
quota_handle: UserQuotaHandle,
|
|
||||||
connection_permit: UserConnectionPermit,
|
|
||||||
stats_observation: Option<UserConnectionObservation>,
|
|
||||||
tracks_ip: bool,
|
|
||||||
) -> Self {
|
|
||||||
let ip_permit = tracks_ip.then(|| UserIpPermit::new(ip_tracker, user, incarnation, ip));
|
|
||||||
Self {
|
|
||||||
stats,
|
|
||||||
quota_handle,
|
|
||||||
_connection_permit: connection_permit,
|
|
||||||
_stats_observation: stats_observation,
|
|
||||||
ip_permit,
|
|
||||||
released: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns quota ownership pinned to the authenticated user incarnation.
|
|
||||||
pub(crate) fn quota_handle(&self) -> UserQuotaHandle {
|
|
||||||
self.quota_handle.clone()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Releases both admission counters through the asynchronous cleanup path.
|
/// Releases both admission counters through the asynchronous cleanup path.
|
||||||
pub(crate) async fn release(mut self) {
|
pub(crate) async fn release(mut self) {
|
||||||
if let Some(ip_permit) = self.ip_permit.take() {
|
if !self.active {
|
||||||
ip_permit.release().await;
|
return;
|
||||||
}
|
}
|
||||||
self.released = true;
|
self.active = false;
|
||||||
|
if self.tracks_ip {
|
||||||
|
self.ip_tracker.remove_ip(&self.user, self.ip).await;
|
||||||
}
|
}
|
||||||
|
self.stats.decrement_user_curr_connects(&self.user);
|
||||||
/// Defers IP cleanup when admission fails after the asynchronous reservation step.
|
|
||||||
pub(crate) fn release_deferred(mut self) {
|
|
||||||
self.released = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for UserConnectionReservation {
|
impl Drop for UserConnectionReservation {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
if self.released {
|
if !self.active {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
self.active = false;
|
||||||
self.stats.increment_session_drop_fallback_total();
|
self.stats.increment_session_drop_fallback_total();
|
||||||
|
self.stats.decrement_user_curr_connects(&self.user);
|
||||||
|
if self.tracks_ip {
|
||||||
|
self.ip_tracker.enqueue_cleanup(self.user.clone(), self.ip);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,20 +269,6 @@ pub(crate) async fn acquire_user_connection_reservation(
|
|||||||
stats: Arc<Stats>,
|
stats: Arc<Stats>,
|
||||||
peer_addr: SocketAddr,
|
peer_addr: SocketAddr,
|
||||||
ip_tracker: Arc<UserIpTracker>,
|
ip_tracker: Arc<UserIpTracker>,
|
||||||
) -> Result<UserConnectionReservation> {
|
|
||||||
acquire_user_connection_reservation_for_incarnation(
|
|
||||||
user, 0, config, stats, peer_addr, ip_tracker,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn acquire_user_connection_reservation_for_incarnation(
|
|
||||||
user: &str,
|
|
||||||
incarnation: UserIncarnation,
|
|
||||||
config: &ProxyConfig,
|
|
||||||
stats: Arc<Stats>,
|
|
||||||
peer_addr: SocketAddr,
|
|
||||||
ip_tracker: Arc<UserIpTracker>,
|
|
||||||
) -> Result<UserConnectionReservation> {
|
) -> Result<UserConnectionReservation> {
|
||||||
if let Some(expiration) = config.access.user_expirations.get(user)
|
if let Some(expiration) = config.access.user_expirations.get(user)
|
||||||
&& chrono::Utc::now() > *expiration
|
&& chrono::Utc::now() > *expiration
|
||||||
@@ -403,13 +277,8 @@ async fn acquire_user_connection_reservation_for_incarnation(
|
|||||||
user: user.to_string(),
|
user: user.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let Some(quota_handle) = stats.quota_handle_for_incarnation(user, incarnation) else {
|
|
||||||
return Err(ProxyError::UserDisabled {
|
|
||||||
user: user.to_string(),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
if let Some(quota) = config.access.user_data_quota.get(user)
|
if let Some(quota) = config.access.user_data_quota.get(user)
|
||||||
&& quota_handle.used() >= *quota
|
&& stats.get_user_quota_used(user) >= *quota
|
||||||
{
|
{
|
||||||
return Err(ProxyError::DataQuotaExceeded {
|
return Err(ProxyError::DataQuotaExceeded {
|
||||||
user: user.to_string(),
|
user: user.to_string(),
|
||||||
@@ -425,17 +294,14 @@ async fn acquire_user_connection_reservation_for_incarnation(
|
|||||||
.or((config.access.user_max_tcp_conns_global_each > 0)
|
.or((config.access.user_max_tcp_conns_global_each > 0)
|
||||||
.then_some(config.access.user_max_tcp_conns_global_each))
|
.then_some(config.access.user_max_tcp_conns_global_each))
|
||||||
.map(|value| value as u64);
|
.map(|value| value as u64);
|
||||||
let Some(connection_permit) = stats.connection_authority().try_acquire(user, limit) else {
|
if !stats.try_acquire_user_curr_connects(user, limit) {
|
||||||
return Err(ProxyError::ConnectionLimitExceeded {
|
return Err(ProxyError::ConnectionLimitExceeded {
|
||||||
user: user.to_string(),
|
user: user.to_string(),
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
let stats_observation = stats.observe_user_current_connection(user);
|
|
||||||
|
|
||||||
if let Err(reason) = ip_tracker
|
if let Err(reason) = ip_tracker.check_and_add(user, peer_addr.ip()).await {
|
||||||
.check_and_add_for_incarnation(user, incarnation, peer_addr.ip())
|
stats.decrement_user_curr_connects(user);
|
||||||
.await
|
|
||||||
{
|
|
||||||
warn!(
|
warn!(
|
||||||
user = %user,
|
user = %user,
|
||||||
ip = %peer_addr.ip(),
|
ip = %peer_addr.ip(),
|
||||||
@@ -447,15 +313,11 @@ async fn acquire_user_connection_reservation_for_incarnation(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(UserConnectionReservation::new_for_incarnation(
|
Ok(UserConnectionReservation::new(
|
||||||
stats,
|
stats,
|
||||||
ip_tracker,
|
ip_tracker,
|
||||||
user.to_string(),
|
user.to_string(),
|
||||||
peer_addr.ip(),
|
peer_addr.ip(),
|
||||||
incarnation,
|
|
||||||
quota_handle,
|
|
||||||
connection_permit,
|
|
||||||
stats_observation,
|
|
||||||
true,
|
true,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user