fix(relay): bounded resources for traffic spikes
- Reject envelopes for addresses without a device (404) instead of queueing them for 30 days; random addresses were the cheapest way to fill the relay's disk. - Per-sender hourly quota on stored envelopes (count and bytes, 429) and a count cap on each recipient's queue next to the byte cap. - Per-account attachment quota: bytes per day and bytes stored within the TTL, kept in memory only (no account-to-blob link is stored). - Disk watermarks: below a configurable share of free space the relay refuses new attachments, then new offline envelopes (507), before Postgres runs out of space. - WebSocket buffers 4 KiB read / unbuffered write: ~16.6 KiB per connection instead of ~137 KiB (10 500 connections measured at ~180 MiB including the DB pool). - reqwest with HTTP/2: APNs only accepts HTTP/2, so no iOS wake-up could ever be delivered. APNs registration with only a VoIP token is stored instead of deleted. - All limits configurable via environment (limits.rs). Defaults keep previous behaviour except registration per IP (50 + 1/10 s, for carrier-grade NAT) guarded by a global cap (40/s), separate Tor buckets (SEPOT_TOR_PEERS) and a rate limit on opening WebSockets. SEPOT_REGISTRATION_PAUSED=1 pauses new registrations (503). - Envelopes whose encoding exceeds the queue column limit are 400 before they count against the sender quota. Rate limiting moves from tower_governor to governor directly, so Tor and clearnet can use different quotas on the same route.
This commit is contained in:
Generated
+15
-132
@@ -633,16 +633,6 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "forwarded-header-value"
|
|
||||||
version = "0.1.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9"
|
|
||||||
dependencies = [
|
|
||||||
"nonempty",
|
|
||||||
"thiserror 1.0.69",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -1059,19 +1049,6 @@ dependencies = [
|
|||||||
"webpki-roots 1.0.8",
|
"webpki-roots 1.0.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hyper-timeout"
|
|
||||||
version = "0.5.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0"
|
|
||||||
dependencies = [
|
|
||||||
"hyper",
|
|
||||||
"hyper-util",
|
|
||||||
"pin-project-lite",
|
|
||||||
"tokio",
|
|
||||||
"tower-service",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
@@ -1440,7 +1417,7 @@ dependencies = [
|
|||||||
"rand 0.9.4",
|
"rand 0.9.4",
|
||||||
"sha2",
|
"sha2",
|
||||||
"subtle",
|
"subtle",
|
||||||
"thiserror 2.0.18",
|
"thiserror",
|
||||||
"uuid",
|
"uuid",
|
||||||
"x25519-dalek",
|
"x25519-dalek",
|
||||||
"zerocopy",
|
"zerocopy",
|
||||||
@@ -1488,7 +1465,7 @@ dependencies = [
|
|||||||
"signal-crypto",
|
"signal-crypto",
|
||||||
"spqr",
|
"spqr",
|
||||||
"subtle",
|
"subtle",
|
||||||
"thiserror 2.0.18",
|
"thiserror",
|
||||||
"uuid",
|
"uuid",
|
||||||
"zerocopy",
|
"zerocopy",
|
||||||
]
|
]
|
||||||
@@ -1590,12 +1567,6 @@ version = "0.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
|
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "nonempty"
|
|
||||||
version = "0.7.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nonzero_ext"
|
name = "nonzero_ext"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -1788,26 +1759,6 @@ dependencies = [
|
|||||||
"indexmap",
|
"indexmap",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pin-project"
|
|
||||||
version = "1.1.13"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
|
|
||||||
dependencies = [
|
|
||||||
"pin-project-internal",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pin-project-internal"
|
|
||||||
version = "1.1.13"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -2071,7 +2022,7 @@ dependencies = [
|
|||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"rustls",
|
"rustls",
|
||||||
"socket2",
|
"socket2",
|
||||||
"thiserror 2.0.18",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"web-time",
|
"web-time",
|
||||||
@@ -2092,7 +2043,7 @@ dependencies = [
|
|||||||
"rustls",
|
"rustls",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"slab",
|
"slab",
|
||||||
"thiserror 2.0.18",
|
"thiserror",
|
||||||
"tinyvec",
|
"tinyvec",
|
||||||
"tracing",
|
"tracing",
|
||||||
"web-time",
|
"web-time",
|
||||||
@@ -2277,6 +2228,7 @@ dependencies = [
|
|||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
@@ -2445,6 +2397,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"governor",
|
||||||
"hex",
|
"hex",
|
||||||
"hmac",
|
"hmac",
|
||||||
"ipnet",
|
"ipnet",
|
||||||
@@ -2463,7 +2416,6 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower-http 0.7.0",
|
"tower-http 0.7.0",
|
||||||
"tower_governor",
|
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
@@ -2603,7 +2555,7 @@ dependencies = [
|
|||||||
"sha1",
|
"sha1",
|
||||||
"sha2",
|
"sha2",
|
||||||
"subtle",
|
"subtle",
|
||||||
"thiserror 2.0.18",
|
"thiserror",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2635,7 +2587,7 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"num-bigint",
|
"num-bigint",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"thiserror 2.0.18",
|
"thiserror",
|
||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2718,7 +2670,7 @@ dependencies = [
|
|||||||
"rand_core 0.9.5",
|
"rand_core 0.9.5",
|
||||||
"sha2",
|
"sha2",
|
||||||
"sorted-vec",
|
"sorted-vec",
|
||||||
"thiserror 2.0.18",
|
"thiserror",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2763,7 +2715,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"thiserror 2.0.18",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -2847,7 +2799,7 @@ dependencies = [
|
|||||||
"smallvec",
|
"smallvec",
|
||||||
"sqlx-core",
|
"sqlx-core",
|
||||||
"stringprep",
|
"stringprep",
|
||||||
"thiserror 2.0.18",
|
"thiserror",
|
||||||
"tracing",
|
"tracing",
|
||||||
"whoami",
|
"whoami",
|
||||||
]
|
]
|
||||||
@@ -2885,7 +2837,7 @@ dependencies = [
|
|||||||
"smallvec",
|
"smallvec",
|
||||||
"sqlx-core",
|
"sqlx-core",
|
||||||
"stringprep",
|
"stringprep",
|
||||||
"thiserror 2.0.18",
|
"thiserror",
|
||||||
"tracing",
|
"tracing",
|
||||||
"whoami",
|
"whoami",
|
||||||
]
|
]
|
||||||
@@ -2910,7 +2862,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sqlx-core",
|
"sqlx-core",
|
||||||
"thiserror 2.0.18",
|
"thiserror",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
@@ -2982,33 +2934,13 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "thiserror"
|
|
||||||
version = "1.0.69"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
|
||||||
dependencies = [
|
|
||||||
"thiserror-impl 1.0.69",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.18"
|
version = "2.0.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl 2.0.18",
|
"thiserror-impl",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "thiserror-impl"
|
|
||||||
version = "1.0.69"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3190,35 +3122,6 @@ dependencies = [
|
|||||||
"winnow",
|
"winnow",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tonic"
|
|
||||||
version = "0.14.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef"
|
|
||||||
dependencies = [
|
|
||||||
"async-trait",
|
|
||||||
"axum",
|
|
||||||
"base64",
|
|
||||||
"bytes",
|
|
||||||
"h2",
|
|
||||||
"http",
|
|
||||||
"http-body",
|
|
||||||
"http-body-util",
|
|
||||||
"hyper",
|
|
||||||
"hyper-timeout",
|
|
||||||
"hyper-util",
|
|
||||||
"percent-encoding",
|
|
||||||
"pin-project",
|
|
||||||
"socket2",
|
|
||||||
"sync_wrapper",
|
|
||||||
"tokio",
|
|
||||||
"tokio-stream",
|
|
||||||
"tower",
|
|
||||||
"tower-layer",
|
|
||||||
"tower-service",
|
|
||||||
"tracing",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -3227,12 +3130,9 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"indexmap",
|
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"slab",
|
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -3285,23 +3185,6 @@ version = "0.3.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tower_governor"
|
|
||||||
version = "0.8.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "44de9b94d849d3c46e06a883d72d408c2de6403367b39df2b1c9d9e7b6736fe6"
|
|
||||||
dependencies = [
|
|
||||||
"axum",
|
|
||||||
"forwarded-header-value",
|
|
||||||
"governor",
|
|
||||||
"http",
|
|
||||||
"pin-project",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"tonic",
|
|
||||||
"tower",
|
|
||||||
"tracing",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing"
|
name = "tracing"
|
||||||
version = "0.1.44"
|
version = "0.1.44"
|
||||||
@@ -3383,7 +3266,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"rand 0.9.4",
|
"rand 0.9.4",
|
||||||
"sha1",
|
"sha1",
|
||||||
"thiserror 2.0.18",
|
"thiserror",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -42,13 +42,15 @@ tokio-util = { version = "0.7", features = ["io"] }
|
|||||||
# při AUTH. Relay NIKDY nešifruje/nedešifruje zprávy (hloupý relay).
|
# při AUTH. Relay NIKDY nešifruje/nedešifruje zprávy (hloupý relay).
|
||||||
libsignal-protocol = { git = "https://github.com/signalapp/libsignal", tag = "v0.96.4" }
|
libsignal-protocol = { git = "https://github.com/signalapp/libsignal", tag = "v0.96.4" }
|
||||||
libsignal-core = { git = "https://github.com/signalapp/libsignal", tag = "v0.96.4" }
|
libsignal-core = { git = "https://github.com/signalapp/libsignal", tag = "v0.96.4" }
|
||||||
tower_governor = "0.8.0"
|
# Rate limity (token bucket per IP / Tor / globálně, limits.rs)
|
||||||
|
governor = "0.10"
|
||||||
tower-http = { version = "0.7.0", features = ["timeout"] }
|
tower-http = { version = "0.7.0", features = ["timeout"] }
|
||||||
|
|
||||||
# Push budíčky (UnifiedPush/WebPush = POST na endpoint). rustls, ať build
|
# Push budíčky (UnifiedPush/WebPush = POST na endpoint). rustls, ať build
|
||||||
# zůstane bez OpenSSL (stejně jako sqlx tls-rustls). Bez default featur —
|
# zůstane bez OpenSSL (stejně jako sqlx tls-rustls). Bez default featur —
|
||||||
# nechceme native-tls ani zbytečné kodeky.
|
# nechceme native-tls ani zbytečné kodeky. http2: APNs jiný protokol
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
# nepřijme (HTTP/1.1 spojení odmítne) — bez něj nedorazil žádný iOS budíček.
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "http2"] }
|
||||||
# FCM fast mode: RS256 JWT pro OAuth2 service account flow
|
# FCM fast mode: RS256 JWT pro OAuth2 service account flow
|
||||||
jsonwebtoken = "9"
|
jsonwebtoken = "9"
|
||||||
# setrlimit(RLIMIT_NOFILE) při startu — docker dává soft limit 1024
|
# setrlimit(RLIMIT_NOFILE) při startu — docker dává soft limit 1024
|
||||||
|
|||||||
@@ -201,4 +201,26 @@ mod tests {
|
|||||||
assert!(limit <= 30 * 86_400, "APNs nedrží notifikace déle než 30 dní");
|
assert!(limit <= 30 * 86_400, "APNs nedrží notifikace déle než 30 dní");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// APNs mluví jen HTTP/2. Bez featury `http2` v reqwestu relay spojení
|
||||||
|
/// vůbec nenaváže a iPhone žádný budíček nedostane. Bez platného JWT
|
||||||
|
/// vrátí Apple 403 (nebo 400) — to stačí: spojení i protokol sedí.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "vyžaduje síť (api.push.apple.com)"]
|
||||||
|
async fn apns_spojeni_jde_pres_http2() {
|
||||||
|
let client = crate::push::build_client();
|
||||||
|
let res = client
|
||||||
|
.post(format!("{PROD_HOST}/3/device/00"))
|
||||||
|
.header("apns-topic", "cz.sepot.sepot")
|
||||||
|
.body("{}")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("spojení s APNs");
|
||||||
|
assert_eq!(res.version(), reqwest::Version::HTTP_2);
|
||||||
|
assert!(
|
||||||
|
matches!(res.status().as_u16(), 400 | 403),
|
||||||
|
"čekána odpověď APNs bez platného tokenu, přišlo {}",
|
||||||
|
res.status()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,24 +111,86 @@ pub async fn upload(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
body: Body,
|
body: Body,
|
||||||
) -> ApiResult<(StatusCode, Json<UploadOut>)> {
|
) -> ApiResult<(StatusCode, Json<UploadOut>)> {
|
||||||
|
let authed =
|
||||||
crate::rest::authenticate_device(&state, &headers, "POST /v1/attachments").await?;
|
crate::rest::authenticate_device(&state, &headers, "POST /v1/attachments").await?;
|
||||||
|
|
||||||
let cfg = &state.attachments;
|
let cfg = &state.attachments;
|
||||||
|
|
||||||
// Rychlé odmítnutí podle Content-Length, než tečou data
|
// Rychlé odmítnutí podle Content-Length, než tečou data
|
||||||
if let Some(len) = headers
|
let declared = headers
|
||||||
.get(header::CONTENT_LENGTH)
|
.get(header::CONTENT_LENGTH)
|
||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.and_then(|v| v.parse::<u64>().ok())
|
.and_then(|v| v.parse::<u64>().ok());
|
||||||
{
|
if declared.is_some_and(|len| len > cfg.max_bytes) {
|
||||||
if len > cfg.max_bytes {
|
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::PAYLOAD_TOO_LARGE,
|
StatusCode::PAYLOAD_TOO_LARGE,
|
||||||
"příloha je příliš velká".into(),
|
"příloha je příliš velká".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Místo na disku dřív než cokoli dalšího: přílohy a DB sdílí filesystém
|
||||||
|
// a plný disk by zastavil i zprávy (disk.rs).
|
||||||
|
if state
|
||||||
|
.disk
|
||||||
|
.below(state.limits.disk_min_free_pct_attachments)
|
||||||
|
{
|
||||||
|
return Err((
|
||||||
|
StatusCode::INSUFFICIENT_STORAGE,
|
||||||
|
"úložiště příloh je plné, zkus to později".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kvóta účtu: rezervuje se ohlášená velikost (bez Content-Length strop
|
||||||
|
// jedné přílohy) a po zápisu se nespotřebovaný zbytek vrátí. Jen
|
||||||
|
// v paměti — vazba účet → blob se neukládá (viz hlavička modulu).
|
||||||
|
let reserved = declared.unwrap_or(cfg.max_bytes);
|
||||||
|
let account = authed.account_id;
|
||||||
|
if !state.attachment_budget.try_take(&account, reserved) {
|
||||||
|
return Err((
|
||||||
|
StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
"denní limit příloh vyčerpán, zkus to později".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !state.attachment_stored.try_reserve(&account, reserved) {
|
||||||
|
state.attachment_budget.refund(&account, reserved);
|
||||||
|
return Err((
|
||||||
|
StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
"příliš mnoho uložených příloh, počkej, až je příjemci stáhnou".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let refund = |unused: u64| {
|
||||||
|
state.attachment_budget.refund(&account, unused);
|
||||||
|
state.attachment_stored.release(&account, unused);
|
||||||
|
};
|
||||||
|
let result = store_upload(&state, body).await;
|
||||||
|
match &result {
|
||||||
|
Ok(stored) => refund(reserved.saturating_sub(stored.size_bytes)),
|
||||||
|
Err(_) => refund(reserved),
|
||||||
|
}
|
||||||
|
result.map(|stored| (StatusCode::CREATED, Json(stored.into())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Výsledek zápisu včetně skutečné velikosti (pro vyúčtování kvóty).
|
||||||
|
struct Stored {
|
||||||
|
blob_id: String,
|
||||||
|
expires_at_ms: i64,
|
||||||
|
max_bytes: u64,
|
||||||
|
size_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Stored> for UploadOut {
|
||||||
|
fn from(s: Stored) -> Self {
|
||||||
|
Self {
|
||||||
|
blob_id: s.blob_id,
|
||||||
|
expires_at_ms: s.expires_at_ms,
|
||||||
|
max_bytes: s.max_bytes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn store_upload(state: &AppState, body: Body) -> ApiResult<Stored> {
|
||||||
|
let cfg = &state.attachments;
|
||||||
|
|
||||||
// Globální strop: součet živých příloh + tahle nesmí přesáhnout
|
// Globální strop: součet živých příloh + tahle nesmí přesáhnout
|
||||||
// total_bytes. Jeden SELECT nad malou tabulkou; nic o zařízení se
|
// total_bytes. Jeden SELECT nad malou tabulkou; nic o zařízení se
|
||||||
// k blobu neváže.
|
// k blobu neváže.
|
||||||
@@ -192,14 +254,12 @@ pub async fn upload(
|
|||||||
// Log bez blob_id — jen velikost (kapacitní metrika)
|
// Log bez blob_id — jen velikost (kapacitní metrika)
|
||||||
tracing::debug!(size = written, "příloha uložena");
|
tracing::debug!(size = written, "příloha uložena");
|
||||||
|
|
||||||
Ok((
|
Ok(Stored {
|
||||||
StatusCode::CREATED,
|
|
||||||
Json(UploadOut {
|
|
||||||
blob_id: blob_hex,
|
blob_id: blob_hex,
|
||||||
expires_at_ms: expires_at.timestamp_millis(),
|
expires_at_ms: expires_at.timestamp_millis(),
|
||||||
max_bytes: cfg.max_bytes,
|
max_bytes: cfg.max_bytes,
|
||||||
}),
|
size_bytes: written,
|
||||||
))
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn write_stream(body: Body, path: &FsPath, max_bytes: u64) -> ApiResult<u64> {
|
async fn write_stream(body: Body, path: &FsPath, max_bytes: u64) -> ApiResult<u64> {
|
||||||
@@ -343,6 +403,7 @@ mod db_tests {
|
|||||||
total_bytes: 1 << 30,
|
total_bytes: 1 << 30,
|
||||||
},
|
},
|
||||||
trusted,
|
trusted,
|
||||||
|
crate::limits::Limits::from_env(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
//! Hlídání volného místa na datovém filesystému.
|
||||||
|
//!
|
||||||
|
//! Přílohy i DB leží v produkci na jednom filesystému (svazky Dockeru v CT).
|
||||||
|
//! Když dojde místo, Postgres přestane zapisovat a relay je mrtvý pro všechny
|
||||||
|
//! — i pro registrace a doručení online. Relay proto přestane přijímat to, co
|
||||||
|
//! disk plní (přílohy, pak offline fronta) o kus dřív, než místo dojde.
|
||||||
|
|
||||||
|
use std::ffi::CString;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Jak často se místo měří. Zápis 25 MiB přílohy trvá déle než tohle okno,
|
||||||
|
/// takže práh nepřeskočí ani souběžný nápor.
|
||||||
|
const MEASURE_EVERY: Duration = Duration::from_secs(5);
|
||||||
|
/// Pod 20 % volna se do logu zapíše varování (dřív, než zabere kterýkoli
|
||||||
|
/// práh odmítání), aby si toho Loki alert všiml s předstihem.
|
||||||
|
const WARN_BELOW_BP: u32 = 2000;
|
||||||
|
|
||||||
|
/// Volné místo v setinách procenta; `u32::MAX` = ještě neměřeno / nejde měřit
|
||||||
|
/// (pak se nic neblokuje — chyba měření nesmí shodit provoz).
|
||||||
|
pub struct DiskWatch {
|
||||||
|
free_bp: AtomicU32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiskWatch {
|
||||||
|
pub fn unknown() -> Self {
|
||||||
|
Self {
|
||||||
|
free_bp: AtomicU32::new(u32::MAX),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Volné místo v procentech, `None` = neznámé.
|
||||||
|
pub fn free_pct(&self) -> Option<f64> {
|
||||||
|
match self.free_bp.load(Ordering::Relaxed) {
|
||||||
|
u32::MAX => None,
|
||||||
|
bp => Some(f64::from(bp) / 100.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Je volného místa méně než `min_pct`?
|
||||||
|
pub fn below(&self, min_pct: f64) -> bool {
|
||||||
|
self.free_pct().is_some_and(|free| free < min_pct)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn set_free_pct(&self, pct: f64) {
|
||||||
|
self.free_bp.store((pct * 100.0) as u32, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn(self: &Arc<Self>, path: &Path) {
|
||||||
|
let watch = self.clone();
|
||||||
|
let path = path.to_path_buf();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut tick = tokio::time::interval(MEASURE_EVERY);
|
||||||
|
let mut was_low = false;
|
||||||
|
loop {
|
||||||
|
tick.tick().await;
|
||||||
|
let measured = {
|
||||||
|
let path = path.clone();
|
||||||
|
tokio::task::spawn_blocking(move || free_basis_points(&path))
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
};
|
||||||
|
let Some(bp) = measured else {
|
||||||
|
tracing::warn!("volné místo na disku nejde změřit");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
watch.free_bp.store(bp, Ordering::Relaxed);
|
||||||
|
// Změnu stavu zalogovat jednou, ne každých 5 s.
|
||||||
|
let low = bp < WARN_BELOW_BP;
|
||||||
|
if low != was_low {
|
||||||
|
tracing::warn!(volno_pct = f64::from(bp) / 100.0, "volné místo na disku");
|
||||||
|
was_low = low;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// statvfs: místo dostupné neprivilegovanému procesu (`f_bavail`) vůči
|
||||||
|
/// velikosti filesystému. Na ZFS s refquota je velikost = kvóta datasetu.
|
||||||
|
fn free_basis_points(path: &Path) -> Option<u32> {
|
||||||
|
let c_path = CString::new(path.as_os_str().as_encoded_bytes()).ok()?;
|
||||||
|
// SAFETY: statvfs jen zapisuje do předaného structu, cesta je platný C řetězec.
|
||||||
|
let mut st: libc::statvfs = unsafe { std::mem::zeroed() };
|
||||||
|
if unsafe { libc::statvfs(c_path.as_ptr(), &mut st) } != 0 || st.f_blocks == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(((st.f_bavail as u128 * 10_000) / st.f_blocks as u128) as u32)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mereni_vrati_rozumne_cislo() {
|
||||||
|
let bp = free_basis_points(Path::new("/")).expect("statvfs /");
|
||||||
|
assert!(bp <= 10_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nezname_misto_nic_neblokuje() {
|
||||||
|
let w = DiskWatch::unknown();
|
||||||
|
assert!(!w.below(99.0));
|
||||||
|
w.set_free_pct(5.0);
|
||||||
|
assert!(w.below(8.0));
|
||||||
|
assert!(!w.below(4.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
//! Limity relaye — všechny nastavitelné přes env.
|
||||||
|
//!
|
||||||
|
//! Proč env a ne konstanty: limit, který se pod skutečnou zátěží ukáže
|
||||||
|
//! špatně (sdílené CGNAT adresy, Tor, nápor po zveřejnění), se musí dát
|
||||||
|
//! změnit úpravou `.env` a restartem (sekundy), ne novým buildem.
|
||||||
|
//!
|
||||||
|
//! Výchozí hodnoty drží chování dřívějších verzí se třemi vědomými
|
||||||
|
//! výjimkami popsanými u polí: registrace per IP je výrazně volnější (mobilní
|
||||||
|
//! CGNAT dá za jednu IPv4 stovky lidí) a DB místo toho chrání globální strop;
|
||||||
|
//! Tor klienti mají vlastní, větší buckety (všichni přicházejí z jedné
|
||||||
|
//! adresy); a nové kvóty (odesílatel, přílohy na účet, místo na disku) dřív
|
||||||
|
//! neexistovaly.
|
||||||
|
//!
|
||||||
|
//! Rate limity jsou token buckety: `*_BURST` tokenů naráz, jeden token zpět
|
||||||
|
//! každých `*_PERIOD_MS` ms. Kvóty bajtů/kusů: 0 = bez omezení.
|
||||||
|
|
||||||
|
use std::net::{IpAddr, SocketAddr};
|
||||||
|
use std::num::NonZeroU32;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::extract::{ConnectInfo, Request, State};
|
||||||
|
use axum::http::{header, HeaderValue, StatusCode};
|
||||||
|
use axum::middleware::Next;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use governor::clock::{Clock, DefaultClock};
|
||||||
|
use governor::{DefaultDirectRateLimiter, DefaultKeyedRateLimiter, Quota};
|
||||||
|
|
||||||
|
use crate::net::TrustedProxies;
|
||||||
|
|
||||||
|
const MIB: u64 = 1024 * 1024;
|
||||||
|
|
||||||
|
fn env_parse<T: std::str::FromStr>(name: &str, default: T) -> T {
|
||||||
|
match std::env::var(name) {
|
||||||
|
Ok(raw) => match raw.trim().parse() {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => {
|
||||||
|
tracing::warn!(name, "neplatná hodnota, platí výchozí");
|
||||||
|
default
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(_) => default,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Token bucket: `burst` požadavků naráz, pak jeden za `period`.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub struct Rate {
|
||||||
|
pub burst: u32,
|
||||||
|
pub period: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Rate {
|
||||||
|
pub const fn new(burst: u32, period_ms: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
burst,
|
||||||
|
period: Duration::from_millis(period_ms),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `{prefix}_BURST` a `{prefix}_PERIOD_MS`, každá s vlastní výchozí hodnotou.
|
||||||
|
fn from_env(prefix: &str, default: Rate) -> Self {
|
||||||
|
let burst = env_parse(&format!("{prefix}_BURST"), default.burst).max(1);
|
||||||
|
let period_ms = env_parse(
|
||||||
|
&format!("{prefix}_PERIOD_MS"),
|
||||||
|
default.period.as_millis() as u64,
|
||||||
|
)
|
||||||
|
.max(1);
|
||||||
|
Self::new(burst, period_ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn quota(&self) -> Quota {
|
||||||
|
Quota::with_period(self.period)
|
||||||
|
.expect("period > 0")
|
||||||
|
.allow_burst(NonZeroU32::new(self.burst).expect("burst > 0"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Oddělené buckety: clearnet klienti (podle IP klienta) a všichni Tor
|
||||||
|
/// klienti dohromady (sdílí adresu kontejneru Toru).
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct RatePair {
|
||||||
|
pub clearnet: Rate,
|
||||||
|
pub tor: Rate,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Limits {
|
||||||
|
/// Spojení relaye do Postgresu (`SEPOT_DB_POOL`, výchozí 8).
|
||||||
|
pub db_pool: u32,
|
||||||
|
|
||||||
|
/// `SEPOT_RL_REGISTER_*` na IP: 50 naráz, pak 1 za 10 s (360/h).
|
||||||
|
/// Dřív 10 + 1/5 min: za CGNAT mobilního operátora čekal jedenáctý
|
||||||
|
/// člověk na účet minuty.
|
||||||
|
pub register: RatePair,
|
||||||
|
/// `SEPOT_RL_REGISTER_GLOBAL_*`: všechny registrace dohromady, 600 naráz,
|
||||||
|
/// pak 40/s. Chrání DB (změřeno 117–147 registrací/s na 2 jádrech), když
|
||||||
|
/// registruje mnoho adres naráz; BURST 0 = vypnuto.
|
||||||
|
pub register_global: Option<Rate>,
|
||||||
|
/// `SEPOT_REGISTRATION_PAUSED=1`: nové registrace dostanou 503 + Retry-After
|
||||||
|
/// (nouzový vypínač při záplavě registrací); existující zařízení jedou dál.
|
||||||
|
pub registration_paused: bool,
|
||||||
|
/// `SEPOT_RL_GENERAL_*`: server-info, bundly klíčů, certifikát, smazání.
|
||||||
|
pub general: RatePair,
|
||||||
|
/// `SEPOT_RL_ATTACHMENTS_*`: upload i stažení příloh.
|
||||||
|
pub attachments: RatePair,
|
||||||
|
/// `SEPOT_RL_WS_*`: otevírání WS spojení (každý AUTH = dotaz do DB).
|
||||||
|
pub ws_upgrade: RatePair,
|
||||||
|
|
||||||
|
/// `SEPOT_MAX_CONNS_PER_IP` (výchozí 32) a `SEPOT_TOR_MAX_CONNS`
|
||||||
|
/// (výchozí 5000, všichni Tor klienti dohromady).
|
||||||
|
pub max_conns_per_ip: u32,
|
||||||
|
pub max_conns_tor: u32,
|
||||||
|
/// `SEPOT_WS_FRAMES_PER_10S`: drahé rámce na spojení (výchozí 200).
|
||||||
|
pub ws_frames_per_window: u32,
|
||||||
|
|
||||||
|
/// `SEPOT_OTP_ISSUE_PER_HOUR`: vydaných one-time prekeys na adresu za hodinu.
|
||||||
|
pub otp_issue_per_hour: u32,
|
||||||
|
/// `SEPOT_MAX_STORED_OTP`: zásoba one-time prekeys na zařízení.
|
||||||
|
pub max_stored_otp: i64,
|
||||||
|
/// `SEPOT_CALL_WAKES_PER_10MIN` a `SEPOT_CALL_WAKE_MIN_INTERVAL_S`.
|
||||||
|
pub call_wakes_per_window: usize,
|
||||||
|
pub call_wake_min_interval: Duration,
|
||||||
|
|
||||||
|
/// `SEPOT_QUEUE_MAX_BYTES_PER_RECIPIENT` (50 MiB) a
|
||||||
|
/// `SEPOT_QUEUE_MAX_PER_RECIPIENT` (10 000 obálek): přebytek padá od nejstarších.
|
||||||
|
pub queue_max_bytes_per_recipient: i64,
|
||||||
|
pub queue_max_per_recipient: i64,
|
||||||
|
/// `SEPOT_SEND_MAX_PER_HOUR` / `SEPOT_SEND_MAX_BYTES_PER_HOUR` na odesílající
|
||||||
|
/// účet (ukládané obálky; „píše…“ se nepočítá).
|
||||||
|
pub send_per_hour: u64,
|
||||||
|
pub send_bytes_per_hour: u64,
|
||||||
|
|
||||||
|
/// `SEPOT_ATT_ACCOUNT_BYTES_PER_DAY` (100 MiB) a
|
||||||
|
/// `SEPOT_ATT_ACCOUNT_STORED_BYTES` (250 MiB živých v rámci TTL).
|
||||||
|
pub att_account_bytes_per_day: u64,
|
||||||
|
pub att_account_stored_bytes: u64,
|
||||||
|
|
||||||
|
/// `SEPOT_DISK_MIN_FREE_PCT_ATTACHMENTS` (15) a `SEPOT_DISK_MIN_FREE_PCT_QUEUE`
|
||||||
|
/// (8): pod tímto podílem volného místa na datovém filesystému relay
|
||||||
|
/// odmítá nové přílohy, resp. nové offline obálky (507) — Postgres si tak
|
||||||
|
/// nechá místo na WAL a na všechno, co už přijal.
|
||||||
|
pub disk_min_free_pct_attachments: f64,
|
||||||
|
pub disk_min_free_pct_queue: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Limits {
|
||||||
|
pub fn from_env() -> Self {
|
||||||
|
let pair = |prefix: &str, clearnet: Rate, tor: Rate| RatePair {
|
||||||
|
clearnet: Rate::from_env(prefix, clearnet),
|
||||||
|
tor: Rate::from_env(&format!("{prefix}_TOR"), tor),
|
||||||
|
};
|
||||||
|
let global = Rate::from_env("SEPOT_RL_REGISTER_GLOBAL", Rate::new(600, 25));
|
||||||
|
let global_off = env_parse("SEPOT_RL_REGISTER_GLOBAL_BURST", 600u32) == 0;
|
||||||
|
Self {
|
||||||
|
db_pool: env_parse("SEPOT_DB_POOL", 8u32).max(1),
|
||||||
|
register: pair(
|
||||||
|
"SEPOT_RL_REGISTER",
|
||||||
|
Rate::new(50, 10_000),
|
||||||
|
Rate::new(100, 3_000),
|
||||||
|
),
|
||||||
|
register_global: (!global_off).then_some(global),
|
||||||
|
registration_paused: matches!(
|
||||||
|
std::env::var("SEPOT_REGISTRATION_PAUSED").as_deref(),
|
||||||
|
Ok("1") | Ok("true")
|
||||||
|
),
|
||||||
|
general: pair("SEPOT_RL_GENERAL", Rate::new(60, 500), Rate::new(600, 20)),
|
||||||
|
attachments: pair(
|
||||||
|
"SEPOT_RL_ATTACHMENTS",
|
||||||
|
Rate::new(30, 1_000),
|
||||||
|
Rate::new(300, 100),
|
||||||
|
),
|
||||||
|
ws_upgrade: pair("SEPOT_RL_WS", Rate::new(120, 100), Rate::new(600, 20)),
|
||||||
|
max_conns_per_ip: env_parse("SEPOT_MAX_CONNS_PER_IP", 32u32),
|
||||||
|
max_conns_tor: env_parse("SEPOT_TOR_MAX_CONNS", 5000u32),
|
||||||
|
ws_frames_per_window: env_parse("SEPOT_WS_FRAMES_PER_10S", 200u32),
|
||||||
|
otp_issue_per_hour: env_parse("SEPOT_OTP_ISSUE_PER_HOUR", 30u32),
|
||||||
|
max_stored_otp: env_parse("SEPOT_MAX_STORED_OTP", 100i64),
|
||||||
|
call_wakes_per_window: env_parse("SEPOT_CALL_WAKES_PER_10MIN", 6usize),
|
||||||
|
call_wake_min_interval: Duration::from_secs(env_parse(
|
||||||
|
"SEPOT_CALL_WAKE_MIN_INTERVAL_S",
|
||||||
|
10u64,
|
||||||
|
)),
|
||||||
|
queue_max_bytes_per_recipient: env_parse(
|
||||||
|
"SEPOT_QUEUE_MAX_BYTES_PER_RECIPIENT",
|
||||||
|
(50 * MIB) as i64,
|
||||||
|
),
|
||||||
|
queue_max_per_recipient: env_parse("SEPOT_QUEUE_MAX_PER_RECIPIENT", 10_000i64),
|
||||||
|
send_per_hour: env_parse("SEPOT_SEND_MAX_PER_HOUR", 5_000u64),
|
||||||
|
send_bytes_per_hour: env_parse("SEPOT_SEND_MAX_BYTES_PER_HOUR", 32 * MIB),
|
||||||
|
att_account_bytes_per_day: env_parse("SEPOT_ATT_ACCOUNT_BYTES_PER_DAY", 100 * MIB),
|
||||||
|
att_account_stored_bytes: env_parse("SEPOT_ATT_ACCOUNT_STORED_BYTES", 250 * MIB),
|
||||||
|
disk_min_free_pct_attachments: env_parse("SEPOT_DISK_MIN_FREE_PCT_ATTACHMENTS", 15.0),
|
||||||
|
disk_min_free_pct_queue: env_parse("SEPOT_DISK_MIN_FREE_PCT_QUEUE", 8.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rate limiter skupiny rout, jen v paměti. Nic se nezapisuje ani neloguje
|
||||||
|
/// (adresy klientů jsou osobní údaj).
|
||||||
|
pub struct RateLimit {
|
||||||
|
clearnet: DefaultKeyedRateLimiter<IpAddr>,
|
||||||
|
tor: DefaultDirectRateLimiter,
|
||||||
|
global: Option<DefaultDirectRateLimiter>,
|
||||||
|
trusted: Arc<TrustedProxies>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RateLimit {
|
||||||
|
pub fn new(rates: RatePair, global: Option<Rate>, trusted: Arc<TrustedProxies>) -> Arc<Self> {
|
||||||
|
let limiter = Arc::new(Self {
|
||||||
|
clearnet: DefaultKeyedRateLimiter::keyed(rates.clearnet.quota()),
|
||||||
|
tor: DefaultDirectRateLimiter::direct(rates.tor.quota()),
|
||||||
|
global: global.map(|g| DefaultDirectRateLimiter::direct(g.quota())),
|
||||||
|
trusted,
|
||||||
|
});
|
||||||
|
// Zapomenout nečinné buckety, ať mapa neroste s každou kdy viděnou IP.
|
||||||
|
let weak = Arc::downgrade(&limiter);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut tick = tokio::time::interval(Duration::from_secs(60));
|
||||||
|
loop {
|
||||||
|
tick.tick().await;
|
||||||
|
let Some(limiter) = weak.upgrade() else { break };
|
||||||
|
limiter.clearnet.retain_recent();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
limiter
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Ok` = povoleno, `Err(wait)` = odmítnuto, zkusit znovu za `wait`.
|
||||||
|
pub fn check(&self, peer: IpAddr, headers: &axum::http::HeaderMap) -> Result<(), Duration> {
|
||||||
|
let now = DefaultClock::default().now();
|
||||||
|
let own = if self.trusted.is_tor(peer) {
|
||||||
|
self.tor.check()
|
||||||
|
} else {
|
||||||
|
self.clearnet
|
||||||
|
.check_key(&self.trusted.client_ip(peer, headers))
|
||||||
|
};
|
||||||
|
own.map_err(|not_until| not_until.wait_time_from(now))?;
|
||||||
|
if let Some(global) = &self.global {
|
||||||
|
global.check().map_err(|not_until| not_until.wait_time_from(now))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// axum middleware: 429 s Retry-After, když je bucket prázdný.
|
||||||
|
pub async fn enforce(
|
||||||
|
State(limit): State<Arc<RateLimit>>,
|
||||||
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||||
|
request: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
match limit.check(peer.ip(), request.headers()) {
|
||||||
|
Ok(()) => next.run(request).await,
|
||||||
|
Err(wait) => too_many(wait),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn too_many(wait: Duration) -> Response {
|
||||||
|
let secs = wait.as_secs() + u64::from(wait.subsec_nanos() > 0);
|
||||||
|
let mut response = (StatusCode::TOO_MANY_REQUESTS, "příliš mnoho požadavků").into_response();
|
||||||
|
if let Ok(value) = HeaderValue::from_str(&secs.max(1).to_string()) {
|
||||||
|
response.headers_mut().insert(header::RETRY_AFTER, value);
|
||||||
|
}
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use axum::http::HeaderMap;
|
||||||
|
|
||||||
|
fn trusted(proxies: &str, tor: &str) -> Arc<TrustedProxies> {
|
||||||
|
Arc::new(TrustedProxies::parse(proxies, tor))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn xff(ip: &str) -> HeaderMap {
|
||||||
|
let mut h = HeaderMap::new();
|
||||||
|
h.insert("x-forwarded-for", ip.parse().unwrap());
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn clearnet_bucket_je_na_ip_klienta() {
|
||||||
|
let rates = RatePair {
|
||||||
|
clearnet: Rate::new(2, 3_600_000),
|
||||||
|
tor: Rate::new(100, 1),
|
||||||
|
};
|
||||||
|
let rl = RateLimit::new(rates, None, trusted("10.0.0.1", ""));
|
||||||
|
let proxy: IpAddr = "10.0.0.1".parse().unwrap();
|
||||||
|
assert!(rl.check(proxy, &xff("198.51.100.1")).is_ok());
|
||||||
|
assert!(rl.check(proxy, &xff("198.51.100.1")).is_ok());
|
||||||
|
let wait = rl.check(proxy, &xff("198.51.100.1")).unwrap_err();
|
||||||
|
assert!(wait > Duration::from_secs(3000), "Retry-After odpovídá periodě");
|
||||||
|
assert!(rl.check(proxy, &xff("198.51.100.2")).is_ok(), "jiný klient limit nesdílí");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tor_ma_vlastni_bucket_a_nepodvrhne_ip() {
|
||||||
|
let rates = RatePair {
|
||||||
|
clearnet: Rate::new(1, 3_600_000),
|
||||||
|
tor: Rate::new(3, 3_600_000),
|
||||||
|
};
|
||||||
|
let rl = RateLimit::new(rates, None, trusted("10.0.0.1", "172.20.1.0/24"));
|
||||||
|
let tor_peer: IpAddr = "172.20.1.3".parse().unwrap();
|
||||||
|
for i in 0..3 {
|
||||||
|
// podvržená XFF z Toru nesmí založit nové buckety
|
||||||
|
assert!(rl.check(tor_peer, &xff(&format!("203.0.113.{i}"))).is_ok());
|
||||||
|
}
|
||||||
|
assert!(rl.check(tor_peer, &xff("203.0.113.99")).is_err());
|
||||||
|
// vyčerpaný Tor bucket clearnet klienta neomezí
|
||||||
|
let proxy: IpAddr = "10.0.0.1".parse().unwrap();
|
||||||
|
assert!(rl.check(proxy, &xff("198.51.100.1")).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn globalni_strop_plati_pres_klienty() {
|
||||||
|
let rates = RatePair {
|
||||||
|
clearnet: Rate::new(100, 1),
|
||||||
|
tor: Rate::new(100, 1),
|
||||||
|
};
|
||||||
|
let rl = RateLimit::new(rates, Some(Rate::new(2, 3_600_000)), trusted("10.0.0.1", ""));
|
||||||
|
let proxy: IpAddr = "10.0.0.1".parse().unwrap();
|
||||||
|
assert!(rl.check(proxy, &xff("198.51.100.1")).is_ok());
|
||||||
|
assert!(rl.check(proxy, &xff("198.51.100.2")).is_ok());
|
||||||
|
assert!(rl.check(proxy, &xff("198.51.100.3")).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_z_env_padne_na_vychozi_po_polich() {
|
||||||
|
std::env::set_var("SEPOT_TEST_RL_BURST", "7");
|
||||||
|
std::env::set_var("SEPOT_TEST_RL_PERIOD_MS", "nonsense");
|
||||||
|
let rate = Rate::from_env("SEPOT_TEST_RL", Rate::new(1, 250));
|
||||||
|
assert_eq!(rate, Rate::new(7, 250));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,9 +8,12 @@
|
|||||||
mod apns;
|
mod apns;
|
||||||
mod attachments;
|
mod attachments;
|
||||||
mod certs;
|
mod certs;
|
||||||
|
mod disk;
|
||||||
mod fcm;
|
mod fcm;
|
||||||
|
mod limits;
|
||||||
mod net;
|
mod net;
|
||||||
mod push;
|
mod push;
|
||||||
|
mod quota;
|
||||||
mod rest;
|
mod rest;
|
||||||
mod state;
|
mod state;
|
||||||
mod turn;
|
mod turn;
|
||||||
@@ -22,14 +25,14 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use axum::extract::DefaultBodyLimit;
|
use axum::extract::DefaultBodyLimit;
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
|
use axum::middleware::from_fn_with_state;
|
||||||
use axum::routing::{delete, get, post};
|
use axum::routing::{delete, get, post};
|
||||||
use axum::serve::ListenerExt;
|
use axum::serve::ListenerExt;
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
use tower_governor::governor::GovernorConfigBuilder;
|
|
||||||
use tower_governor::GovernorLayer;
|
|
||||||
|
|
||||||
use crate::net::{TrustedProxies, TrustedProxyIp};
|
use crate::limits::{Limits, RateLimit};
|
||||||
|
use crate::net::TrustedProxies;
|
||||||
use tower_http::timeout::TimeoutLayer;
|
use tower_http::timeout::TimeoutLayer;
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
@@ -49,8 +52,9 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.unwrap_or_else(|_| "postgres://sepot:sepot_dev@127.0.0.1/sepot_relay".into());
|
.unwrap_or_else(|_| "postgres://sepot:sepot_dev@127.0.0.1/sepot_relay".into());
|
||||||
let bind = std::env::var("SEPOT_BIND").unwrap_or_else(|_| "127.0.0.1:8470".into());
|
let bind = std::env::var("SEPOT_BIND").unwrap_or_else(|_| "127.0.0.1:8470".into());
|
||||||
|
|
||||||
|
let limits = Limits::from_env();
|
||||||
let pool = PgPoolOptions::new()
|
let pool = PgPoolOptions::new()
|
||||||
.max_connections(8)
|
.max_connections(limits.db_pool)
|
||||||
.connect(&database_url)
|
.connect(&database_url)
|
||||||
.await?;
|
.await?;
|
||||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||||
@@ -66,60 +70,23 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
certs,
|
certs,
|
||||||
attachments_cfg,
|
attachments_cfg,
|
||||||
trusted.clone(),
|
trusted.clone(),
|
||||||
|
limits.clone(),
|
||||||
));
|
));
|
||||||
attachments::spawn_cleanup(state.clone());
|
attachments::spawn_cleanup(state.clone());
|
||||||
|
state.disk.spawn(&state.attachments.dir);
|
||||||
|
tracing::info!(?limits, "limity");
|
||||||
|
|
||||||
// ── Rate limiting (minimální sada; plný PoW/Privacy Pass později) ──
|
// ── Rate limiting ──
|
||||||
//
|
//
|
||||||
// Klíč = klientská IP přes TrustedProxyIp: X-Forwarded-For/X-Real-Ip se
|
// Klíč = klientská IP: X-Forwarded-For se uzná JEN od trusted proxy
|
||||||
// uzná JEN od trusted proxy (traefik), jinak peer adresa. Onion klienti
|
// (traefik), jinak peer adresa. Onion klienti přicházejí z kontejneru Toru
|
||||||
// přicházejí z tor kontejneru (mimo trusted sadu) → sdílí jeden bucket
|
// (SEPOT_TOR_PEERS) a mají vlastní společný bucket — hlavičkou si IP
|
||||||
// a nemohou si IP nastavit hlavičkou (SECURITY audit #1).
|
// nastavit nemohou (SECURITY audit #1). Jen v paměti, nikdy na disk.
|
||||||
// In-memory token bucket — nikdy se nezapisuje na disk (privacy).
|
// Hodnoty a jejich zdůvodnění: limits.rs.
|
||||||
|
let rl_register = RateLimit::new(limits.register, limits.register_global, trusted.clone());
|
||||||
// registrace: zápis do DB bez auth → nejpřísnější (burst 10, +1/5 min)
|
let rl_general = RateLimit::new(limits.general, None, trusted.clone());
|
||||||
let gov_register = Arc::new(
|
let rl_attachments = RateLimit::new(limits.attachments, None, trusted.clone());
|
||||||
GovernorConfigBuilder::default()
|
let rl_ws = RateLimit::new(limits.ws_upgrade, None, trusted.clone());
|
||||||
.key_extractor(TrustedProxyIp(trusted.clone()))
|
|
||||||
.burst_size(10)
|
|
||||||
.period(Duration::from_secs(300))
|
|
||||||
.finish()
|
|
||||||
.expect("governor config"),
|
|
||||||
);
|
|
||||||
// čtecí API (bundle žere OTP klíče → drain ochrana): burst 60, +2/s
|
|
||||||
let gov_general = Arc::new(
|
|
||||||
GovernorConfigBuilder::default()
|
|
||||||
.key_extractor(TrustedProxyIp(trusted.clone()))
|
|
||||||
.burst_size(60)
|
|
||||||
.per_second(2)
|
|
||||||
.finish()
|
|
||||||
.expect("governor config"),
|
|
||||||
);
|
|
||||||
|
|
||||||
// přílohy: upload je autentizovaný, ale drahý (disk) → burst 30, +1/s
|
|
||||||
let gov_attachments = Arc::new(
|
|
||||||
GovernorConfigBuilder::default()
|
|
||||||
.key_extractor(TrustedProxyIp(trusted.clone()))
|
|
||||||
.burst_size(30)
|
|
||||||
.per_second(1)
|
|
||||||
.finish()
|
|
||||||
.expect("governor config"),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Governor drží mapu IP→bucket v paměti — periodicky vyhazovat staré
|
|
||||||
for limiter in [
|
|
||||||
gov_register.limiter().clone(),
|
|
||||||
gov_general.limiter().clone(),
|
|
||||||
gov_attachments.limiter().clone(),
|
|
||||||
] {
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut tick = tokio::time::interval(Duration::from_secs(60));
|
|
||||||
loop {
|
|
||||||
tick.tick().await;
|
|
||||||
limiter.retain_recent();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Úklid značek budíčků a OTP oken (jen paměť)
|
// Úklid značek budíčků a OTP oken (jen paměť)
|
||||||
{
|
{
|
||||||
@@ -135,7 +102,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
let register_routes = Router::new()
|
let register_routes = Router::new()
|
||||||
.route("/v1/register", post(rest::register))
|
.route("/v1/register", post(rest::register))
|
||||||
.layer(GovernorLayer::new(gov_register));
|
.layer(from_fn_with_state(rl_register, crate::limits::enforce));
|
||||||
|
|
||||||
let general_routes = Router::new()
|
let general_routes = Router::new()
|
||||||
.route("/v1/server-info", get(rest::server_info))
|
.route("/v1/server-info", get(rest::server_info))
|
||||||
@@ -144,7 +111,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.route("/v1/keys", post(rest::upload_keys))
|
.route("/v1/keys", post(rest::upload_keys))
|
||||||
.route("/v1/certificate/delivery", get(rest::certificate_delivery))
|
.route("/v1/certificate/delivery", get(rest::certificate_delivery))
|
||||||
.route("/v1/identity", delete(rest::delete_identity))
|
.route("/v1/identity", delete(rest::delete_identity))
|
||||||
.layer(GovernorLayer::new(gov_general));
|
.layer(from_fn_with_state(rl_general, crate::limits::enforce));
|
||||||
|
|
||||||
let rest_routes = register_routes
|
let rest_routes = register_routes
|
||||||
.merge(general_routes)
|
.merge(general_routes)
|
||||||
@@ -159,7 +126,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let attachment_routes = Router::new()
|
let attachment_routes = Router::new()
|
||||||
.route("/v1/attachments", post(attachments::upload))
|
.route("/v1/attachments", post(attachments::upload))
|
||||||
.route("/v1/attachments/{blob_id}", get(attachments::download))
|
.route("/v1/attachments/{blob_id}", get(attachments::download))
|
||||||
.layer(GovernorLayer::new(gov_attachments))
|
.layer(from_fn_with_state(rl_attachments, crate::limits::enforce))
|
||||||
.layer(TimeoutLayer::with_status_code(
|
.layer(TimeoutLayer::with_status_code(
|
||||||
StatusCode::REQUEST_TIMEOUT,
|
StatusCode::REQUEST_TIMEOUT,
|
||||||
Duration::from_secs(120),
|
Duration::from_secs(120),
|
||||||
@@ -169,8 +136,12 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
// health bez limitů — interní docker/uptime checky; ready ověří i DB
|
// health bez limitů — interní docker/uptime checky; ready ověří i DB
|
||||||
.route("/v1/health", get(rest::health))
|
.route("/v1/health", get(rest::health))
|
||||||
.route("/v1/ready", get(rest::ready))
|
.route("/v1/ready", get(rest::ready))
|
||||||
// WS má vlastní limity (conn cap per IP, auth deadline, frame rate)
|
// WS: tempo otevírání spojení + vlastní limity v ws.rs (strop
|
||||||
.route("/v1/ws", get(ws::ws_handler))
|
// souběžných spojení na IP, AUTH deadline, tempo rámců)
|
||||||
|
.route(
|
||||||
|
"/v1/ws",
|
||||||
|
get(ws::ws_handler).layer(from_fn_with_state(rl_ws, crate::limits::enforce)),
|
||||||
|
)
|
||||||
.merge(rest_routes)
|
.merge(rest_routes)
|
||||||
.merge(attachment_routes)
|
.merge(attachment_routes)
|
||||||
.layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
|
.layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
|
||||||
|
|||||||
+49
-45
@@ -14,29 +14,23 @@
|
|||||||
//! proxy. Onion peer (tor kontejner) tam není → použije se jeho IP a všichni
|
//! proxy. Onion peer (tor kontejner) tam není → použije se jeho IP a všichni
|
||||||
//! onion klienti sdílí jeden bucket (přesně jak počítal původní komentář).
|
//! onion klienti sdílí jeden bucket (přesně jak počítal původní komentář).
|
||||||
|
|
||||||
use std::net::{IpAddr, SocketAddr};
|
use std::net::IpAddr;
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use axum::extract::ConnectInfo;
|
use axum::http::HeaderMap;
|
||||||
use axum::http::{HeaderMap, Request};
|
|
||||||
use ipnet::IpNet;
|
use ipnet::IpNet;
|
||||||
use tower_governor::key_extractor::KeyExtractor;
|
|
||||||
use tower_governor::GovernorError;
|
|
||||||
|
|
||||||
/// Sada sítí, jejichž forwarded hlavičkám se věří (traefik/pangolin).
|
/// Sada sítí, jejichž forwarded hlavičkám se věří (traefik/pangolin).
|
||||||
/// Konfiguruje se přes `SEPOT_TRUSTED_PROXIES` (čárkou oddělené IP nebo CIDR).
|
/// Konfiguruje se přes `SEPOT_TRUSTED_PROXIES` (čárkou oddělené IP nebo CIDR).
|
||||||
pub struct TrustedProxies {
|
pub struct TrustedProxies {
|
||||||
nets: Vec<IpNet>,
|
nets: Vec<IpNet>,
|
||||||
|
/// Adresy, ze kterých přichází Tor (kontejner `tor` v síti tornet,
|
||||||
|
/// `SEPOT_TOR_PEERS`). Takový peer nikdy není trusted proxy a dostává
|
||||||
|
/// vlastní, společné limity — všichni onion klienti mají jeho IP.
|
||||||
|
tor: Vec<IpNet>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TrustedProxies {
|
fn parse_nets(raw: &str) -> Vec<IpNet> {
|
||||||
/// Načte z env `SEPOT_TRUSTED_PROXIES`. Prázdné/nenastavené = nevěří se
|
raw.split(',')
|
||||||
/// nikomu (vždy peer IP) — fail-safe: chybějící konfigurace nesmí otevřít
|
|
||||||
/// spoofing, maximálně sloučí clearnet klienty do jednoho bucketu.
|
|
||||||
pub fn from_env() -> Self {
|
|
||||||
let raw = std::env::var("SEPOT_TRUSTED_PROXIES").unwrap_or_default();
|
|
||||||
let nets: Vec<IpNet> = raw
|
|
||||||
.split(',')
|
|
||||||
.filter_map(|s| {
|
.filter_map(|s| {
|
||||||
let s = s.trim();
|
let s = s.trim();
|
||||||
if s.is_empty() {
|
if s.is_empty() {
|
||||||
@@ -47,18 +41,45 @@ impl TrustedProxies {
|
|||||||
.ok()
|
.ok()
|
||||||
.or_else(|| s.parse::<IpAddr>().ok().map(IpNet::from))
|
.or_else(|| s.parse::<IpAddr>().ok().map(IpNet::from))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect()
|
||||||
if nets.is_empty() {
|
}
|
||||||
|
|
||||||
|
impl TrustedProxies {
|
||||||
|
/// Načte z env `SEPOT_TRUSTED_PROXIES`. Prázdné/nenastavené = nevěří se
|
||||||
|
/// nikomu (vždy peer IP) — fail-safe: chybějící konfigurace nesmí otevřít
|
||||||
|
/// spoofing, maximálně sloučí clearnet klienty do jednoho bucketu.
|
||||||
|
pub fn from_env() -> Self {
|
||||||
|
let proxies = Self::parse(
|
||||||
|
&std::env::var("SEPOT_TRUSTED_PROXIES").unwrap_or_default(),
|
||||||
|
&std::env::var("SEPOT_TOR_PEERS").unwrap_or_default(),
|
||||||
|
);
|
||||||
|
if proxies.nets.is_empty() {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"SEPOT_TRUSTED_PROXIES prázdné — forwarded hlavičky se ignorují, \
|
"SEPOT_TRUSTED_PROXIES prázdné — forwarded hlavičky se ignorují, \
|
||||||
anti-abuse klíč = peer IP. Za reverse proxy nastav CIDR proxy sítě."
|
anti-abuse klíč = peer IP. Za reverse proxy nastav CIDR proxy sítě."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Self { nets }
|
proxies
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prázdné/nenastavené proxy = nevěří se nikomu (vždy peer IP) — fail-safe:
|
||||||
|
/// chybějící konfigurace nesmí otevřít spoofing, maximálně sloučí clearnet
|
||||||
|
/// klienty do jednoho bucketu. Tor má přednost: síť, která je zároveň
|
||||||
|
/// v obou sadách, se jako proxy nebere (onion klient by si jinak IP
|
||||||
|
/// nastavil hlavičkou).
|
||||||
|
pub fn parse(proxies: &str, tor: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
nets: parse_nets(proxies),
|
||||||
|
tor: parse_nets(tor),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_tor(&self, peer: IpAddr) -> bool {
|
||||||
|
self.tor.iter().any(|n| n.contains(&peer))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn trusts(&self, peer: IpAddr) -> bool {
|
fn trusts(&self, peer: IpAddr) -> bool {
|
||||||
self.nets.iter().any(|n| n.contains(&peer))
|
!self.is_tor(peer) && self.nets.iter().any(|n| n.contains(&peer))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Klientská IP pro anti-abuse. Forwarded hlavičku uznej jen od trusted
|
/// Klientská IP pro anti-abuse. Forwarded hlavičku uznej jen od trusted
|
||||||
@@ -83,24 +104,6 @@ impl TrustedProxies {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Governor key extractor s ověřením trusted proxy. Nahrazuje
|
|
||||||
/// `SmartIpKeyExtractor`, který forwarded hlavičkám věří bezpodmínečně.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct TrustedProxyIp(pub Arc<TrustedProxies>);
|
|
||||||
|
|
||||||
impl KeyExtractor for TrustedProxyIp {
|
|
||||||
type Key = IpAddr;
|
|
||||||
|
|
||||||
fn extract<T>(&self, req: &Request<T>) -> Result<Self::Key, GovernorError> {
|
|
||||||
let peer = req
|
|
||||||
.extensions()
|
|
||||||
.get::<ConnectInfo<SocketAddr>>()
|
|
||||||
.map(|ci| ci.0.ip())
|
|
||||||
.ok_or(GovernorError::UnableToExtractKey)?;
|
|
||||||
Ok(self.0.client_ip(peer, req.headers()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -108,15 +111,7 @@ mod tests {
|
|||||||
use axum::http::HeaderMap;
|
use axum::http::HeaderMap;
|
||||||
|
|
||||||
fn proxies(cfg: &[&str]) -> TrustedProxies {
|
fn proxies(cfg: &[&str]) -> TrustedProxies {
|
||||||
let nets = cfg
|
TrustedProxies::parse(&cfg.join(","), "")
|
||||||
.iter()
|
|
||||||
.filter_map(|s| {
|
|
||||||
s.parse::<IpNet>()
|
|
||||||
.ok()
|
|
||||||
.or_else(|| s.parse::<IpAddr>().ok().map(IpNet::from))
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
TrustedProxies { nets }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ip(s: &str) -> IpAddr {
|
fn ip(s: &str) -> IpAddr {
|
||||||
@@ -191,4 +186,13 @@ mod tests {
|
|||||||
let h = headers(&[("x-forwarded-for", "2001:db8::1")]);
|
let h = headers(&[("x-forwarded-for", "2001:db8::1")]);
|
||||||
assert_eq!(tp.client_ip(peer, &h), ip("2001:db8::1"));
|
assert_eq!(tp.client_ip(peer, &h), ip("2001:db8::1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Síť Toru v sadě proxy omylem: onion peer si IP hlavičkou nastavit nesmí.
|
||||||
|
#[test]
|
||||||
|
fn tor_peer_neni_proxy_ani_pri_prekryvu() {
|
||||||
|
let tp = TrustedProxies::parse("172.20.0.0/16", "172.20.1.0/24");
|
||||||
|
let tor = ip("172.20.1.3");
|
||||||
|
assert!(tp.is_tor(tor));
|
||||||
|
assert_eq!(tp.client_ip(tor, &headers(&[("x-forwarded-for", "1.2.3.4")])), tor);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
//! Kvóty na účet — jen v paměti, nikam se neukládají ani nelogují.
|
||||||
|
//!
|
||||||
|
//! Proč v paměti: relay záměrně nikde nedrží vazbu „účet → co poslal / jakou
|
||||||
|
//! přílohu nahrál“ (metadata). Cena: restart relaye kvóty vynuluje. Proti
|
||||||
|
//! zaplnění disku proto stojí ještě globální strop příloh a hlídání volného
|
||||||
|
//! místa (`disk.rs`), které restart nevynuluje.
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use dashmap::DashMap;
|
||||||
|
|
||||||
|
/// Token bucket počtu i bajtů na klíč: plný na `count`/`bytes`, za `period`
|
||||||
|
/// se doplní celý. 0 = daná dimenze bez omezení.
|
||||||
|
pub struct Budgets {
|
||||||
|
count: u64,
|
||||||
|
bytes: u64,
|
||||||
|
period: Duration,
|
||||||
|
map: DashMap<Vec<u8>, Bucket>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Bucket {
|
||||||
|
count: f64,
|
||||||
|
bytes: f64,
|
||||||
|
last: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Budgets {
|
||||||
|
pub fn new(count: u64, bytes: u64, period: Duration) -> Self {
|
||||||
|
Self {
|
||||||
|
count,
|
||||||
|
bytes,
|
||||||
|
period,
|
||||||
|
map: DashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refill(&self, b: &mut Bucket, now: Instant) {
|
||||||
|
let share = now.duration_since(b.last).as_secs_f64() / self.period.as_secs_f64();
|
||||||
|
b.count = (b.count + share * self.count as f64).min(self.count as f64);
|
||||||
|
b.bytes = (b.bytes + share * self.bytes as f64).min(self.bytes as f64);
|
||||||
|
b.last = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Odečte jednu položku o `bytes` bajtech; `false` = kvóta vyčerpaná
|
||||||
|
/// (nic se neodečte).
|
||||||
|
pub fn try_take(&self, key: &[u8], bytes: u64) -> bool {
|
||||||
|
self.try_take_at(key, bytes, Instant::now())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_take_at(&self, key: &[u8], bytes: u64, now: Instant) -> bool {
|
||||||
|
let mut b = self.map.entry(key.to_vec()).or_insert_with(|| Bucket {
|
||||||
|
count: self.count as f64,
|
||||||
|
bytes: self.bytes as f64,
|
||||||
|
last: now,
|
||||||
|
});
|
||||||
|
self.refill(&mut b, now);
|
||||||
|
let count_ok = self.count == 0 || b.count >= 1.0;
|
||||||
|
let bytes_ok = self.bytes == 0 || b.bytes >= bytes as f64;
|
||||||
|
if !(count_ok && bytes_ok) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
b.count -= 1.0;
|
||||||
|
b.bytes -= bytes as f64;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vrátí bajty, které se nakonec nespotřebovaly (upload menší než
|
||||||
|
/// rezervace, chyba zápisu).
|
||||||
|
pub fn refund(&self, key: &[u8], bytes: u64) {
|
||||||
|
if let Some(mut b) = self.map.get_mut(key) {
|
||||||
|
b.bytes = (b.bytes + bytes as f64).min(self.bytes as f64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Úklid plných bucketů (účet dlouho nic neposlal) — paměť jen pro aktivní.
|
||||||
|
pub fn prune(&self) {
|
||||||
|
let now = Instant::now();
|
||||||
|
self.map.retain(|_, b| {
|
||||||
|
let share = now.duration_since(b.last).as_secs_f64() / self.period.as_secs_f64();
|
||||||
|
share < 1.0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kolik bajtů má účet právě uložených (příloh v rámci TTL). Účetnictví po
|
||||||
|
/// dnech: na účet nejvýš `ttl_days` čísel, ne seznam blobů — paměť je pevná
|
||||||
|
/// a vazba na jednotlivé bloby nevzniká.
|
||||||
|
pub struct StoredLedger {
|
||||||
|
cap: u64,
|
||||||
|
ttl_days: i64,
|
||||||
|
map: DashMap<Vec<u8>, VecDeque<(i64, u64)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StoredLedger {
|
||||||
|
pub fn new(cap: u64, ttl_days: i64) -> Self {
|
||||||
|
Self {
|
||||||
|
cap,
|
||||||
|
ttl_days,
|
||||||
|
map: DashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn today() -> i64 {
|
||||||
|
chrono::Utc::now().timestamp().div_euclid(86_400)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zarezervuje `bytes`; `false` = účet by přesáhl strop uloženého.
|
||||||
|
pub fn try_reserve(&self, key: &[u8], bytes: u64) -> bool {
|
||||||
|
self.try_reserve_on(key, bytes, Self::today())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_reserve_on(&self, key: &[u8], bytes: u64, day: i64) -> bool {
|
||||||
|
if self.cap == 0 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let mut days = self.map.entry(key.to_vec()).or_default();
|
||||||
|
// Den nahrání + TTL dní: pak je blob úklidem pryč (zaokrouhleno nahoru).
|
||||||
|
while days.front().is_some_and(|(d, _)| *d + self.ttl_days < day) {
|
||||||
|
days.pop_front();
|
||||||
|
}
|
||||||
|
let used: u64 = days.iter().map(|(_, b)| b).sum();
|
||||||
|
if used.saturating_add(bytes) > self.cap {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match days.back_mut() {
|
||||||
|
Some((d, b)) if *d == day => *b += bytes,
|
||||||
|
_ => days.push_back((day, bytes)),
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn release(&self, key: &[u8], bytes: u64) {
|
||||||
|
if let Some(mut days) = self.map.get_mut(key) {
|
||||||
|
if let Some((_, b)) = days.back_mut() {
|
||||||
|
*b = b.saturating_sub(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prune(&self) {
|
||||||
|
let horizon = Self::today() - self.ttl_days;
|
||||||
|
self.map
|
||||||
|
.retain(|_, days| days.back().is_some_and(|(d, _)| *d >= horizon));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn budget_odmitne_nad_bajty_i_kusy_a_doplni_se() {
|
||||||
|
let q = Budgets::new(3, 1000, Duration::from_secs(3600));
|
||||||
|
let t0 = Instant::now();
|
||||||
|
assert!(q.try_take_at(b"a", 600, t0));
|
||||||
|
assert!(!q.try_take_at(b"a", 600, t0), "bajty došly");
|
||||||
|
assert!(q.try_take_at(b"a", 100, t0));
|
||||||
|
assert!(q.try_take_at(b"a", 100, t0));
|
||||||
|
assert!(!q.try_take_at(b"a", 1, t0), "kusy došly");
|
||||||
|
assert!(q.try_take_at(b"b", 600, t0), "jiný účet má vlastní kvótu");
|
||||||
|
// za půl periody se doplní polovina
|
||||||
|
let t1 = t0 + Duration::from_secs(1800);
|
||||||
|
assert!(q.try_take_at(b"a", 400, t1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn budget_nula_znamena_bez_omezeni() {
|
||||||
|
let q = Budgets::new(0, 0, Duration::from_secs(1));
|
||||||
|
for _ in 0..1000 {
|
||||||
|
assert!(q.try_take(b"a", u64::MAX / 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refund_vrati_nespotrebovane() {
|
||||||
|
let q = Budgets::new(0, 1000, Duration::from_secs(3600));
|
||||||
|
assert!(q.try_take(b"a", 1000));
|
||||||
|
q.refund(b"a", 700);
|
||||||
|
assert!(q.try_take(b"a", 700));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ledger_drzi_strop_a_stare_dny_uvolni() {
|
||||||
|
let l = StoredLedger::new(1000, 30);
|
||||||
|
assert!(l.try_reserve_on(b"a", 800, 100));
|
||||||
|
assert!(!l.try_reserve_on(b"a", 300, 100));
|
||||||
|
assert!(l.try_reserve_on(b"b", 300, 100), "jiný účet");
|
||||||
|
l.release(b"a", 500);
|
||||||
|
assert!(l.try_reserve_on(b"a", 600, 101));
|
||||||
|
// po TTL dnech od nahrání je místo zase volné
|
||||||
|
assert!(l.try_reserve_on(b"a", 1000, 132));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -132,6 +132,12 @@ pub async fn register(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(body): Json<RegisterIn>,
|
Json(body): Json<RegisterIn>,
|
||||||
) -> ApiResult<(StatusCode, Json<RegisterOut>)> {
|
) -> ApiResult<(StatusCode, Json<RegisterOut>)> {
|
||||||
|
if state.limits.registration_paused {
|
||||||
|
return Err((
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"registrace jsou dočasně pozastavené, zkus to za pár minut".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
if body.one_time_prekeys.len() > MAX_ONE_TIME_PREKEYS {
|
if body.one_time_prekeys.len() > MAX_ONE_TIME_PREKEYS {
|
||||||
return Err(bad("příliš mnoho one-time prekeys"));
|
return Err(bad("příliš mnoho one-time prekeys"));
|
||||||
}
|
}
|
||||||
@@ -489,9 +495,6 @@ pub struct UploadKeysOut {
|
|||||||
pub one_time_prekeys: i64,
|
pub one_time_prekeys: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Strop zásoby OTP na zařízení — nad něj se další zahodí (klient posílá
|
|
||||||
/// dávky po 50, relay zůstane u sta jako při registraci).
|
|
||||||
const MAX_STORED_ONE_TIME_PREKEYS: i64 = 100;
|
|
||||||
|
|
||||||
/// Po kolika dnech se starý signed prekey smaže (po rotaci ho relay ještě
|
/// Po kolika dnech se starý signed prekey smaže (po rotaci ho relay ještě
|
||||||
/// drží, protože bundle s ním mohl někdo stáhnout těsně před výměnou).
|
/// drží, protože bundle s ním mohl někdo stáhnout těsně před výměnou).
|
||||||
@@ -595,7 +598,9 @@ pub async fn upload_keys(
|
|||||||
.await
|
.await
|
||||||
.map_err(internal)?;
|
.map_err(internal)?;
|
||||||
for otp in &body.one_time_prekeys {
|
for otp in &body.one_time_prekeys {
|
||||||
if stored >= MAX_STORED_ONE_TIME_PREKEYS {
|
// Strop zásoby OTP (`Limits::max_stored_otp`, výchozí 100) — nad něj se
|
||||||
|
// další zahodí (klient posílá dávky po 50, jako při registraci sto).
|
||||||
|
if stored >= state.limits.max_stored_otp {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let public = b64("one_time_prekeys.public_key", &otp.public_key, Some(33))?;
|
let public = b64("one_time_prekeys.public_key", &otp.public_key, Some(33))?;
|
||||||
|
|||||||
+290
-50
@@ -15,11 +15,8 @@ use crate::net::TrustedProxies;
|
|||||||
/// TTL offline fronty.
|
/// TTL offline fronty.
|
||||||
pub const QUEUE_TTL_DAYS: i64 = 30;
|
pub const QUEUE_TTL_DAYS: i64 = 30;
|
||||||
|
|
||||||
/// Strop celkové velikosti fronty na příjemce (SECURITY audit #3). Bez něj
|
/// Největší zakódovaná obálka, kterou fronta přijme (CHECK v migraci 0001).
|
||||||
/// autentizovaný útočník nasype neomezeně obálek (256 KiB × N) a při flushi
|
const MAX_STORED_ENVELOPE_BYTES: usize = 262_144;
|
||||||
/// z toho vznikne OOM, na disku neomezený růst. Přetečení = FIFO zahození
|
|
||||||
/// nejstarších. 50 MiB ≈ 200 max obálek na příjemce.
|
|
||||||
const MAX_QUEUE_BYTES_PER_RECIPIENT: i64 = 50 * 1024 * 1024;
|
|
||||||
|
|
||||||
/// Velikost dávky při vylévání fronty (SECURITY audit #3). Strop paměti =
|
/// Velikost dávky při vylévání fronty (SECURITY audit #3). Strop paměti =
|
||||||
/// FLUSH_BATCH × MAX_ENVELOPE_PAYLOAD (~16 MiB), ne celá fronta najednou.
|
/// FLUSH_BATCH × MAX_ENVELOPE_PAYLOAD (~16 MiB), ne celá fronta najednou.
|
||||||
@@ -40,29 +37,61 @@ const MAX_DELIVERY_ATTEMPTS: i16 = 8;
|
|||||||
/// takže dva zmeškané cykly znamenají, že už nedýchá.
|
/// takže dva zmeškané cykly znamenají, že už nedýchá.
|
||||||
const FRESH_CONNECTION_MS: u64 = 70_000;
|
const FRESH_CONNECTION_MS: u64 = 70_000;
|
||||||
|
|
||||||
/// Výchozí strop souběžných WS spojení z jedné IP. Env
|
|
||||||
/// `SEPOT_MAX_CONNS_PER_IP` ho přepisuje — za CGNAT mobilních operátorů
|
|
||||||
/// sdílí jednu IP stovky lidí.
|
|
||||||
const DEFAULT_MAX_CONNS_PER_IP: u32 = 32;
|
|
||||||
|
|
||||||
/// Běžný budíček na jeden cíl nejvýš jednou za tohle okno: třicet zpráv ve
|
/// Běžný budíček na jeden cíl nejvýš jednou za tohle okno: třicet zpráv ve
|
||||||
/// skupině během výpadku sítě = jeden push, ne třicet (APNs i FCM nápor
|
/// skupině během výpadku sítě = jeden push, ne třicet (APNs i FCM nápor
|
||||||
/// tichých pushů škrtí, baterie taky).
|
/// tichých pushů škrtí, baterie taky).
|
||||||
const WAKE_MIN_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
|
const WAKE_MIN_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
|
||||||
/// Budíček hovoru (VoIP push / FCM k=call) zvoní na telefonu hned a bez
|
/// Budíček hovoru (VoIP push / FCM k=call) zvoní na telefonu hned a bez
|
||||||
/// ověření — proto má vlastní, přísnější strop na cíl: nejvýš jeden za
|
/// ověření — proto má vlastní, přísnější strop na cíl: nejvýš jeden za
|
||||||
/// CALL_WAKE_MIN_INTERVAL a CALL_WAKE_MAX_PER_WINDOW za CALL_WAKE_WINDOW.
|
/// `Limits::call_wake_min_interval` a `Limits::call_wakes_per_window` za
|
||||||
/// Nad strop se obálka uloží a jde jen běžný budíček (phantom
|
/// CALL_WAKE_WINDOW. Nad strop se obálka uloží a jde jen běžný budíček
|
||||||
/// hovory od kohokoli, kdo zná adresu).
|
/// (phantom hovory od kohokoli, kdo zná adresu).
|
||||||
const CALL_WAKE_MIN_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10);
|
|
||||||
const CALL_WAKE_WINDOW: std::time::Duration = std::time::Duration::from_secs(600);
|
const CALL_WAKE_WINDOW: std::time::Duration = std::time::Duration::from_secs(600);
|
||||||
const CALL_WAKE_MAX_PER_WINDOW: usize = 6;
|
/// Okno pro `Limits::otp_issue_per_hour`: noví kontakti jsou jednotky za den;
|
||||||
/// Kolik one-time prekeys smí relay vydat na jednu adresu za hodinu.
|
/// sto GET /v1/keys za dvacet sekund je vyčerpání zásoby cizím. Nad strop
|
||||||
/// Noví kontakti jsou jednotky za den; sto GET /v1/keys za dvacet sekund
|
/// jde bundle bez OTP.
|
||||||
/// je vyčerpání zásoby cizím. Nad strop jde bundle bez OTP.
|
|
||||||
const OTP_ISSUE_MAX_PER_HOUR: u32 = 30;
|
|
||||||
const OTP_ISSUE_WINDOW: std::time::Duration = std::time::Duration::from_secs(3600);
|
const OTP_ISSUE_WINDOW: std::time::Duration = std::time::Duration::from_secs(3600);
|
||||||
|
|
||||||
|
/// Okno kvóty odesílatele (`Limits::send_*_per_hour`).
|
||||||
|
const SEND_BUDGET_PERIOD: std::time::Duration = std::time::Duration::from_secs(3600);
|
||||||
|
/// Okno kvóty příloh na účet (`Limits::att_account_bytes_per_day`).
|
||||||
|
const ATTACHMENT_BUDGET_PERIOD: std::time::Duration = std::time::Duration::from_secs(86_400);
|
||||||
|
|
||||||
|
/// Proč relay obálku nepřijal. Klient dostane Error rámec s kódem, spojení
|
||||||
|
/// zůstává otevřené.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum RouteError {
|
||||||
|
/// 400: formát (délky, prázdný payload, nad strop velikosti).
|
||||||
|
Invalid,
|
||||||
|
/// 404: adresa nepatří žádnému zařízení. Ukládat by ji nikdo nevyzvedl
|
||||||
|
/// — a fronta na náhodné adresy byla nejlevnější cesta, jak zaplnit disk.
|
||||||
|
/// Nic nového to neprozradí: GET /v1/keys vrací na neznámou adresu 404 taky.
|
||||||
|
UnknownRecipient,
|
||||||
|
/// 429: odesílající účet vyčerpal hodinovou kvótu.
|
||||||
|
SenderQuota,
|
||||||
|
/// 507: na disku zbývá méně než `Limits::disk_min_free_pct_queue`.
|
||||||
|
DiskFull,
|
||||||
|
Internal(anyhow::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RouteError {
|
||||||
|
pub fn code_and_message(&self) -> (u32, &'static str) {
|
||||||
|
match self {
|
||||||
|
Self::Invalid => (400, "obálka odmítnuta"),
|
||||||
|
Self::UnknownRecipient => (404, "příjemce neexistuje"),
|
||||||
|
Self::SenderQuota => (429, "příliš mnoho zpráv, zkus to později"),
|
||||||
|
Self::DiskFull => (507, "relay je dočasně plný, zkus to později"),
|
||||||
|
Self::Internal(_) => (500, "interní chyba"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<sqlx::Error> for RouteError {
|
||||||
|
fn from(e: sqlx::Error) -> Self {
|
||||||
|
Self::Internal(e.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Značky budíčků jednoho cíle (jen v paměti, nikam se neukládají).
|
/// Značky budíčků jednoho cíle (jen v paměti, nikam se neukládají).
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct WakeMarks {
|
pub struct WakeMarks {
|
||||||
@@ -157,8 +186,15 @@ pub struct AppState {
|
|||||||
pub apns: Option<std::sync::Arc<crate::apns::ApnsSender>>,
|
pub apns: Option<std::sync::Arc<crate::apns::ApnsSender>>,
|
||||||
/// TURN pro hovory (None = relay údaje nevydává).
|
/// TURN pro hovory (None = relay údaje nevydává).
|
||||||
pub turn: Option<crate::turn::TurnConfig>,
|
pub turn: Option<crate::turn::TurnConfig>,
|
||||||
/// Strop WS spojení na IP.
|
/// Všechny limity relaye (env, viz limits.rs).
|
||||||
pub max_conns_per_ip: u32,
|
pub limits: crate::limits::Limits,
|
||||||
|
/// Kvóta ukládaných obálek na odesílající účet (paměť).
|
||||||
|
pub send_budget: crate::quota::Budgets,
|
||||||
|
/// Kvóta příloh na účet: tempo za den a uloženo celkem (paměť).
|
||||||
|
pub attachment_budget: crate::quota::Budgets,
|
||||||
|
pub attachment_stored: crate::quota::StoredLedger,
|
||||||
|
/// Volné místo na datovém filesystému (přílohy i DB).
|
||||||
|
pub disk: Arc<crate::disk::DiskWatch>,
|
||||||
/// delivery_address → značky budíčků (throttling pushů a hovorů).
|
/// delivery_address → značky budíčků (throttling pushů a hovorů).
|
||||||
pub wake_marks: DashMap<Vec<u8>, WakeMarks>,
|
pub wake_marks: DashMap<Vec<u8>, WakeMarks>,
|
||||||
/// delivery_address → (začátek okna, vydaných OTP) — drain ochrana.
|
/// delivery_address → (začátek okna, vydaných OTP) — drain ochrana.
|
||||||
@@ -174,6 +210,7 @@ impl AppState {
|
|||||||
certs: CertAuthority,
|
certs: CertAuthority,
|
||||||
attachments: crate::attachments::Config,
|
attachments: crate::attachments::Config,
|
||||||
trusted_proxies: Arc<TrustedProxies>,
|
trusted_proxies: Arc<TrustedProxies>,
|
||||||
|
limits: crate::limits::Limits,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
pool,
|
pool,
|
||||||
@@ -187,10 +224,22 @@ impl AppState {
|
|||||||
fcm: crate::fcm::FcmSender::from_env(),
|
fcm: crate::fcm::FcmSender::from_env(),
|
||||||
apns: crate::apns::ApnsSender::from_env(),
|
apns: crate::apns::ApnsSender::from_env(),
|
||||||
turn: crate::turn::TurnConfig::from_env(),
|
turn: crate::turn::TurnConfig::from_env(),
|
||||||
max_conns_per_ip: std::env::var("SEPOT_MAX_CONNS_PER_IP")
|
send_budget: crate::quota::Budgets::new(
|
||||||
.ok()
|
limits.send_per_hour,
|
||||||
.and_then(|v| v.parse().ok())
|
limits.send_bytes_per_hour,
|
||||||
.unwrap_or(DEFAULT_MAX_CONNS_PER_IP),
|
SEND_BUDGET_PERIOD,
|
||||||
|
),
|
||||||
|
attachment_budget: crate::quota::Budgets::new(
|
||||||
|
0,
|
||||||
|
limits.att_account_bytes_per_day,
|
||||||
|
ATTACHMENT_BUDGET_PERIOD,
|
||||||
|
),
|
||||||
|
attachment_stored: crate::quota::StoredLedger::new(
|
||||||
|
limits.att_account_stored_bytes,
|
||||||
|
crate::attachments::ATTACHMENT_TTL_DAYS,
|
||||||
|
),
|
||||||
|
disk: Arc::new(crate::disk::DiskWatch::unknown()),
|
||||||
|
limits,
|
||||||
wake_marks: DashMap::new(),
|
wake_marks: DashMap::new(),
|
||||||
otp_issued: DashMap::new(),
|
otp_issued: DashMap::new(),
|
||||||
source_url: std::env::var("SEPOT_SOURCE_URL")
|
source_url: std::env::var("SEPOT_SOURCE_URL")
|
||||||
@@ -212,6 +261,9 @@ impl AppState {
|
|||||||
});
|
});
|
||||||
self.otp_issued
|
self.otp_issued
|
||||||
.retain(|_, (start, _)| now.duration_since(*start) < OTP_ISSUE_WINDOW);
|
.retain(|_, (start, _)| now.duration_since(*start) < OTP_ISSUE_WINDOW);
|
||||||
|
self.send_budget.prune();
|
||||||
|
self.attachment_budget.prune();
|
||||||
|
self.attachment_stored.prune();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Smí se vydat další OTP pro tuhle adresu? Počítá v hodinovém okně.
|
/// Smí se vydat další OTP pro tuhle adresu? Počítá v hodinovém okně.
|
||||||
@@ -224,7 +276,7 @@ impl AppState {
|
|||||||
if now.duration_since(entry.0) >= OTP_ISSUE_WINDOW {
|
if now.duration_since(entry.0) >= OTP_ISSUE_WINDOW {
|
||||||
*entry = (now, 0);
|
*entry = (now, 0);
|
||||||
}
|
}
|
||||||
if entry.1 >= OTP_ISSUE_MAX_PER_HOUR {
|
if entry.1 >= self.limits.otp_issue_per_hour {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
entry.1 += 1;
|
entry.1 += 1;
|
||||||
@@ -255,11 +307,11 @@ impl AppState {
|
|||||||
.call_wakes
|
.call_wakes
|
||||||
.retain(|t| now.duration_since(*t) < CALL_WAKE_WINDOW);
|
.retain(|t| now.duration_since(*t) < CALL_WAKE_WINDOW);
|
||||||
if let Some(last) = marks.call_wakes.back() {
|
if let Some(last) = marks.call_wakes.back() {
|
||||||
if now.duration_since(*last) < CALL_WAKE_MIN_INTERVAL {
|
if now.duration_since(*last) < self.limits.call_wake_min_interval {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if marks.call_wakes.len() >= CALL_WAKE_MAX_PER_WINDOW {
|
if marks.call_wakes.len() >= self.limits.call_wakes_per_window {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
marks.call_wakes.push_back(now);
|
marks.call_wakes.push_back(now);
|
||||||
@@ -277,19 +329,22 @@ impl AppState {
|
|||||||
Ok(n)
|
Ok(n)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Přijme obálku: validace → perzistentní fronta (dedup) → pokud je
|
/// Přijme obálku: validace → existence příjemce → kvóta odesílatele a
|
||||||
/// příjemce online, doručí hned. At-least-once: záznam z fronty maže
|
/// místo na disku → perzistentní fronta (dedup) → pokud je příjemce
|
||||||
/// až ACK příjemce, ne odeslání.
|
/// online, doručí hned. At-least-once: záznam z fronty maže až ACK
|
||||||
|
/// příjemce, ne odeslání. `sender_account` = ověřený účet spojení (jen
|
||||||
|
/// klíč kvóty v paměti; do fronty se neukládá — sealed sender).
|
||||||
pub async fn route_envelope(
|
pub async fn route_envelope(
|
||||||
&self,
|
&self,
|
||||||
env: v1::Envelope,
|
env: v1::Envelope,
|
||||||
) -> anyhow::Result<v1::receipt::Status> {
|
sender_account: &[u8],
|
||||||
|
) -> Result<v1::receipt::Status, RouteError> {
|
||||||
if env.dst_address.len() != 32
|
if env.dst_address.len() != 32
|
||||||
|| env.msg_uuid.len() != 16
|
|| env.msg_uuid.len() != 16
|
||||||
|| env.payload.is_empty()
|
|| env.payload.is_empty()
|
||||||
|| env.payload.len() > MAX_ENVELOPE_PAYLOAD
|
|| env.payload.len() > MAX_ENVELOPE_PAYLOAD
|
||||||
{
|
{
|
||||||
anyhow::bail!("neplatná obálka");
|
return Err(RouteError::Invalid);
|
||||||
}
|
}
|
||||||
|
|
||||||
if env.content_hint == v1::ContentHint::Ephemeral as i32 {
|
if env.content_hint == v1::ContentHint::Ephemeral as i32 {
|
||||||
@@ -297,7 +352,26 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let dst = env.dst_address.clone();
|
let dst = env.dst_address.clone();
|
||||||
|
// Online příjemce existuje; jinak jeden indexovaný dotaz (UNIQUE).
|
||||||
|
if self.online.get(&dst).is_none() && !self.address_exists(&dst).await? {
|
||||||
|
return Err(RouteError::UnknownRecipient);
|
||||||
|
}
|
||||||
|
if self.disk.below(self.limits.disk_min_free_pct_queue) {
|
||||||
|
return Err(RouteError::DiskFull);
|
||||||
|
}
|
||||||
let encoded = env.encode_to_vec();
|
let encoded = env.encode_to_vec();
|
||||||
|
// Strop zakódované obálky = CHECK message_queue.envelope v 0001; payload
|
||||||
|
// těsně pod MAX_ENVELOPE_PAYLOAD ho s hlavičkou protobufu přesáhne a DB
|
||||||
|
// by zápis odmítla až po odečtení kvóty (dřív jako 400 přes chybu DB).
|
||||||
|
if encoded.len() > MAX_STORED_ENVELOPE_BYTES {
|
||||||
|
return Err(RouteError::Invalid);
|
||||||
|
}
|
||||||
|
if !self
|
||||||
|
.send_budget
|
||||||
|
.try_take(sender_account, encoded.len() as u64)
|
||||||
|
{
|
||||||
|
return Err(RouteError::SenderQuota);
|
||||||
|
}
|
||||||
let expires = Utc::now() + Duration::days(QUEUE_TTL_DAYS);
|
let expires = Utc::now() + Duration::days(QUEUE_TTL_DAYS);
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO message_queue (msg_uuid, dst_address, envelope, expires_at)
|
"INSERT INTO message_queue (msg_uuid, dst_address, envelope, expires_at)
|
||||||
@@ -311,7 +385,13 @@ impl AppState {
|
|||||||
.await?;
|
.await?;
|
||||||
// FIFO strop na příjemce (audit #3). Nově vložená má nejmladší
|
// FIFO strop na příjemce (audit #3). Nově vložená má nejmladší
|
||||||
// enqueued_at → sama se nikdy nezahodí, padne jen starý přebytek.
|
// enqueued_at → sama se nikdy nezahodí, padne jen starý přebytek.
|
||||||
self.trim_queue(&dst, MAX_QUEUE_BYTES_PER_RECIPIENT).await?;
|
self.trim_queue(
|
||||||
|
&dst,
|
||||||
|
self.limits.queue_max_bytes_per_recipient,
|
||||||
|
self.limits.queue_max_per_recipient,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(RouteError::Internal)?;
|
||||||
|
|
||||||
let is_call = env.content_hint == v1::ContentHint::Call as i32;
|
let is_call = env.content_hint == v1::ContentHint::Call as i32;
|
||||||
if let Some(dev) = self.online.get(&dst) {
|
if let Some(dev) = self.online.get(&dst) {
|
||||||
@@ -341,6 +421,13 @@ impl AppState {
|
|||||||
Ok(v1::receipt::Status::StoredOffline)
|
Ok(v1::receipt::Status::StoredOffline)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn address_exists(&self, dst: &[u8]) -> Result<bool, sqlx::Error> {
|
||||||
|
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM devices WHERE delivery_address = $1)")
|
||||||
|
.bind(dst)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
/// Jen živé doručení („píše…“): do fronty nic, push nic. Zatuchlé
|
/// Jen živé doručení („píše…“): do fronty nic, push nic. Zatuchlé
|
||||||
/// spojení se nepočítá — obálka by v mrtvém kanálu zmizela stejně
|
/// spojení se nepočítá — obálka by v mrtvém kanálu zmizela stejně
|
||||||
/// a odesílatel se má dozvědět, že příjemce nikdo neposlouchá.
|
/// a odesílatel se má dozvědět, že příjemce nikdo neposlouchá.
|
||||||
@@ -362,23 +449,27 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// FIFO strop fronty příjemce: nech nejnovější obálky do `cap_bytes`
|
/// FIFO strop fronty příjemce: nech nejnovější obálky do `cap_bytes`
|
||||||
/// (běžný součet od nejnovější), starší přebytek zahoď. Vrací počet
|
/// (běžný součet od nejnovější) a nejvýš `cap_count` kusů, starší
|
||||||
/// zahozených. Oddělené kvůli přímému testu bez vkládání 50 MiB.
|
/// přebytek zahoď. Strop počtu chrání před miliony drobných obálek, kde
|
||||||
async fn trim_queue(&self, dst: &[u8], cap_bytes: i64) -> anyhow::Result<u64> {
|
/// řádek v DB stojí víc než payload. Vrací počet zahozených. Oddělené
|
||||||
|
/// kvůli přímému testu bez vkládání 50 MiB.
|
||||||
|
async fn trim_queue(&self, dst: &[u8], cap_bytes: i64, cap_count: i64) -> anyhow::Result<u64> {
|
||||||
let res = sqlx::query(
|
let res = sqlx::query(
|
||||||
"DELETE FROM message_queue
|
"DELETE FROM message_queue
|
||||||
WHERE dst_address = $1 AND msg_uuid IN (
|
WHERE dst_address = $1 AND msg_uuid IN (
|
||||||
SELECT msg_uuid FROM (
|
SELECT msg_uuid FROM (
|
||||||
SELECT msg_uuid,
|
SELECT msg_uuid,
|
||||||
sum(octet_length(envelope)) OVER (
|
sum(octet_length(envelope)) OVER newest AS running,
|
||||||
ORDER BY enqueued_at DESC, msg_uuid DESC
|
row_number() OVER newest AS position
|
||||||
ROWS UNBOUNDED PRECEDING) AS running
|
|
||||||
FROM message_queue WHERE dst_address = $1
|
FROM message_queue WHERE dst_address = $1
|
||||||
) s WHERE running > $2
|
WINDOW newest AS (ORDER BY enqueued_at DESC, msg_uuid DESC
|
||||||
|
ROWS UNBOUNDED PRECEDING)
|
||||||
|
) s WHERE running > $2 OR position > $3
|
||||||
)",
|
)",
|
||||||
)
|
)
|
||||||
.bind(dst)
|
.bind(dst)
|
||||||
.bind(cap_bytes)
|
.bind(cap_bytes)
|
||||||
|
.bind(cap_count)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(res.rows_affected())
|
Ok(res.rows_affected())
|
||||||
@@ -475,7 +566,11 @@ impl AppState {
|
|||||||
endpoint: Option<&[u8]>,
|
endpoint: Option<&[u8]>,
|
||||||
voip_token: Option<&[u8]>,
|
voip_token: Option<&[u8]>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
if platform == crate::push::PLATFORM_NONE || endpoint.is_none() {
|
// APNs s jediným VoIP tokenem (appka hovory zvoní přes PushKit, běžný
|
||||||
|
// APNs token ještě nemá) se ukládá taky — dřív by ji tahle větev
|
||||||
|
// smazala a hovor by offline iPhone nikdy nevzbudil.
|
||||||
|
let voip_only = platform == crate::push::PLATFORM_APNS && voip_token.is_some();
|
||||||
|
if platform == crate::push::PLATFORM_NONE || (endpoint.is_none() && !voip_only) {
|
||||||
sqlx::query("DELETE FROM push_tokens WHERE device_uuid = $1")
|
sqlx::query("DELETE FROM push_tokens WHERE device_uuid = $1")
|
||||||
.bind(device_uuid)
|
.bind(device_uuid)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
@@ -693,6 +788,9 @@ mod db_tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
|
|
||||||
|
/// Účet odesílatele v testech — kvóta je jen v paměti, DB ho nezná.
|
||||||
|
const SENDER: &[u8] = b"test-odesilatel";
|
||||||
|
|
||||||
async fn test_state() -> AppState {
|
async fn test_state() -> AppState {
|
||||||
let url = std::env::var("DATABASE_URL")
|
let url = std::env::var("DATABASE_URL")
|
||||||
.unwrap_or_else(|_| "postgres://sepot:sepot_dev@127.0.0.1/sepot_relay".into());
|
.unwrap_or_else(|_| "postgres://sepot:sepot_dev@127.0.0.1/sepot_relay".into());
|
||||||
@@ -703,7 +801,13 @@ mod db_tests {
|
|||||||
.expect("dev DB");
|
.expect("dev DB");
|
||||||
let certs = crate::certs::CertAuthority::init(&pool).await.unwrap();
|
let certs = crate::certs::CertAuthority::init(&pool).await.unwrap();
|
||||||
let trusted = Arc::new(crate::net::TrustedProxies::from_env());
|
let trusted = Arc::new(crate::net::TrustedProxies::from_env());
|
||||||
AppState::new(pool, certs, crate::attachments::Config::from_env(), trusted)
|
AppState::new(
|
||||||
|
pool,
|
||||||
|
certs,
|
||||||
|
crate::attachments::Config::from_env(),
|
||||||
|
trusted,
|
||||||
|
crate::limits::Limits::from_env(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rand_bytes(n: usize) -> Vec<u8> {
|
fn rand_bytes(n: usize) -> Vec<u8> {
|
||||||
@@ -826,7 +930,7 @@ mod db_tests {
|
|||||||
content_hint: v1::ContentHint::Call as i32,
|
content_hint: v1::ContentHint::Call as i32,
|
||||||
payload: vec![0xCD; 64],
|
payload: vec![0xCD; 64],
|
||||||
};
|
};
|
||||||
let status = state.route_envelope(env).await.unwrap();
|
let status = state.route_envelope(env, SENDER).await.unwrap();
|
||||||
assert_eq!(status, v1::receipt::Status::StoredOffline);
|
assert_eq!(status, v1::receipt::Status::StoredOffline);
|
||||||
let queued: i64 =
|
let queued: i64 =
|
||||||
sqlx::query_scalar("SELECT count(*) FROM message_queue WHERE dst_address = $1")
|
sqlx::query_scalar("SELECT count(*) FROM message_queue WHERE dst_address = $1")
|
||||||
@@ -875,7 +979,7 @@ mod db_tests {
|
|||||||
content_hint: 0,
|
content_hint: 0,
|
||||||
payload: vec![0xAB; 64],
|
payload: vec![0xAB; 64],
|
||||||
};
|
};
|
||||||
let status = state.route_envelope(env).await.unwrap();
|
let status = state.route_envelope(env, SENDER).await.unwrap();
|
||||||
assert_eq!(status, v1::receipt::Status::StoredOffline);
|
assert_eq!(status, v1::receipt::Status::StoredOffline);
|
||||||
|
|
||||||
// obálka je ve frontě (příjemce byl offline)
|
// obálka je ve frontě (příjemce byl offline)
|
||||||
@@ -902,7 +1006,7 @@ mod db_tests {
|
|||||||
content_hint: v1::ContentHint::Ephemeral as i32,
|
content_hint: v1::ContentHint::Ephemeral as i32,
|
||||||
payload: vec![0xAB; 64],
|
payload: vec![0xAB; 64],
|
||||||
};
|
};
|
||||||
let status = state.route_envelope(env).await.unwrap();
|
let status = state.route_envelope(env, SENDER).await.unwrap();
|
||||||
assert_eq!(status, v1::receipt::Status::Dropped);
|
assert_eq!(status, v1::receipt::Status::Dropped);
|
||||||
|
|
||||||
// „píše…“ pro offline příjemce nesmí skončit ve frontě
|
// „píše…“ pro offline příjemce nesmí skončit ve frontě
|
||||||
@@ -1055,7 +1159,7 @@ mod db_tests {
|
|||||||
payload: vec![0xCD; 64],
|
payload: vec![0xCD; 64],
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
state.route_envelope(env).await.unwrap(),
|
state.route_envelope(env, SENDER).await.unwrap(),
|
||||||
v1::receipt::Status::StoredOffline,
|
v1::receipt::Status::StoredOffline,
|
||||||
"zatuchlé spojení se nesmí hlásit jako doručené online"
|
"zatuchlé spojení se nesmí hlásit jako doručené online"
|
||||||
);
|
);
|
||||||
@@ -1069,7 +1173,7 @@ mod db_tests {
|
|||||||
payload: vec![0xCE; 64],
|
payload: vec![0xCE; 64],
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
state.route_envelope(env2).await.unwrap(),
|
state.route_envelope(env2, SENDER).await.unwrap(),
|
||||||
v1::receipt::Status::DeliveredOnline,
|
v1::receipt::Status::DeliveredOnline,
|
||||||
"živé spojení má být online"
|
"živé spojení má být online"
|
||||||
);
|
);
|
||||||
@@ -1109,7 +1213,7 @@ mod db_tests {
|
|||||||
payload: vec![0xEF; 64],
|
payload: vec![0xEF; 64],
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
state.route_envelope(env).await.unwrap(),
|
state.route_envelope(env, SENDER).await.unwrap(),
|
||||||
v1::receipt::Status::DeliveredOnline,
|
v1::receipt::Status::DeliveredOnline,
|
||||||
"konec zombie spojení nesmí vyřadit nové spojení"
|
"konec zombie spojení nesmí vyřadit nové spojení"
|
||||||
);
|
);
|
||||||
@@ -1237,7 +1341,7 @@ mod db_tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
let dropped = state.trim_queue(&dst, 250).await.unwrap();
|
let dropped = state.trim_queue(&dst, 250, 1000).await.unwrap();
|
||||||
assert_eq!(dropped, 3);
|
assert_eq!(dropped, 3);
|
||||||
|
|
||||||
let remaining: Vec<Vec<u8>> = sqlx::query_scalar(
|
let remaining: Vec<Vec<u8>> = sqlx::query_scalar(
|
||||||
@@ -1255,4 +1359,140 @@ mod db_tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn envelope(dst: &[u8], payload_len: usize) -> v1::Envelope {
|
||||||
|
v1::Envelope {
|
||||||
|
dst_address: dst.to_vec(),
|
||||||
|
msg_uuid: rand_bytes(16),
|
||||||
|
content_hint: 0,
|
||||||
|
payload: vec![0x5Au8; payload_len],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn queued(state: &AppState, dst: &[u8]) -> i64 {
|
||||||
|
sqlx::query_scalar("SELECT count(*) FROM message_queue WHERE dst_address = $1")
|
||||||
|
.bind(dst)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obálka na adresu bez zařízení se neuloží (dřív 30 dní ve frontě =
|
||||||
|
/// nejlevnější cesta, jak zaplnit disk relaye).
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
|
||||||
|
async fn obalka_na_neexistujici_adresu_se_neulozi() {
|
||||||
|
let state = test_state().await;
|
||||||
|
let dst = rand_bytes(32);
|
||||||
|
let err = state.route_envelope(envelope(&dst, 64), SENDER).await.unwrap_err();
|
||||||
|
assert!(matches!(err, RouteError::UnknownRecipient), "{err:?}");
|
||||||
|
assert_eq!(err.code_and_message().0, 404);
|
||||||
|
assert_eq!(queued(&state, &dst).await, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kvóta odesílatele: nad hodinový strop 429, jiný účet jede dál.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
|
||||||
|
async fn kvota_odesilatele_odmitne_nad_strop() {
|
||||||
|
let mut state = test_state().await;
|
||||||
|
state.send_budget = crate::quota::Budgets::new(3, 0, SEND_BUDGET_PERIOD);
|
||||||
|
let (account_id, _device, dst) = seed_device(&state).await;
|
||||||
|
let sender = rand_bytes(16);
|
||||||
|
for _ in 0..3 {
|
||||||
|
state.route_envelope(envelope(&dst, 64), &sender).await.unwrap();
|
||||||
|
}
|
||||||
|
let err = state.route_envelope(envelope(&dst, 64), &sender).await.unwrap_err();
|
||||||
|
assert!(matches!(err, RouteError::SenderQuota), "{err:?}");
|
||||||
|
state
|
||||||
|
.route_envelope(envelope(&dst, 64), &rand_bytes(16))
|
||||||
|
.await
|
||||||
|
.expect("jiný odesílatel má vlastní kvótu");
|
||||||
|
assert_eq!(queued(&state, &dst).await, 4);
|
||||||
|
cleanup(&state, &account_id, &dst).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Málo místa na disku: offline fronta stojí (507), nic se nezapíše.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
|
||||||
|
async fn plny_disk_zastavi_frontu() {
|
||||||
|
let state = test_state().await;
|
||||||
|
let (account_id, _device, dst) = seed_device(&state).await;
|
||||||
|
state.disk.set_free_pct(state.limits.disk_min_free_pct_queue - 1.0);
|
||||||
|
let err = state.route_envelope(envelope(&dst, 64), SENDER).await.unwrap_err();
|
||||||
|
assert!(matches!(err, RouteError::DiskFull), "{err:?}");
|
||||||
|
assert_eq!(queued(&state, &dst).await, 0);
|
||||||
|
cleanup(&state, &account_id, &dst).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strop počtu obálek na příjemce drží nejnovější, i když bajty stačí.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
|
||||||
|
async fn trim_queue_strop_poctu() {
|
||||||
|
let state = test_state().await;
|
||||||
|
let dst = rand_bytes(32);
|
||||||
|
let mut uuids = Vec::new();
|
||||||
|
for i in 0..5 {
|
||||||
|
let msg_uuid = rand_bytes(16);
|
||||||
|
uuids.push(msg_uuid.clone());
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO message_queue (msg_uuid, dst_address, envelope, enqueued_at, expires_at)
|
||||||
|
VALUES ($1, $2, $3, now() + (interval '1 second' * $4), now() + interval '30 days')",
|
||||||
|
)
|
||||||
|
.bind(&msg_uuid)
|
||||||
|
.bind(&dst)
|
||||||
|
.bind(vec![0u8; 10])
|
||||||
|
.bind(i as i32)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(state.trim_queue(&dst, 1 << 30, 2).await.unwrap(), 3);
|
||||||
|
let remaining: Vec<Vec<u8>> = sqlx::query_scalar(
|
||||||
|
"SELECT msg_uuid FROM message_queue WHERE dst_address = $1 ORDER BY enqueued_at",
|
||||||
|
)
|
||||||
|
.bind(&dst)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(remaining, vec![uuids[3].clone(), uuids[4].clone()]);
|
||||||
|
sqlx::query("DELETE FROM message_queue WHERE dst_address = $1")
|
||||||
|
.bind(&dst)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// iPhone s jediným VoIP tokenem (běžný APNs token ještě nemá) musí jít
|
||||||
|
/// vzbudit hovorem — registrace se nesmí zahodit.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
|
||||||
|
async fn apns_jen_s_voip_tokenem_se_ulozi() {
|
||||||
|
let state = test_state().await;
|
||||||
|
let (account_id, device_uuid, dst) = seed_device(&state).await;
|
||||||
|
state
|
||||||
|
.register_push(&device_uuid, crate::push::PLATFORM_APNS, None, Some(b"voip"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let target = state.push_target_full(&dst).await.expect("VoIP cíl");
|
||||||
|
assert_eq!(target.voip_token.as_deref(), Some(&b"voip"[..]));
|
||||||
|
assert!(target.token.is_none());
|
||||||
|
assert!(state.push_target(&dst).await.is_none(), "běžný budíček nemá kam");
|
||||||
|
cleanup(&state, &account_id, &dst).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obálka, která se zakódovaná nevejde do CHECK fronty, je 400 dřív, než
|
||||||
|
/// ukrojí z kvóty odesílatele — ne 500 z databáze.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
|
||||||
|
async fn obalka_nad_strop_fronty_je_neplatna() {
|
||||||
|
let state = test_state().await;
|
||||||
|
let (account_id, _device, dst) = seed_device(&state).await;
|
||||||
|
let err = state
|
||||||
|
.route_envelope(envelope(&dst, MAX_ENVELOPE_PAYLOAD), SENDER)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, RouteError::Invalid), "{err:?}");
|
||||||
|
assert_eq!(queued(&state, &dst).await, 0);
|
||||||
|
cleanup(&state, &account_id, &dst).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-11
@@ -33,13 +33,20 @@ use crate::state::AppState;
|
|||||||
const AUTH_DEADLINE: Duration = Duration::from_secs(10);
|
const AUTH_DEADLINE: Duration = Duration::from_secs(10);
|
||||||
/// Kolik AUTH pokusů smí jedno spojení udělat (každý = dotaz do DB).
|
/// Kolik AUTH pokusů smí jedno spojení udělat (každý = dotaz do DB).
|
||||||
const MAX_AUTH_ATTEMPTS: u32 = 3;
|
const MAX_AUTH_ATTEMPTS: u32 = 3;
|
||||||
/// Rate limit rámců od klienta: max FRAME_WINDOW_MAX za FRAME_WINDOW.
|
/// Rate limit rámců od klienta: max `Limits::ws_frames_per_window` za FRAME_WINDOW.
|
||||||
/// Počítají se jen rámce, které něco stojí (obálka, push registrace, TURN);
|
/// Počítají se jen rámce, které něco stojí (obálka, push registrace, TURN);
|
||||||
/// ACK a Pong ne — klient po vylití velké fronty posílá ACK + doručenku na
|
/// ACK a Pong ne — klient po vylití velké fronty posílá ACK + doručenku na
|
||||||
/// každou obálku a 429 by ho odpojilo uprostřed, fronta by se po osmi
|
/// každou obálku a 429 by ho odpojilo uprostřed, fronta by se po osmi
|
||||||
/// kolech smazala (ztráta zpráv bez útočníka).
|
/// kolech smazala (ztráta zpráv bez útočníka).
|
||||||
const FRAME_WINDOW: Duration = Duration::from_secs(10);
|
const FRAME_WINDOW: Duration = Duration::from_secs(10);
|
||||||
const FRAME_WINDOW_MAX: u32 = 200;
|
/// Buffery jednoho spojení. Výchozí v axum (tungstenite) jsou 128 KiB čtecí
|
||||||
|
/// + 128 KiB zapisovací = ~137 KiB RAM na spojení; při 512 MiB stropu
|
||||||
|
/// kontejneru OOM kolem 3,7–7 tisíc připojených (změřeno). 4 KiB čtecí buffer
|
||||||
|
/// a zápis bez bufferu (každý rámec se hned flushne) = ~16,6 KiB na spojení,
|
||||||
|
/// 8 990 spojení = 149 MiB. Velké obálky (256 KiB) tím netrpí: čtecí buffer
|
||||||
|
/// je jen blok, zprávu skládá tungstenite zvlášť do `max_message_size`.
|
||||||
|
const WS_READ_BUFFER: usize = 4096;
|
||||||
|
const WS_WRITE_BUFFER: usize = 0;
|
||||||
/// Push endpoint (URL distributora / FCM token) a VoIP token mají rozumný
|
/// Push endpoint (URL distributora / FCM token) a VoIP token mají rozumný
|
||||||
/// strop; APNs token je 64 hex, FCM do ~200 znaků, UnifiedPush URL < 1 KiB.
|
/// strop; APNs token je 64 hex, FCM do ~200 znaků, UnifiedPush URL < 1 KiB.
|
||||||
const MAX_PUSH_ENDPOINT_BYTES: usize = 2048;
|
const MAX_PUSH_ENDPOINT_BYTES: usize = 2048;
|
||||||
@@ -83,10 +90,16 @@ pub async fn ws_handler(
|
|||||||
ws: WebSocketUpgrade,
|
ws: WebSocketUpgrade,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let ip = state.trusted_proxies.client_ip(peer.ip(), &headers);
|
let ip = state.trusted_proxies.client_ip(peer.ip(), &headers);
|
||||||
|
// Všichni onion klienti mají IP kontejneru Toru → vlastní, vyšší strop.
|
||||||
|
let cap = if state.trusted_proxies.is_tor(peer.ip()) {
|
||||||
|
state.limits.max_conns_tor
|
||||||
|
} else {
|
||||||
|
state.limits.max_conns_per_ip
|
||||||
|
};
|
||||||
|
|
||||||
let over_cap = {
|
let over_cap = {
|
||||||
let mut entry = state.ws_conns.entry(ip).or_insert(0);
|
let mut entry = state.ws_conns.entry(ip).or_insert(0);
|
||||||
if *entry >= state.max_conns_per_ip {
|
if *entry >= cap {
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
*entry += 1;
|
*entry += 1;
|
||||||
@@ -103,7 +116,9 @@ pub async fn ws_handler(
|
|||||||
ip,
|
ip,
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.max_message_size(MAX_ENVELOPE_PAYLOAD + 4096)
|
ws.read_buffer_size(WS_READ_BUFFER)
|
||||||
|
.write_buffer_size(WS_WRITE_BUFFER)
|
||||||
|
.max_message_size(MAX_ENVELOPE_PAYLOAD + 4096)
|
||||||
.max_frame_size(MAX_ENVELOPE_PAYLOAD + 4096)
|
.max_frame_size(MAX_ENVELOPE_PAYLOAD + 4096)
|
||||||
.on_upgrade(move |socket| async move {
|
.on_upgrade(move |socket| async move {
|
||||||
let _guard = guard;
|
let _guard = guard;
|
||||||
@@ -119,6 +134,7 @@ struct Pending {
|
|||||||
delivery_address: Vec<u8>,
|
delivery_address: Vec<u8>,
|
||||||
identity_key: Vec<u8>,
|
identity_key: Vec<u8>,
|
||||||
device_uuid: Vec<u8>,
|
device_uuid: Vec<u8>,
|
||||||
|
account_id: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn server_frame(frame: v1::server_frame::Frame) -> v1::ServerFrame {
|
fn server_frame(frame: v1::server_frame::Frame) -> v1::ServerFrame {
|
||||||
@@ -166,6 +182,8 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
|
|||||||
let mut authed_address: Option<Vec<u8>> = None;
|
let mut authed_address: Option<Vec<u8>> = None;
|
||||||
// device_uuid ověřeného zařízení — klíč pro push registraci.
|
// device_uuid ověřeného zařízení — klíč pro push registraci.
|
||||||
let mut authed_device: Option<Vec<u8>> = None;
|
let mut authed_device: Option<Vec<u8>> = None;
|
||||||
|
// Účet ověřeného zařízení — klíč kvóty odesílatele (jen v paměti).
|
||||||
|
let mut authed_account: Option<Vec<u8>> = None;
|
||||||
// Záznam v `online` mapě — značkuje se každým rámcem od klienta, aby
|
// Záznam v `online` mapě — značkuje se každým rámcem od klienta, aby
|
||||||
// routing poznal živé spojení od zatuchlého.
|
// routing poznal živé spojení od zatuchlého.
|
||||||
let mut online_device: Option<Arc<crate::state::OnlineDevice>> = None;
|
let mut online_device: Option<Arc<crate::state::OnlineDevice>> = None;
|
||||||
@@ -233,7 +251,7 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
|
|||||||
window_frames = 0;
|
window_frames = 0;
|
||||||
}
|
}
|
||||||
window_frames += 1;
|
window_frames += 1;
|
||||||
if window_frames > FRAME_WINDOW_MAX {
|
if window_frames > state.limits.ws_frames_per_window {
|
||||||
let _ = tx.send(error_frame(429, "příliš mnoho rámců")).await;
|
let _ = tx.send(error_frame(429, "příliš mnoho rámců")).await;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -250,8 +268,8 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
|
|||||||
let _ = tx.send(error_frame(400, "neplatný AUTH")).await;
|
let _ = tx.send(error_frame(400, "neplatný AUTH")).await;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let row: Option<(Vec<u8>, Vec<u8>)> = sqlx::query_as(
|
let row: Option<(Vec<u8>, Vec<u8>, Vec<u8>)> = sqlx::query_as(
|
||||||
"SELECT d.delivery_address, i.identity_key
|
"SELECT d.delivery_address, i.identity_key, d.account_id
|
||||||
FROM devices d JOIN identities i USING (account_id)
|
FROM devices d JOIN identities i USING (account_id)
|
||||||
WHERE d.device_uuid = $1",
|
WHERE d.device_uuid = $1",
|
||||||
)
|
)
|
||||||
@@ -260,7 +278,7 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
|
|||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.flatten();
|
.flatten();
|
||||||
let Some((delivery_address, identity_key)) = row else {
|
let Some((delivery_address, identity_key, account_id)) = row else {
|
||||||
let _ = tx.send(error_frame(401, "neautorizováno")).await;
|
let _ = tx.send(error_frame(401, "neautorizováno")).await;
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
@@ -279,6 +297,7 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
|
|||||||
delivery_address,
|
delivery_address,
|
||||||
identity_key,
|
identity_key,
|
||||||
device_uuid: auth.device_uuid,
|
device_uuid: auth.device_uuid,
|
||||||
|
account_id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,6 +366,7 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
authed_device = Some(pd.device_uuid);
|
authed_device = Some(pd.device_uuid);
|
||||||
|
authed_account = Some(pd.account_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(v1::client_frame::Frame::RegisterPush(reg)) if authed_address.is_some() => {
|
Some(v1::client_frame::Frame::RegisterPush(reg)) if authed_address.is_some() => {
|
||||||
@@ -377,7 +397,8 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
|
|||||||
|
|
||||||
Some(v1::client_frame::Frame::Envelope(envelope)) if authed_address.is_some() => {
|
Some(v1::client_frame::Frame::Envelope(envelope)) if authed_address.is_some() => {
|
||||||
let msg_uuid = envelope.msg_uuid.clone();
|
let msg_uuid = envelope.msg_uuid.clone();
|
||||||
match state.route_envelope(envelope).await {
|
let sender = authed_account.as_deref().unwrap_or_default();
|
||||||
|
match state.route_envelope(envelope, sender).await {
|
||||||
Ok(status) => {
|
Ok(status) => {
|
||||||
let _ = tx
|
let _ = tx
|
||||||
.send(server_frame(v1::server_frame::Frame::Receipt(
|
.send(server_frame(v1::server_frame::Frame::Receipt(
|
||||||
@@ -388,8 +409,12 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
|
|||||||
)))
|
)))
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(e) => {
|
||||||
let _ = tx.send(error_frame(400, "obálka odmítnuta")).await;
|
if let crate::state::RouteError::Internal(err) = &e {
|
||||||
|
tracing::warn!("obálka neuložena: {err}");
|
||||||
|
}
|
||||||
|
let (code, message) = e.code_and_message();
|
||||||
|
let _ = tx.send(error_frame(code, message)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user