4 Commits

Author SHA1 Message Date
Julius Joska 8780486eae fix(ios): adopt the UIScene life cycle — 1.2.3 (27)
Build 26 was the first one made with the iOS 27 SDK, and UIKit stops apps
without the scene life cycle at launch on iOS 27 (TN3187). The scene
delegate only shows the FlutterEngine that AppDelegate starts with the
process, so a VoIP push in the background still finds the CallKit plugin
and a running Dart isolate when no scene is connected.
2026-10-23 22:28:10 +02:00
Julius Joska 10ca35e352 feat(relay): optional proof-of-work for registration
GET /v1/register/challenge issues a stateless challenge (version, difficulty,
expiry and a random nonce, HMAC-signed with a per-process key). The client
finds a counter so that SHA-256(domain || challenge || identity_key ||
counter) has the requested number of leading zero bits and sends it in the
X-Sepot-Pow header of POST /v1/register. Verification is one HMAC and one
SHA-256; used challenges are kept in memory until they expire.

SEPOT_REGISTER_POW=off|optional|required (default optional): in optional
mode registrations with a valid solution get a separate, more generous
per-IP bucket (SEPOT_RL_REGISTER_POW_*), registrations without one keep the
existing limit; both share the global cap. required answers 428 without a
valid solution. Difficulty: SEPOT_REGISTER_POW_BITS (18), raised by one bit
per doubling of the registration rate over SEPOT_REGISTER_POW_ADAPT_PER_MIN
up to SEPOT_REGISTER_POW_MAX_BITS (22). server-info advertises the mode.

The paused-registration 503 now carries Retry-After.
2026-10-23 11:44:47 +02:00
Julius Joska 45f169c20d build(ios): raise the pods' deployment target to 15.0
Xcode with the iOS 27 SDK fails the archive for pods that target
anything below 15.0; the app itself already requires 15.0.
2026-10-23 11:10:21 +02:00
Julius Joska 5222c301ff 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.
2026-10-23 11:04:33 +02:00
20 changed files with 1933 additions and 359 deletions
+6
View File
@@ -8,6 +8,12 @@ verzování dle [ajtak standardu](#verzování).
## [1.2.3] – 2026-10-23 ## [1.2.3] – 2026-10-23
### Opraveno (iOS, build 27)
- **Na iOS 27 appka hned po spuštění spadla.** Build 26 vznikl novým
Xcode (SDK iOS 27) a ten u appek bez scénového životního cyklu (UIScene)
start zastaví. Appka teď scény používá; Flutter běží od startu procesu,
takže příchozí hovor na pozadí dál zazvoní i bez otevřeného okna.
### Bezpečnost ### Bezpečnost
- **24 slov jen po čerstvém ověření.** Zálohovací fráze se z odemčené appky - **24 slov jen po čerstvém ověření.** Zálohovací fráze se z odemčené appky
ukáže až po zadání PINu (nebo otisku/obličeje, je-li zapnutý). Totéž platí ukáže až po zadání PINu (nebo otisku/obličeje, je-li zapnutý). Totéž platí
+3
View File
@@ -44,6 +44,9 @@ post_install do |installer|
# staví jen pro arm64 (Apple Silicon Mac). Bez tohoto Xcode linkuje # staví jen pro arm64 (Apple Silicon Mac). Bez tohoto Xcode linkuje
# i x86_64 slice, který dylib nemá → „Undefined symbols for x86_64". # i x86_64 slice, který dylib nemá → „Undefined symbols for x86_64".
config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = 'x86_64' config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = 'x86_64'
# Xcode se SDK 27 odmítá pody s deployment targetem pod 15.0 (archive
# skončí chybou místo varování); appka sama minimum 15.0 má.
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0'
end end
end end
end end
+4
View File
@@ -13,6 +13,7 @@
AA00000000000000000000A2 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = AA00000000000000000000A1 /* PrivacyInfo.xcprivacy */; }; AA00000000000000000000A2 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = AA00000000000000000000A1 /* PrivacyInfo.xcprivacy */; };
AB10000000000000000000B1 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = AB10000000000000000000C0 /* InfoPlist.strings */; }; AB10000000000000000000B1 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = AB10000000000000000000C0 /* InfoPlist.strings */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
AA00000000000000000000B2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000000000000000000B1 /* SceneDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
@@ -49,6 +50,7 @@
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
AA00000000000000000000B1 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
@@ -126,6 +128,7 @@
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
AA00000000000000000000B1 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
); );
path = Runner; path = Runner;
@@ -285,6 +288,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
AA00000000000000000000B2 /* SceneDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
+23 -24
View File
@@ -7,6 +7,12 @@ import flutter_callkit_incoming
@main @main
@objc class AppDelegate: FlutterAppDelegate, PKPushRegistryDelegate, CallkitIncomingAppDelegate { @objc class AppDelegate: FlutterAppDelegate, PKPushRegistryDelegate, CallkitIncomingAppDelegate {
// Engine startuje s procesem, ne se scénou. VoIP push umí appku spustit na
// pozadí, aniž by iOS připojil scénu (a tím vytvořil FlutterViewController).
// CallKit plugin musí být zaregistrovaný dřív, než push dorazí, a Dart musí
// běžet, aby vyzvedl nabídku hovoru z relaye. SceneDelegate tenhle engine
// jen zobrazí.
let flutterEngine = FlutterEngine(name: "sepot")
// PushKit registry musí žít po celou dobu běhu appky, jinak VoIP pushe nechodí. // PushKit registry musí žít po celou dobu běhu appky, jinak VoIP pushe nechodí.
private var voipRegistry: PKPushRegistry? private var voipRegistry: PKPushRegistry?
private var apnsChannel: FlutterMethodChannel? private var apnsChannel: FlutterMethodChannel?
@@ -17,31 +23,24 @@ import flutter_callkit_incoming
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> Bool {
// Bez force-castu: kdyby root nebyl FlutterViewController (jiný storyboard, flutterEngine.run()
// budoucí scény), appka by spadla hned při startu. Registrar dá messenger GeneratedPluginRegistrant.register(with: flutterEngine)
// enginu i tak.
let messenger =
(window?.rootViewController as? FlutterViewController)?.binaryMessenger
?? registrar(forPlugin: "SepotApns")?.messenger()
if let messenger {
let channel = FlutterMethodChannel(
name: "cz.sepot.sepot/apns", binaryMessenger: messenger)
channel.setMethodCallHandler { [weak self] call, result in
switch call.method {
case "register":
// Nativní APNs registrace (bez Firebase) — token přijde v callbacku
application.registerForRemoteNotifications()
result(nil)
case "getToken":
result(self?.pendingToken)
default:
result(FlutterMethodNotImplemented)
}
}
apnsChannel = channel
}
GeneratedPluginRegistrant.register(with: self) let channel = FlutterMethodChannel(
name: "cz.sepot.sepot/apns", binaryMessenger: flutterEngine.binaryMessenger)
channel.setMethodCallHandler { [weak self] call, result in
switch call.method {
case "register":
// Nativní APNs registrace (bez Firebase) — token přijde v callbacku
UIApplication.shared.registerForRemoteNotifications()
result(nil)
case "getToken":
result(self?.pendingToken)
default:
result(FlutterMethodNotImplemented)
}
}
apnsChannel = channel
// Hovory: VoIP push z relaye → CallKit. // Hovory: VoIP push z relaye → CallKit.
let registry = PKPushRegistry(queue: DispatchQueue.main) let registry = PKPushRegistry(queue: DispatchQueue.main)
+19 -2
View File
@@ -48,8 +48,25 @@
<true/> <true/>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIMainStoryboardFile</key> <key>UIApplicationSceneManifest</key>
<string>Main</string> <dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
</dict>
</array>
</dict>
</dict>
<key>UISupportedInterfaceOrientations</key> <key>UISupportedInterfaceOrientations</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
+32
View File
@@ -0,0 +1,32 @@
import Flutter
import UIKit
// SDK iOS 27 bez scénového životního cyklu appku nespustí (TN3187). Scéna jen
// ukáže engine, který AppDelegate spustil při startu procesu — viz komentář
// u `AppDelegate.flutterEngine`.
class SceneDelegate: FlutterSceneDelegate {
override func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let windowScene = scene as? UIWindowScene,
let appDelegate = UIApplication.shared.delegate as? AppDelegate
else { return }
let window = UIWindow(windowScene: windowScene)
window.rootViewController = FlutterViewController(
engine: appDelegate.flutterEngine, nibName: nil, bundle: nil)
self.window = window
window.makeKeyAndVisible()
// Super podle okna scény přihlásí engine k událostem scény pro pluginy.
// Musí běžet dřív, než okno dostane i delegát aplikace: našel-li by ho
// tam, Flutter by ho bral jako starý způsob startu a přesunul do nového okna.
super.scene(scene, willConnectTo: session, options: connectionOptions)
// Pluginy, které okno hledají přes delegáta aplikace (local_auth_darwin
// u dialogu „biometrie není nastavená“), by ho jinak nenašly.
appDelegate.window = window
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: sepot name: sepot
description: "Šepot — Soukromý messenger s E2E šifrováním" description: "Šepot — Soukromý messenger s E2E šifrováním"
publish_to: 'none' publish_to: 'none'
version: 1.2.3+26 version: 1.2.3+27
environment: environment:
sdk: ^3.10.8 sdk: ^3.10.8
+16 -132
View File
@@ -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",
@@ -2459,11 +2412,11 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sha1", "sha1",
"sha2",
"sqlx", "sqlx",
"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 +2556,7 @@ dependencies = [
"sha1", "sha1",
"sha2", "sha2",
"subtle", "subtle",
"thiserror 2.0.18", "thiserror",
"zeroize", "zeroize",
] ]
@@ -2635,7 +2588,7 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
dependencies = [ dependencies = [
"num-bigint", "num-bigint",
"num-traits", "num-traits",
"thiserror 2.0.18", "thiserror",
"time", "time",
] ]
@@ -2718,7 +2671,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 +2716,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 +2800,7 @@ dependencies = [
"smallvec", "smallvec",
"sqlx-core", "sqlx-core",
"stringprep", "stringprep",
"thiserror 2.0.18", "thiserror",
"tracing", "tracing",
"whoami", "whoami",
] ]
@@ -2885,7 +2838,7 @@ dependencies = [
"smallvec", "smallvec",
"sqlx-core", "sqlx-core",
"stringprep", "stringprep",
"thiserror 2.0.18", "thiserror",
"tracing", "tracing",
"whoami", "whoami",
] ]
@@ -2910,7 +2863,7 @@ dependencies = [
"serde", "serde",
"serde_urlencoded", "serde_urlencoded",
"sqlx-core", "sqlx-core",
"thiserror 2.0.18", "thiserror",
"tracing", "tracing",
"url", "url",
] ]
@@ -2982,33 +2935,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 +3123,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 +3131,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 +3186,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 +3267,7 @@ dependencies = [
"log", "log",
"rand 0.9.4", "rand 0.9.4",
"sha1", "sha1",
"thiserror 2.0.18", "thiserror",
] ]
[[package]] [[package]]
+7 -3
View File
@@ -29,6 +29,8 @@ serde_json = "1"
base64 = "0.22" base64 = "0.22"
# TURN údaje pro hovory: coturn ověřuje HMAC-SHA1 (REST schéma use-auth-secret) # TURN údaje pro hovory: coturn ověřuje HMAC-SHA1 (REST schéma use-auth-secret)
hmac = "0.12" hmac = "0.12"
# Proof-of-work registrace (pow.rs); už v grafu závislostí přes libsignal
sha2 = "0.10"
sha1 = "0.10" sha1 = "0.10"
hex = "0.4" hex = "0.4"
chrono = "0.4" chrono = "0.4"
@@ -42,13 +44,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
+22
View File
@@ -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()
);
}
} }
+77 -16
View File
@@ -111,23 +111,85 @@ pub async fn upload(
headers: HeaderMap, headers: HeaderMap,
body: Body, body: Body,
) -> ApiResult<(StatusCode, Json<UploadOut>)> { ) -> ApiResult<(StatusCode, Json<UploadOut>)> {
crate::rest::authenticate_device(&state, &headers, "POST /v1/attachments").await?; let authed =
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) {
return Err((
StatusCode::PAYLOAD_TOO_LARGE,
"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)
{ {
if len > cfg.max_bytes { return Err((
return Err(( StatusCode::INSUFFICIENT_STORAGE,
StatusCode::PAYLOAD_TOO_LARGE, "úložiště příloh je plné, zkus to později".into(),
"příloha je příliš velká".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
@@ -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, blob_id: blob_hex,
Json(UploadOut { expires_at_ms: expires_at.timestamp_millis(),
blob_id: blob_hex, max_bytes: cfg.max_bytes,
expires_at_ms: expires_at.timestamp_millis(), size_bytes: written,
max_bytes: cfg.max_bytes, })
}),
))
} }
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(),
) )
} }
+113
View File
@@ -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));
}
}
+402
View File
@@ -0,0 +1,402 @@
//! 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_POW_*` na IP pro registrace s platným proof-of-work
/// (pow.rs): 200 naráz, pak 1 za 2 s (1 800/h). Výpočet zdražuje každou
/// registraci, takže velký CGNAT může dostat víc bez rizika levné záplavy
/// z jedné adresy. Globální strop platí společně s registracemi bez PoW.
pub register_pow: RatePair,
/// `SEPOT_RL_POW_CHALLENGE_*`: vydávání výzev (jen HMAC, nic do DB).
pub pow_challenge: 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_pow: pair(
"SEPOT_RL_REGISTER_POW",
Rate::new(200, 2_000),
Rate::new(300, 1_000),
),
pow_challenge: pair(
"SEPOT_RL_POW_CHALLENGE",
Rate::new(120, 100),
Rate::new(600, 20),
),
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<Arc<DefaultDirectRateLimiter>>,
trusted: Arc<TrustedProxies>,
}
/// Registrace bez a s proof-of-work: vlastní buckety per IP, společný
/// globální strop (chrání DB bez ohledu na to, kdo PoW přinesl).
pub struct RegisterLimits {
pub base: Arc<RateLimit>,
pub pow: Arc<RateLimit>,
}
impl RegisterLimits {
pub fn new(limits: &Limits, trusted: Arc<TrustedProxies>) -> Arc<Self> {
let base = RateLimit::new(limits.register, limits.register_global, trusted);
let pow = base.sibling(limits.register_pow);
Arc::new(Self { base, pow })
}
}
impl RateLimit {
pub fn new(rates: RatePair, global: Option<Rate>, trusted: Arc<TrustedProxies>) -> Arc<Self> {
let global = global.map(|g| Arc::new(DefaultDirectRateLimiter::direct(g.quota())));
Self::build(rates, global, trusted)
}
/// Limiter s vlastními buckety per IP a stejným globálním stropem.
pub fn sibling(&self, rates: RatePair) -> Arc<Self> {
Self::build(rates, self.global.clone(), self.trusted.clone())
}
fn build(
rates: RatePair,
global: Option<Arc<DefaultDirectRateLimiter>>,
trusted: Arc<TrustedProxies>,
) -> Arc<Self> {
let limiter = Arc::new(Self {
clearnet: DefaultKeyedRateLimiter::keyed(rates.clearnet.quota()),
tor: DefaultDirectRateLimiter::direct(rates.tor.quota()),
global,
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),
}
}
pub 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());
}
#[tokio::test]
async fn sourozenec_sdili_globalni_strop_ne_ip_bucket() {
let base_rates = RatePair {
clearnet: Rate::new(1, 3_600_000),
tor: Rate::new(1, 3_600_000),
};
let pow_rates = RatePair {
clearnet: Rate::new(5, 3_600_000),
tor: Rate::new(5, 3_600_000),
};
let base = RateLimit::new(base_rates, Some(Rate::new(3, 3_600_000)), trusted("10.0.0.1", ""));
let pow = base.sibling(pow_rates);
let proxy: IpAddr = "10.0.0.1".parse().unwrap();
assert!(base.check(proxy, &xff("198.51.100.1")).is_ok());
assert!(base.check(proxy, &xff("198.51.100.1")).is_err(), "IP bucket bez PoW vyčerpán");
assert!(pow.check(proxy, &xff("198.51.100.1")).is_ok(), "s PoW vlastní bucket");
assert!(pow.check(proxy, &xff("198.51.100.1")).is_ok());
assert!(pow.check(proxy, &xff("198.51.100.1")).is_err(), "globální strop 3 společný");
}
#[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));
}
}
+40 -59
View File
@@ -8,9 +8,13 @@
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 pow;
mod push; mod push;
mod quota;
mod rest; mod rest;
mod state; mod state;
mod turn; mod turn;
@@ -22,14 +26,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, RegisterLimits};
use crate::net::TrustedProxies;
use tower_http::timeout::TimeoutLayer; use tower_http::timeout::TimeoutLayer;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
@@ -49,8 +53,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 +71,27 @@ 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");
tracing::info!(pow = ?state.pow.cfg, "proof-of-work registrace");
// ── 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.
// Registrace: bucket per IP volí handler podle proof-of-work (rest.rs),
// registrace: zápis do DB bez auth → nejpřísnější (burst 10, +1/5 min) // proto limiter jako Extension, ne middleware.
let gov_register = Arc::new( let rl_register = RegisterLimits::new(&limits, trusted.clone());
GovernorConfigBuilder::default() let rl_challenge = RateLimit::new(limits.pow_challenge, None, trusted.clone());
.key_extractor(TrustedProxyIp(trusted.clone())) let rl_general = RateLimit::new(limits.general, None, trusted.clone());
.burst_size(10) let rl_attachments = RateLimit::new(limits.attachments, None, trusted.clone());
.period(Duration::from_secs(300)) let rl_ws = RateLimit::new(limits.ws_upgrade, None, trusted.clone());
.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 +107,12 @@ 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(axum::Extension(rl_register))
.merge(
Router::new()
.route("/v1/register/challenge", get(rest::register_challenge))
.layer(from_fn_with_state(rl_challenge, 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 +121,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 +136,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 +146,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))
+54 -50
View File
@@ -14,19 +14,34 @@
//! 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>,
}
fn parse_nets(raw: &str) -> Vec<IpNet> {
raw.split(',')
.filter_map(|s| {
let s = s.trim();
if s.is_empty() {
return None;
}
// "10.0.0.0/24" i holé "10.0.0.5" (bereme jako /32, resp. /128)
s.parse::<IpNet>()
.ok()
.or_else(|| s.parse::<IpAddr>().ok().map(IpNet::from))
})
.collect()
} }
impl TrustedProxies { impl TrustedProxies {
@@ -34,31 +49,37 @@ impl TrustedProxies {
/// nikomu (vždy peer IP) — fail-safe: chybějící konfigurace nesmí otevřít /// nikomu (vždy peer IP) — fail-safe: chybějící konfigurace nesmí otevřít
/// spoofing, maximálně sloučí clearnet klienty do jednoho bucketu. /// spoofing, maximálně sloučí clearnet klienty do jednoho bucketu.
pub fn from_env() -> Self { pub fn from_env() -> Self {
let raw = std::env::var("SEPOT_TRUSTED_PROXIES").unwrap_or_default(); let proxies = Self::parse(
let nets: Vec<IpNet> = raw &std::env::var("SEPOT_TRUSTED_PROXIES").unwrap_or_default(),
.split(',') &std::env::var("SEPOT_TOR_PEERS").unwrap_or_default(),
.filter_map(|s| { );
let s = s.trim(); if proxies.nets.is_empty() {
if s.is_empty() {
return None;
}
// "10.0.0.0/24" i holé "10.0.0.5" (bereme jako /32, resp. /128)
s.parse::<IpNet>()
.ok()
.or_else(|| s.parse::<IpAddr>().ok().map(IpNet::from))
})
.collect();
if 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);
}
} }
+482
View File
@@ -0,0 +1,482 @@
//! Proof-of-work při registraci (`GET /v1/register/challenge`).
//!
//! Proč: registrace je jediný anonymní zápis do DB. Limit per IP nejde
//! utáhnout (za CGNAT mobilního operátora sdílí jednu IPv4 stovky lidí)
//! a proti záplavě z mnoha adres stojí jen globální strop, který zablokuje
//! i skutečné lidi. PoW zdraží každou registraci výpočtem na straně klienta;
//! kdo ho přinese, dostane volnější limit per IP.
//!
//! Úloha (hashcash): `SHA-256("sepot-register-pow-v1" ‖ výzva ‖ identity_key
//! ‖ counter_be64)` má aspoň `bits` úvodních nulových bitů. Ověření = jedna
//! HMAC a jedna SHA-256 — levné i pod náporem, proto ne Argon2 (paměťově
//! náročné ověření by relay pod záplavou sám přetížilo).
//!
//! Výzva je bezstavová: verze, obtížnost, expirace a náhodná nonce podepsané
//! HMAC klíčem, který vzniká náhodně při startu procesu. Nic se neukládá do DB
//! ani do `.env`; restart relaye zneplatní rozdané výzvy (klient si vyžádá
//! novou) a tím i znovupoužití řešení přes restart. Jednorázovost v rámci
//! běhu hlídá paměťová sada spotřebovaných nonce, mazaná po expiraci.
//!
//! Stejné testovací vektory má klient (app/lib/core/network/register_pow.dart).
use std::sync::Mutex;
use std::time::{Duration, Instant};
use axum::http::HeaderMap;
use base64::prelude::*;
use dashmap::DashMap;
use hmac::{Hmac, Mac};
use rand::RngCore;
use sha2::{Digest, Sha256};
const DOMAIN: &[u8] = b"sepot-register-pow-v1";
const VERSION: u8 = 1;
const NONCE_LEN: usize = 16;
const TAG_LEN: usize = 16;
/// verze(1) ‖ bits(1) ‖ expirace unix s (8, BE) ‖ nonce(16) ‖ HMAC(16)
pub const TOKEN_LEN: usize = 2 + 8 + NONCE_LEN + TAG_LEN;
const SIGNED_LEN: usize = TOKEN_LEN - TAG_LEN;
/// Strop obtížnosti: 2^32 pokusů je na telefonu přes hodinu — víc nemá smysl
/// nastavovat ani adaptivně.
const MAX_BITS: u8 = 32;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mode {
/// Relay výzvy nevydává (`/v1/register/challenge` = 404), registruje jako dřív.
Off,
/// Výzvy vydává, registrace bez PoW projde s běžným limitem per IP,
/// s platným PoW s volnějším. Výchozí — appky v obchodech PoW neumí.
Optional,
/// Registrace bez platného PoW = 428. Nouzová páka; staré appky se
/// nezaregistrují.
Required,
}
impl Mode {
pub fn as_str(self) -> &'static str {
match self {
Mode::Off => "off",
Mode::Optional => "optional",
Mode::Required => "required",
}
}
fn parse(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"off" | "0" => Some(Mode::Off),
"optional" => Some(Mode::Optional),
"required" | "1" => Some(Mode::Required),
_ => None,
}
}
}
#[derive(Clone, Debug)]
pub struct Config {
/// `SEPOT_REGISTER_POW` (off | optional | required), výchozí optional.
pub mode: Mode,
/// `SEPOT_REGISTER_POW_BITS`: základní obtížnost (výchozí 18 ≈ 0,3 s na
/// x86 jádře, ~1 s na středním Androidu — měření v README relaye).
pub bits: u8,
/// `SEPOT_REGISTER_POW_MAX_BITS`: strop adaptivní obtížnosti (výchozí 22).
pub max_bits: u8,
/// `SEPOT_REGISTER_POW_ADAPT_PER_MIN`: od kolika registrací za minutu
/// (všech, s PoW i bez) přidávat obtížnost — +1 bit za každé zdvojnásobení
/// nad prahem. 0 = obtížnost pevná (výchozí 600 = 10/s, ~10× nad odhadem
/// běžné špičky; při útoku se zdraží registrace jen těm, kdo PoW posílají,
/// ostatní drží limity per IP a globální strop).
pub adapt_per_min: u32,
/// `SEPOT_REGISTER_POW_TTL_S`: platnost výzvy (výchozí 120 s; pomalý telefon
/// při 20 bitech potřebuje v 99 % případů pod 60 s).
pub ttl: Duration,
}
impl Config {
pub fn from_env() -> Self {
let mode = match std::env::var("SEPOT_REGISTER_POW") {
Ok(raw) => Mode::parse(&raw).unwrap_or_else(|| {
tracing::warn!("SEPOT_REGISTER_POW neplatná hodnota, platí optional");
Mode::Optional
}),
Err(_) => Mode::Optional,
};
let bits = env_u64("SEPOT_REGISTER_POW_BITS", 18).clamp(1, MAX_BITS as u64) as u8;
let max_bits =
env_u64("SEPOT_REGISTER_POW_MAX_BITS", 22).clamp(bits as u64, MAX_BITS as u64) as u8;
Self {
mode,
bits,
max_bits,
adapt_per_min: env_u64("SEPOT_REGISTER_POW_ADAPT_PER_MIN", 600) as u32,
ttl: Duration::from_secs(env_u64("SEPOT_REGISTER_POW_TTL_S", 120).clamp(10, 3600)),
}
}
}
fn env_u64(name: &str, default: u64) -> u64 {
match std::env::var(name) {
Ok(raw) => raw.trim().parse().unwrap_or_else(|_| {
tracing::warn!(name, "neplatná hodnota, platí výchozí");
default
}),
Err(_) => default,
}
}
/// Výsledek kontroly hlavičky `X-Sepot-Pow`.
#[derive(Debug, PartialEq, Eq)]
pub enum Verdict {
Valid,
Missing,
Invalid(&'static str),
}
/// Výzva pro klienta (`GET /v1/register/challenge`).
pub struct Challenge {
pub token: [u8; TOKEN_LEN],
pub bits: u8,
pub expires_at: u64,
}
pub struct RegisterPow {
pub cfg: Config,
key: [u8; 32],
/// nonce → expirace (unix s). Jen spotřebované výzvy; velikost drží
/// globální strop registrací × TTL.
used: DashMap<[u8; NONCE_LEN], u64>,
/// (začátek aktuální minuty, registrace v ní, registrace v minulé minutě)
load: Mutex<(Instant, u32, u32)>,
}
impl RegisterPow {
pub fn new(cfg: Config) -> Self {
let mut key = [0u8; 32];
rand::rng().fill_bytes(&mut key);
Self {
cfg,
key,
used: DashMap::new(),
load: Mutex::new((Instant::now(), 0, 0)),
}
}
pub fn mode(&self) -> Mode {
self.cfg.mode
}
fn mac(&self, signed: &[u8]) -> Hmac<Sha256> {
let mut mac = Hmac::<Sha256>::new_from_slice(&self.key).expect("HMAC bere libovolný klíč");
mac.update(signed);
mac
}
pub fn issue(&self, now_unix: u64) -> Challenge {
let bits = self.current_bits();
let expires_at = now_unix + self.cfg.ttl.as_secs();
let mut token = [0u8; TOKEN_LEN];
token[0] = VERSION;
token[1] = bits;
token[2..10].copy_from_slice(&expires_at.to_be_bytes());
rand::rng().fill_bytes(&mut token[10..10 + NONCE_LEN]);
let tag = self.mac(&token[..SIGNED_LEN]).finalize().into_bytes();
token[SIGNED_LEN..].copy_from_slice(&tag[..TAG_LEN]);
Challenge {
token,
bits,
expires_at,
}
}
/// Ověří `X-Sepot-Pow: <výzva base64url>.<counter>` vůči identity klíči
/// registrace. Platné řešení se tím spotřebuje (druhé použití = Invalid).
pub fn verify(&self, headers: &HeaderMap, identity_key: &[u8], now_unix: u64) -> Verdict {
let Some(raw) = headers.get("x-sepot-pow").and_then(|v| v.to_str().ok()) else {
return Verdict::Missing;
};
let Some((token_b64, counter_str)) = raw.trim().split_once('.') else {
return Verdict::Invalid("formát");
};
let Ok(token) = BASE64_URL_SAFE_NO_PAD.decode(token_b64) else {
return Verdict::Invalid("formát");
};
let Ok(counter) = counter_str.parse::<u64>() else {
return Verdict::Invalid("formát");
};
if token.len() != TOKEN_LEN || token[0] != VERSION {
return Verdict::Invalid("formát");
}
if self
.mac(&token[..SIGNED_LEN])
.verify_truncated_left(&token[SIGNED_LEN..])
.is_err()
{
return Verdict::Invalid("podpis");
}
let expires_at = u64::from_be_bytes(token[2..10].try_into().expect("8 B"));
if now_unix > expires_at {
return Verdict::Invalid("prošlá");
}
if leading_zero_bits(&digest(&token, identity_key, counter)) < token[1] as u32 {
return Verdict::Invalid("řešení");
}
let nonce: [u8; NONCE_LEN] = token[10..10 + NONCE_LEN].try_into().expect("16 B");
// entry = atomické vložení: dva souběžné požadavky se stejným řešením
// neprojdou oba.
match self.used.entry(nonce) {
dashmap::Entry::Occupied(_) => Verdict::Invalid("použitá"),
dashmap::Entry::Vacant(slot) => {
slot.insert(expires_at);
Verdict::Valid
}
}
}
/// Započítá registraci do zátěže pro adaptivní obtížnost.
pub fn note_registration(&self) {
let mut load = self.load.lock().expect("zámek zátěže");
roll_window(&mut load, Instant::now());
load.1 = load.1.saturating_add(1);
}
/// Obtížnost pro novou výzvu: základ + 1 bit za každé zdvojnásobení
/// zátěže nad prahem (větší z aktuální a minulé minuty, ať obtížnost
/// nespadne hned na začátku nové minuty).
pub fn current_bits(&self) -> u8 {
if self.cfg.adapt_per_min == 0 {
return self.cfg.bits;
}
let per_min = {
let mut load = self.load.lock().expect("zámek zátěže");
roll_window(&mut load, Instant::now());
load.1.max(load.2)
};
self.cfg
.bits
.saturating_add(extra_bits(per_min, self.cfg.adapt_per_min))
.min(self.cfg.max_bits)
}
/// Zapomene spotřebované výzvy po expiraci (periodická úloha).
pub fn prune(&self, now_unix: u64) {
self.used.retain(|_, expires_at| *expires_at >= now_unix);
}
}
fn roll_window(load: &mut (Instant, u32, u32), now: Instant) {
const MINUTE: Duration = Duration::from_secs(60);
let elapsed = now.duration_since(load.0);
if elapsed >= MINUTE {
// Víc než dvě minuty bez registrace = minulá minuta byla prázdná.
load.2 = if elapsed >= 2 * MINUTE { 0 } else { load.1 };
load.1 = 0;
load.0 = now;
}
}
fn extra_bits(per_min: u32, threshold: u32) -> u8 {
if per_min < threshold {
return 0;
}
// per_min ≥ práh → +1, ≥ 2× práh → +2, ≥ 4× → +3 …
let ratio = per_min / threshold;
(32 - ratio.leading_zeros()) as u8
}
pub fn digest(token: &[u8], identity_key: &[u8], counter: u64) -> [u8; 32] {
let mut h = Sha256::new();
h.update(DOMAIN);
h.update(token);
h.update(identity_key);
h.update(counter.to_be_bytes());
h.finalize().into()
}
pub fn leading_zero_bits(digest: &[u8; 32]) -> u32 {
let mut bits = 0;
for byte in digest {
if *byte == 0 {
bits += 8;
} else {
return bits + byte.leading_zeros();
}
}
bits
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderValue;
fn cfg(mode: Mode, bits: u8) -> Config {
Config {
mode,
bits,
max_bits: 22,
adapt_per_min: 0,
ttl: Duration::from_secs(120),
}
}
fn solve(token: &[u8], identity_key: &[u8], bits: u8) -> u64 {
(0u64..)
.find(|c| leading_zero_bits(&digest(token, identity_key, *c)) >= bits as u32)
.unwrap()
}
fn header(token: &[u8], counter: u64) -> HeaderMap {
let mut h = HeaderMap::new();
let value = format!("{}.{counter}", BASE64_URL_SAFE_NO_PAD.encode(token));
h.insert("x-sepot-pow", HeaderValue::from_str(&value).unwrap());
h
}
const NOW: u64 = 1_800_000_000;
const IK: [u8; 33] = [5u8; 33];
#[test]
fn platne_reseni_projde_jednou() {
let pow = RegisterPow::new(cfg(Mode::Optional, 8));
let ch = pow.issue(NOW);
assert_eq!(ch.expires_at, NOW + 120);
let counter = solve(&ch.token, &IK, ch.bits);
assert_eq!(
pow.verify(&header(&ch.token, counter), &IK, NOW),
Verdict::Valid
);
assert_eq!(
pow.verify(&header(&ch.token, counter), &IK, NOW),
Verdict::Invalid("použitá")
);
}
#[test]
fn chybejici_a_poskozena_hlavicka() {
let pow = RegisterPow::new(cfg(Mode::Optional, 8));
assert_eq!(pow.verify(&HeaderMap::new(), &IK, NOW), Verdict::Missing);
let mut h = HeaderMap::new();
h.insert("x-sepot-pow", HeaderValue::from_static("bez-tecky"));
assert_eq!(pow.verify(&h, &IK, NOW), Verdict::Invalid("formát"));
h.insert("x-sepot-pow", HeaderValue::from_static("AAAA.12"));
assert_eq!(pow.verify(&h, &IK, NOW), Verdict::Invalid("formát"));
}
#[test]
fn spatne_reseni_a_jiny_identity_klic() {
let pow = RegisterPow::new(cfg(Mode::Optional, 12));
let ch = pow.issue(NOW);
let counter = solve(&ch.token, &IK, ch.bits);
let bad = (0u64..)
.find(|c| leading_zero_bits(&digest(&ch.token, &IK, *c)) < 12)
.unwrap();
assert_eq!(
pow.verify(&header(&ch.token, bad), &IK, NOW),
Verdict::Invalid("řešení")
);
// řešení je vázané na identitu — pro jiný účet neplatí (bez spotřeby)
let other = [6u8; 33];
if leading_zero_bits(&digest(&ch.token, &other, counter)) < 12 {
assert_eq!(
pow.verify(&header(&ch.token, counter), &other, NOW),
Verdict::Invalid("řešení")
);
}
assert_eq!(
pow.verify(&header(&ch.token, counter), &IK, NOW),
Verdict::Valid
);
}
#[test]
fn prosla_vyzva() {
let pow = RegisterPow::new(cfg(Mode::Optional, 8));
let ch = pow.issue(NOW);
let counter = solve(&ch.token, &IK, ch.bits);
assert_eq!(
pow.verify(&header(&ch.token, counter), &IK, NOW + 121),
Verdict::Invalid("prošlá")
);
}
#[test]
fn podvrzena_obtiznost_nebo_cizi_klic() {
let pow = RegisterPow::new(cfg(Mode::Optional, 16));
let mut ch = pow.issue(NOW);
// klient si sníží obtížnost v tokenu → HMAC nesedí
ch.token[1] = 1;
assert_eq!(
pow.verify(&header(&ch.token, 0), &IK, NOW),
Verdict::Invalid("podpis")
);
// výzva jiného procesu relaye (jiný klíč, např. před restartem)
let jiny = RegisterPow::new(cfg(Mode::Optional, 8));
let ch = jiny.issue(NOW);
let counter = solve(&ch.token, &IK, ch.bits);
assert_eq!(
pow.verify(&header(&ch.token, counter), &IK, NOW),
Verdict::Invalid("podpis")
);
}
#[test]
fn prune_maze_jen_prosle() {
let pow = RegisterPow::new(cfg(Mode::Optional, 4));
let ch = pow.issue(NOW);
let counter = solve(&ch.token, &IK, ch.bits);
assert_eq!(
pow.verify(&header(&ch.token, counter), &IK, NOW),
Verdict::Valid
);
pow.prune(NOW);
assert_eq!(pow.used.len(), 1);
pow.prune(NOW + 121);
assert_eq!(pow.used.len(), 0);
}
#[test]
fn adaptivni_obtiznost() {
assert_eq!(extra_bits(0, 600), 0);
assert_eq!(extra_bits(599, 600), 0);
assert_eq!(extra_bits(600, 600), 1);
assert_eq!(extra_bits(1200, 600), 2);
assert_eq!(extra_bits(2399, 600), 2);
assert_eq!(extra_bits(2400, 600), 3);
let pow = RegisterPow::new(Config {
adapt_per_min: 2,
..cfg(Mode::Optional, 18)
});
assert_eq!(pow.current_bits(), 18);
for _ in 0..4 {
pow.note_registration();
}
assert_eq!(pow.current_bits(), 20);
for _ in 0..1000 {
pow.note_registration();
}
assert_eq!(pow.current_bits(), 22, "strop max_bits");
}
#[test]
fn rezim_z_env_hodnoty() {
assert_eq!(Mode::parse("OFF"), Some(Mode::Off));
assert_eq!(Mode::parse(" optional "), Some(Mode::Optional));
assert_eq!(Mode::parse("required"), Some(Mode::Required));
assert_eq!(Mode::parse("ano"), None);
}
/// Sdílené vektory s klientem (app/test/network/register_pow_test.dart):
/// pevný token (42 B = 0..41), identity klíč 33 × 0x05.
#[test]
fn vektory_kompatibilni_s_klientem() {
let token: Vec<u8> = (0u8..42).collect();
assert_eq!(
hex::encode(digest(&token, &IK, 0)),
"ab9d9008c6ad674271da019b142f25cba7045107da67ad0938fd90a3d096d90a"
);
assert_eq!(
hex::encode(digest(&token, &IK, 0x0123_4567_89ab_cdef)),
"3fc46d7b70b5c23fef86d979503e03a723929f31e52d2c49e84349e07cfe7d46"
);
assert_eq!(solve(&token, &IK, 16), 145_733);
}
}
+194
View File
@@ -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));
}
}
+107 -11
View File
@@ -1,16 +1,20 @@
//! REST endpointy — registrace, prekey bundle, sender cert. //! REST endpointy — registrace, prekey bundle, sender cert.
//! Chybové odpovědi nikdy neleakují interní detail; logy bez identifikátorů. //! Chybové odpovědi nikdy neleakují interní detail; logy bez identifikátorů.
use std::net::SocketAddr;
use std::sync::Arc; use std::sync::Arc;
use axum::extract::{Path, State}; use axum::extract::{ConnectInfo, Path, State};
use axum::http::{HeaderMap, StatusCode}; use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::Json; use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
use base64::prelude::*; use base64::prelude::*;
use libsignal_protocol::PublicKey; use libsignal_protocol::PublicKey;
use rand::RngCore; use rand::RngCore;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::limits::RegisterLimits;
use crate::pow::{Mode as PowMode, Verdict};
use crate::state::AppState; use crate::state::AppState;
type ApiResult<T> = Result<T, (StatusCode, String)>; type ApiResult<T> = Result<T, (StatusCode, String)>;
@@ -57,15 +61,50 @@ pub async fn ready(State(state): State<Arc<AppState>>) -> ApiResult<Json<serde_j
/// Veřejné informace o relayi. `source` + `revision` plní AGPL §13 — kdo /// Veřejné informace o relayi. `source` + `revision` plní AGPL §13 — kdo
/// s relayem mluví, má vědět, který zdroj právě běží. /// s relayem mluví, má vědět, který zdroj právě běží.
pub async fn server_info(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> { pub async fn server_info(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> {
let mut capabilities = vec!["pqxdh", "sealed-sender", "prekey-upload", "identity-reclaim"];
if state.pow.mode() != PowMode::Off {
capabilities.push("register-pow");
}
Json(serde_json::json!({ Json(serde_json::json!({
"frame_version": 1, "frame_version": 1,
"trust_root": BASE64_STANDARD.encode(&state.certs.trust_root_public), "trust_root": BASE64_STANDARD.encode(&state.certs.trust_root_public),
"capabilities": ["pqxdh", "sealed-sender", "prekey-upload", "identity-reclaim"], "capabilities": capabilities,
"register_pow": state.pow.mode().as_str(),
"source": state.source_url, "source": state.source_url,
"revision": state.revision, "revision": state.revision,
})) }))
} }
fn unix_now() -> u64 {
chrono::Utc::now().timestamp().max(0) as u64
}
// ── GET /v1/register/challenge ─────────────────────────────────────────
/// Výzva proof-of-work pro registraci (pow.rs). `no-store`: každá výzva je
/// jednorázová, sdílená cache by ji rozdala víc klientům.
pub async fn register_challenge(State(state): State<Arc<AppState>>) -> Response {
if state.pow.mode() == PowMode::Off {
return (StatusCode::NOT_FOUND, "proof-of-work je vypnutý").into_response();
}
let now = unix_now();
let challenge = state.pow.issue(now);
let mut response = Json(serde_json::json!({
"algorithm": "sha256-v1",
"challenge": BASE64_URL_SAFE_NO_PAD.encode(challenge.token),
"bits": challenge.bits,
"expires_at": challenge.expires_at,
// relativně: hodiny telefonu bývají mimo, platnost si klient měří sám
"expires_in": challenge.expires_at - now,
"mode": state.pow.mode().as_str(),
}))
.into_response();
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
response
}
/// Ověří hlavičku `X-Sepot-Identity-Proof: <ts_ms>.<sig_b64>` — podpis /// Ověří hlavičku `X-Sepot-Identity-Proof: <ts_ms>.<sig_b64>` — podpis
/// identity klíčem nad `"<method_path>\n<ts_ms>"`, stejné okno jako /// identity klíčem nad `"<method_path>\n<ts_ms>"`, stejné okno jako
/// X-Sepot-Auth. Důkaz držení privátního klíče bez znalosti device_uuid: /// X-Sepot-Auth. Důkaz držení privátního klíče bez znalosti device_uuid:
@@ -127,10 +166,70 @@ pub struct RegisterOut {
/// Strop OTP klíčů v jedné registraci (klient posílá 100). /// Strop OTP klíčů v jedné registraci (klient posílá 100).
const MAX_ONE_TIME_PREKEYS: usize = 100; const MAX_ONE_TIME_PREKEYS: usize = 100;
/// Limit registrací se vybírá až v handleru: bucket per IP závisí na tom,
/// jestli klient přinesl platný proof-of-work, a ten je vázaný na identity
/// klíč z těla. Ověření PoW je jedna HMAC a jedna SHA-256, takže pořadí
/// „parsovat → ověřit → limit → podpisy a DB“ nic drahého nepouští dřív.
pub async fn register( pub async fn register(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Extension(limits): Extension<Arc<RegisterLimits>>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap, headers: HeaderMap,
Json(body): Json<RegisterIn>, Json(body): Json<RegisterIn>,
) -> Response {
if state.limits.registration_paused {
return (
StatusCode::SERVICE_UNAVAILABLE,
[(header::RETRY_AFTER, "300")],
"registrace jsou dočasně pozastavené, zkus to za pár minut",
)
.into_response();
}
let identity_key = match b64("identity_key", &body.identity_key, Some(33)) {
Ok(key) => key,
Err(e) => return e.into_response(),
};
let with_pow = match (state.pow.mode(), state.pow.verify(&headers, &identity_key, unix_now())) {
(PowMode::Off, _) => false,
(_, Verdict::Valid) => true,
(PowMode::Optional, Verdict::Missing) => false,
(PowMode::Optional, Verdict::Invalid(why)) => {
// Prošlá nebo poškozená výzva v režimu optional = registrace jako
// bez PoW (běžný limit), ne chyba: starší výzva po restartu relaye
// nesmí člověka zablokovat.
tracing::debug!(why, "neplatný proof-of-work, běžný limit");
false
}
(PowMode::Required, verdict) => {
if let Verdict::Invalid(why) = verdict {
tracing::debug!(why, "neplatný proof-of-work");
}
return (
StatusCode::PRECONDITION_REQUIRED,
"registrace vyžaduje proof-of-work (GET /v1/register/challenge)",
)
.into_response();
}
};
let limiter = if with_pow { &limits.pow } else { &limits.base };
if let Err(wait) = limiter.check(peer.ip(), &headers) {
return crate::limits::too_many(wait);
}
match register_device(&state, &headers, identity_key, body).await {
Ok(created) => {
state.pow.note_registration();
tracing::info!(pow = with_pow, "nová registrace zařízení");
created.into_response()
}
Err(e) => e.into_response(),
}
}
async fn register_device(
state: &AppState,
headers: &HeaderMap,
identity_key: Vec<u8>,
body: RegisterIn,
) -> ApiResult<(StatusCode, Json<RegisterOut>)> { ) -> ApiResult<(StatusCode, Json<RegisterOut>)> {
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"));
@@ -140,7 +239,6 @@ pub async fn register(
if !(1..=127).contains(&body.device_id) || !(1..=16_383).contains(&body.registration_id) { if !(1..=127).contains(&body.device_id) || !(1..=16_383).contains(&body.registration_id) {
return Err(bad("device_id nebo registration_id mimo rozsah")); return Err(bad("device_id nebo registration_id mimo rozsah"));
} }
let identity_key = b64("identity_key", &body.identity_key, Some(33))?;
let delivery_address = b64("delivery_address", &body.delivery_address, Some(32))?; let delivery_address = b64("delivery_address", &body.delivery_address, Some(32))?;
let spk_pub = b64("signed_prekey.public_key", &body.signed_prekey.public_key, Some(33))?; let spk_pub = b64("signed_prekey.public_key", &body.signed_prekey.public_key, Some(33))?;
let spk_sig = b64( let spk_sig = b64(
@@ -191,7 +289,7 @@ pub async fn register(
// prekeys, push tokeny i nedoručená fronta pryč (nové zařízení staré // prekeys, push tokeny i nedoručená fronta pryč (nové zařízení staré
// ratchet relace stejně nemá), account_id zůstává — kontakty ho znají. // ratchet relace stejně nemá), account_id zůstává — kontakty ho znají.
let account_id = if inserted.rows_affected() == 0 { let account_id = if inserted.rows_affected() == 0 {
if !identity_proof_valid(&headers, &identity, "POST /v1/register") { if !identity_proof_valid(headers, &identity, "POST /v1/register") {
return Err((StatusCode::CONFLICT, "identita už existuje".into())); return Err((StatusCode::CONFLICT, "identita už existuje".into()));
} }
let (existing,): (Vec<u8>,) = let (existing,): (Vec<u8>,) =
@@ -271,7 +369,6 @@ pub async fn register(
} }
tx.commit().await.map_err(internal)?; tx.commit().await.map_err(internal)?;
tracing::info!("nová registrace zařízení");
Ok(( Ok((
StatusCode::CREATED, StatusCode::CREATED,
@@ -489,9 +586,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 +689,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))?;
+295 -50
View File
@@ -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,12 +186,21 @@ 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.
pub otp_issued: DashMap<Vec<u8>, (Instant, u32)>, pub otp_issued: DashMap<Vec<u8>, (Instant, u32)>,
/// Proof-of-work registrace: klíč výzev, spotřebované výzvy, zátěž (paměť).
pub pow: crate::pow::RegisterPow,
/// Odkaz na zdroj a revize běžící binárky (AGPL §13, /v1/server-info). /// Odkaz na zdroj a revize běžící binárky (AGPL §13, /v1/server-info).
pub source_url: String, pub source_url: String,
pub revision: String, pub revision: String,
@@ -174,6 +212,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,12 +226,25 @@ 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(),
pow: crate::pow::RegisterPow::new(crate::pow::Config::from_env()),
source_url: std::env::var("SEPOT_SOURCE_URL") source_url: std::env::var("SEPOT_SOURCE_URL")
.unwrap_or_else(|_| "https://github.com/juliusjoska/sepot".into()), .unwrap_or_else(|_| "https://github.com/juliusjoska/sepot".into()),
revision: std::env::var("SEPOT_REVISION").unwrap_or_else(|_| "dev".into()), revision: std::env::var("SEPOT_REVISION").unwrap_or_else(|_| "dev".into()),
@@ -212,6 +264,11 @@ 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();
self.pow
.prune(chrono::Utc::now().timestamp().max(0) as u64);
} }
/// 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 +281,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 +312,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 +334,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 +357,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 +390,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 +426,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 +454,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 +571,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 +793,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 +806,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 +935,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 +984,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 +1011,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 +1164,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 +1178,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 +1218,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 +1346,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 +1364,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
View File
@@ -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;
} }
} }
} }