5 Commits

Author SHA1 Message Date
Julius Joska f903e21e3a release: 1.1.2 — reliable message delivery 2026-08-13 12:33:24 +02:00
Julius Joska 923a6a01bd fix(app): say it out loud when wake-ups do not work
Registration failures from the distributor were swallowed, so push looked
enabled while nothing arrived. Without wake-ups Android suspends the app
and messages only show up once the user opens it — which looks like broken
delivery even though the relay has them queued.

The failure is now remembered and surfaced in Settings, and the chat list
carries a dismissible line explaining the state.
2026-08-13 12:23:30 +02:00
Julius Joska 9985b79aa6 fix(app): ping the relay, retry stuck messages, drop duplicates
A message sent into a dead socket ended up as failed — attachments even
stayed on "sending" forever — and only the user could send it again by
hand. Mobile NAT drops idle TCP silently, so neither side knew.

- pingInterval on the WebSocket, so the client notices a dead link itself
- outbox: after every reconnect, stuck messages are re-sent (batch of 30,
  up to a day old); permanent errors are not retried
- sendAttachment gained the try/catch it was missing, otherwise the
  attachment never reached the outbox
- de-duplicate by message id: a retry re-sends the SAME message, which
  would otherwise show up twice on the other side
2026-08-13 12:23:24 +02:00
Julius Joska b82f50f888 fix(relay): keep-alive on WebSocket, cap delivery attempts, honest receipts
Messages arrived late or not at all, for two independent reasons.

Dead connections: neither side sent a ping, and after AUTH the server
waited for a frame without a timeout. Mobile NAT drops idle TCP silently,
so the client wrote into a void while the relay still held the device in
its online map — and therefore never sent a push wake-up either. The ping
is a protocol-level one rather than v1::Ping, because dart:io answers
those on its own; the fix works with clients already in the wild.

Poisoned queue: a client decrypts an envelope (ratchet advances), stores
it and acknowledges it. If that ACK is lost, a second decryption attempt
can only fail, so the envelope is never acknowledged — and was redelivered
on every connect until its 30-day TTL, without ever reaching the recipient.
attempt_count existed from the first migration but was never counted; it
now bounds redelivery.

Receipts reported DELIVERED_ONLINE whenever a write into the bounded mpsc
channel succeeded, which it does even for a long-dead socket. Online is now
claimed only for devices that recently sent something.

Push send failures were logged at debug while the relay runs at info, so a
rejected wake-up looked exactly like a working one.
2026-08-13 12:23:17 +02:00
Julius Joska 4f2cbdbdc4 harden(relay): gate forwarded-IP on trusted proxies, bound queue flush
Rate limiting and the per-IP WebSocket cap trusted X-Forwarded-For and
X-Real-Ip unconditionally. Onion traffic bypasses the reverse proxy, so a
client could set the header itself and slip past every limit. Forwarded
headers are now honoured only from peers inside SEPOT_TRUSTED_PROXIES;
everyone else is keyed by peer address, and onion clients share one bucket.

The queue flush also read the whole backlog into memory at once, and the
certificate authority kept the trust-root private key in the database even
though it is only ever needed offline.
2026-08-13 12:23:07 +02:00
37 changed files with 1377 additions and 116 deletions
+23
View File
@@ -4,6 +4,29 @@ Všechny podstatné změny v aplikaci Šepot. Formát dle
[Keep a Changelog](https://keepachangelog.com/cs/1.1.0/),
verzování dle [ajtak standardu](#verzování).
## [1.1.2] – 2026-08-13
### Opraveno
- **Zprávy už nečekají na otevření aplikace.** Když mobilní síť tiše
zahodila nečinné spojení, Šepot o tom nevěděl: tvářil se připojeně, ale
odeslaná zpráva skončila chybou a příchozí nedorazily — a všechny se pak
vysypaly naráz po restartu aplikace. Spojení se teď hlídá pravidelným
pingem na obou stranách, takže výpadek se pozná a obnoví v řádu sekund.
- **Neodeslaná zpráva se pošle sama.** Dosud zůstala označená jako
neodeslaná (u příloh dokonce natrvalo na „odesílá se") a poslat znovu ji
musel člověk ručně. Nově se po obnovení spojení odešle sama; opakované
doručení téže zprávy druhá strana pozná a nezobrazí ji dvakrát.
- **Zprávy, které nešly doručit, se přestaly hromadit.** Když se cestou
ztratilo potvrzení o doručení, obálka na serveru zůstala a znovu a znovu
se posílala, aniž by ji šlo přečíst. Server teď takové zprávy po několika
marných pokusech zahodí, místo aby jimi zdržoval doručování ostatních.
### Přidáno
- **Upozornění, když nechodí notifikace.** Bez nich Android aplikaci na
pozadí uspí a zprávy uvidíš, až ji otevřeš. Šepot to teď řekne rovnou v
seznamu chatů a nabídne nastavení; hlášku jde odmítnout. Selhání
registrace u distributora se už nezamlčí.
## [1.1.1] – 2026-07-18
### Opraveno
+12 -2
View File
@@ -21,6 +21,15 @@ import 'package:socks5_proxy/socks_client.dart';
import 'package:web_socket_channel/io.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
/// Jak často posílat WS ping a jak dlouho čekat na pong.
///
/// Bez pingu se mrtvé spojení tváří jako živé: mobilní NAT zahodí nečinné TCP
/// tiše, appka dál hlásí „připojeno" a píše do prázdna — zprávy pak odejdou až
/// po restartu appky, všechny naráz. `dart:io` při nastaveném intervalu ping
/// posílá a když pong nedorazí včas, spojení zavře → [RelayClient] se
/// reconnectne sám. Relay pinguje protisměrně po 30 s (ws.rs PING_INTERVAL).
const kWsPingInterval = Duration(seconds: 30);
/// Abstrakce nad tím, jak klient otevírá spojení k relayi.
abstract class RelayTransport {
/// Sdílený HTTP klient pro REST (registrace, prekey bundle, přílohy…).
@@ -41,7 +50,8 @@ class DirectTransport implements RelayTransport {
http.Client httpClient() => _client ??= http.Client();
@override
WebSocketChannel connectWs(Uri url) => WebSocketChannel.connect(url);
WebSocketChannel connectWs(Uri url) =>
IOWebSocketChannel.connect(url, pingInterval: kWsPingInterval);
@override
void close() {
@@ -84,7 +94,7 @@ class TorTransport implements RelayTransport {
@override
WebSocketChannel connectWs(Uri url) =>
IOWebSocketChannel.connect(_withExplicitPort(url),
customClient: _sharedHttpClient());
customClient: _sharedHttpClient(), pingInterval: kWsPingInterval);
/// `Uri` pro `ws`/`wss` nezná výchozí port → `url.port` vrací 0 a SOCKS
/// CONNECT by cílil na port 0 (serverError). Doplníme ho podle schématu
+14 -1
View File
@@ -45,6 +45,7 @@ class PushBootstrap {
static const _pendingEndpointKey = 'pending_push_endpoint';
static const _modeKey = 'push_mode'; // 'off' | 'up' | 'fcm'
static const _registrationFailedKey = 'push_registration_failed';
static const _store = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
@@ -57,6 +58,16 @@ class PushBootstrap {
static Future<void> writeMode(String mode) =>
_store.write(key: _modeKey, value: mode);
/// Odmítl distributor registraci? Selhání se dřív jen spolklo, takže
/// uživatel měl v Nastavení „push zapnutý", ale budíčky nechodily a
/// zprávy čekaly, než appku otevře. Nastavení si to přečte a řekne to.
static Future<bool> registrationFailed() async =>
await _store.read(key: _registrationFailedKey) == '1';
static Future<void> _setRegistrationFailed(bool failed) => failed
? _store.write(key: _registrationFailedKey, value: '1')
: _store.delete(key: _registrationFailedKey);
static Future<void> init(List<String> args) async {
backgroundStart = args.contains('--unifiedpush-bg');
@@ -69,7 +80,8 @@ class PushBootstrap {
} else {
await UnifiedPush.initialize(
onNewEndpoint: _onNewEndpoint,
onRegistrationFailed: (_, _) {},
onRegistrationFailed: (_, _) =>
unawaited(_setRegistrationFailed(true)),
onUnregistered: _onUnregistered,
onMessage: _onMessage,
);
@@ -114,6 +126,7 @@ class PushBootstrap {
}
static void _onNewEndpoint(PushEndpoint endpoint, String instance) {
unawaited(_setRegistrationFailed(false)); // registrace se povedla
final s = _service;
if (s != null) {
s.handleNewEndpoint(endpoint.url);
+4
View File
@@ -144,6 +144,10 @@ class PushService {
return (await UnifiedPush.getDistributor()) != null ? 'up' : 'off';
}
/// Odmítl distributor poslední registraci? Nastavení to ukáže — jinak by
/// push vypadal zapnutý, ale budíčky by nechodily.
Future<bool> registrationFailed() => PushBootstrap.registrationFailed();
Future<void> setModeOff() async {
await _teardownFcm(deleteToken: true);
try {
+174 -3
View File
@@ -78,9 +78,15 @@ class MessagingService {
bool _trustRootMismatch = false;
bool get trustRootMismatch => _trustRootMismatch;
StreamSubscription<dynamic>? _deliverSub;
StreamSubscription<RelayConnectionState>? _stateSub;
Timer? _registerRetryTimer;
Timer? _purgeTimer;
/// Běží právě dodatečné odeslání zaseklých zpráv? Reconnectů může přijít
/// několik rychle za sebou a dva souběžné průchody by tutéž zprávu poslaly
/// dvakrát.
bool _outboxRunning = false;
/// Transport relaye: false = přímé WSS, true = přes Tor (SOCKS5/Orbot na
/// `.onion`). Čte se z nastavení při startu, přepíná se za běhu
/// ([setTorEnabled]) přestavením [RelayClient].
@@ -155,6 +161,10 @@ class MessagingService {
final relay = _buildRelay();
_relay = relay;
_deliverSub = relay.deliveries.listen(_onDeliver);
// Po každém (re)connectu dorazit, co zůstalo viset ve frontě odchozích.
_stateSub = relay.stateStream.listen((state) {
if (state == RelayConnectionState.connected) unawaited(_flushOutbox());
});
if (existingAccount != null) {
relay.restoreSession(
@@ -211,6 +221,7 @@ class MessagingService {
/// push registrace se znovu odešle. Trust root zůstává pinovaný.
Future<void> _rebuildRelay() async {
await _deliverSub?.cancel();
await _stateSub?.cancel();
await _relay?.dispose();
_senderCert = null;
_certFetchedAt = null;
@@ -218,6 +229,10 @@ class MessagingService {
final relay = _buildRelay();
_relay = relay;
_deliverSub = relay.deliveries.listen(_onDeliver);
// Po každém (re)connectu dorazit, co zůstalo viset ve frontě odchozích.
_stateSub = relay.stateStream.listen((state) {
if (state == RelayConnectionState.connected) unawaited(_flushOutbox());
});
final existingAccount = accountId;
if (existingAccount != null) {
@@ -357,6 +372,7 @@ class MessagingService {
'v': 1,
't': 'txt',
'id': message.id,
'ts': message.timestamp.millisecondsSinceEpoch,
'txt': text,
're': ?replyToId,
'ttl': ?ttl,
@@ -409,10 +425,16 @@ class MessagingService {
await storage.addMessage(contact.id, message);
onUpdate?.call();
// Stejné ošetření jako u textu: bez něj zůstala příloha po výpadku
// spojení viset na „odesílá se" navždy. Označit jako failed ji dostane
// do fronty odchozích ([_flushOutbox]) — blob je na relayi nahraný,
// dosílá se jen meta.
try {
final delivered = await _sendContent(contact, {
'v': 1,
't': 'att',
'id': message.id,
'ts': message.timestamp.millisecondsSinceEpoch,
if (caption != null && caption.isNotEmpty) 'txt': caption,
'ttl': ?ttl,
'att': meta.toWire(),
@@ -426,6 +448,12 @@ class MessagingService {
await storage.updateMessage(contact.id, sent);
onUpdate?.call();
return sent;
} catch (_) {
await storage.updateMessage(
contact.id, message.copyWith(status: MessageStatus.failed));
onUpdate?.call();
rethrow;
}
}
/// Zpracuje a nahraje přílohu: obrázky re-enkóduje (EXIF/GPS pryč,
@@ -703,6 +731,7 @@ class MessagingService {
'v': 1,
't': 'txt',
'id': message.id,
'ts': message.timestamp.millisecondsSinceEpoch,
'gid': group.id,
'txt': text,
're': ?replyToId,
@@ -759,6 +788,7 @@ class MessagingService {
'v': 1,
't': 'att',
'id': message.id,
'ts': message.timestamp.millisecondsSinceEpoch,
'gid': group.id,
if (caption != null && caption.isNotEmpty) 'txt': caption,
'ttl': ?ttl,
@@ -1022,6 +1052,115 @@ class MessagingService {
/// Společná odesílací cesta: session bootstrap (X3DH s anti-MITM
/// kontrolou identity klíče), sealed encrypt, obálka na relay.
/// Vrací true = doručeno online, false = uloženo do offline fronty.
/// Kolik zaseklých zpráv poslat na jeden průchod. Po delším výpadku sítě
/// jich může být hodně; dávka drží start spojení svižný a zbytek dojde
/// při dalším ticku.
static const _outboxBatch = 30;
/// Jak staré zaseklé zprávy ještě dosílat. Co leží dýl, patří uživateli
/// do rukou — poslat týden starou zprávu bez zeptání by překvapilo.
static const _outboxMaxAge = Duration(hours: 24);
/// Došle zprávy, které zůstaly viset po výpadku spojení.
///
/// Bez tohohle skončila každá zpráva odeslaná do mrtvého socketu jako
/// `failed` (nebo věčné `sending` u příloh) a znovu ji poslal jedině
/// uživatel ručně. Volá se po každém úspěšném (re)connectu; id zprávy
/// zůstává stejné, takže protistrana duplicitu zahodí
/// ([Conversation.addMessage]).
Future<void> _flushOutbox() async {
if (_outboxRunning || !isStarted) return;
_outboxRunning = true;
try {
final identity = _identity;
if (identity == null) return;
final cutoff = DateTime.now().subtract(_outboxMaxAge);
final stuck = <(String convId, Message message)>[];
for (final conv in await storage.loadConversations()) {
for (final m in conv.messages) {
if (m.senderId != identity.id) continue;
if (m.status != MessageStatus.failed &&
m.status != MessageStatus.sending) {
continue;
}
if (m.timestamp.isBefore(cutoff) || m.isDeleted) continue;
stuck.add((conv.contactId, m));
}
}
if (stuck.isEmpty) return;
stuck.sort((a, b) => a.$2.timestamp.compareTo(b.$2.timestamp));
for (final (convId, message) in stuck.take(_outboxBatch)) {
if (connectionState != RelayConnectionState.connected) break;
await _resend(convId, message);
}
onUpdate?.call();
} finally {
_outboxRunning = false;
}
}
/// Jeden pokus o dodatečné odeslání. Trvalé chyby (chybný klíč protistrany,
/// kontakt bez doručovací adresy) nechají zprávu `failed` a dál se
/// neopakují — opakovat má smysl jen to, co shodila síť.
Future<void> _resend(String convId, Message message) async {
try {
final group = await storage.loadGroup(convId);
final bool delivered;
if (group != null) {
delivered = await _fanoutToGroup(group, _outboxWire(message, gid: convId));
} else {
final contact = (await storage.loadContacts())
.where((c) => c.id == convId)
.firstOrNull;
if (contact == null) return;
delivered = await _sendContent(contact, _outboxWire(message));
}
await storage.updateMessage(
convId,
message.copyWith(
isDelivered: delivered,
status: delivered ? MessageStatus.delivered : MessageStatus.sent,
),
);
} on SepotUserError {
// Srozumitelná chyba = stav, který se opakováním nespraví (chybí
// doručovací adresa, nesedí identity klíč, podvržený relay).
await storage.updateMessage(
convId,
message.copyWith(status: MessageStatus.failed),
);
} on Object {
// Síť/timeout — nechat viset a zkusit po příštím reconnectu.
await storage.updateMessage(
convId,
message.copyWith(status: MessageStatus.failed),
);
}
}
/// Obsah zprávy pro drát při dodatečném odeslání. Drží se tvaru, který
/// posílá [sendText]/[sendAttachment] — včetně `id`, aby šlo o TUTÉŽ
/// zprávu, ne novou.
Map<String, dynamic> _outboxWire(Message message, {String? gid}) {
final isAttachment = message.attachment != null;
final caption = message.content;
return {
'v': 1,
't': isAttachment ? 'att' : 'txt',
'id': message.id,
'ts': message.timestamp.millisecondsSinceEpoch,
'gid': ?gid,
if (isAttachment) 'att': message.attachment!.toWire(),
if (!isAttachment || (caption != null && caption.isNotEmpty))
'txt': caption ?? '',
're': ?message.replyToId,
'ttl': ?message.disappearAfterSeconds,
'p': _profile(),
};
}
Future<bool> _sendContent(
Contact contact,
Map<String, dynamic> content,
@@ -1231,7 +1370,7 @@ class MessagingService {
type: MessageType.text,
content: text,
replyToId: replyTo,
timestamp: DateTime.now(),
timestamp: wireSentAt(content['ts']),
isDelivered: true,
disappearAfterSeconds: ttl,
);
@@ -1257,18 +1396,31 @@ class MessagingService {
fileName: meta.name,
fileSize: meta.size,
replyToId: replyTo,
timestamp: DateTime.now(),
timestamp: wireSentAt(content['ts']),
isDelivered: true,
attachment: meta,
disappearAfterSeconds: ttl,
);
}
await storage.addMessage(convId, message);
final isNew = await storage.addMessage(convId, message);
// Uloženo na disku → teprve teď smí relay obálku zahodit.
// (Blob přílohy do fronty nepatří — stahuje se na pozadí a při
// neúspěchu má UI retry, ACK na něj nečeká.)
_relay!.ack(envelope.msgUuid);
// Zprávu už známe (odesílatel ji poslal znovu po výpadku spojení).
// Druhá bublina ani další stahování téhož blobu nemá vzniknout, ale
// doručenku pošli — protistrana ji nejspíš právě proto nedostala.
if (!isNew) {
unawaited(_sendControl(contact, {
't': 'dlv',
'ref': message.id,
'gid': ?gid,
}));
return;
}
onUpdate?.call();
onMessageReceived?.call(contact, message);
@@ -1300,6 +1452,24 @@ class MessagingService {
}
}
/// Čas odeslání z drátu (ms). Bez něj by zpráva dostala čas doručení —
/// po vyzvednutí offline fronty by celá konverzace uskočila na „teď"
/// a každý člen skupiny by u téže zprávy viděl jiný čas.
///
/// Klienti ≤1.1.1 `ts` neposílají → fallback na čas doručení (chová se
/// jako dřív). Odesílatel si čas diktuje sám (jde E2E kanálem), proto
/// strop: hodiny zařízení se běžně rozcházejí o minuty, ale zpráva
/// nesmí uskočit mimo konverzaci.
@visibleForTesting
static DateTime wireSentAt(Object? raw) {
final now = DateTime.now();
if (raw is! int) return now;
final sent = DateTime.fromMillisecondsSinceEpoch(raw);
if (sent.isAfter(now.add(const Duration(minutes: 5)))) return now;
if (sent.isBefore(DateTime(2020))) return now;
return sent;
}
/// Aplikuje příchozí reakci/edit/delete na cílovou zprávu v dané
/// konverzaci (1:1 = kontakt, skupina = gid). Klíč reakce = fingerprint
/// odesílatele (kryptograficky ověřený sealed senderem, ne
@@ -1514,6 +1684,7 @@ class MessagingService {
_purgeTimer?.cancel();
_purgeTimer = null;
await _deliverSub?.cancel();
await _stateSub?.cancel();
await _relay?.dispose();
// DB vlastní SecureStorageService (lock/deleteAll) — tady jen pustit
_db = null;
+6 -2
View File
@@ -442,11 +442,15 @@ class SecureStorageService {
);
}
Future<void> addMessage(String contactId, Message message) async {
/// Přidá zprávu do konverzace. Vrací false, když už tam zpráva s tímhle id
/// je (opakované doručení) — volající pak přeskočí navazující kroky jako
/// doručenku nebo stahování přílohy.
Future<bool> addMessage(String contactId, Message message) async {
var conv = await loadConversation(contactId);
conv ??= Conversation(contactId: contactId);
conv.addMessage(message);
if (!conv.addMessage(message)) return false;
await saveConversation(conv);
return true;
}
/// Založí prázdnou konverzaci, pokud ještě není (nová skupina musí být
@@ -5,6 +5,7 @@ import 'package:sepot/core/l10n/error_messages.dart';
import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/features/chat_list/chat_list_tile.dart';
import 'package:sepot/features/chat_list/push_hint_banner.dart';
import 'package:sepot/models/conversation.dart';
import 'package:sepot/providers/contacts_provider.dart';
import 'package:sepot/providers/conversations_provider.dart';
@@ -62,7 +63,13 @@ class ChatListScreen extends ConsumerWidget {
}
final hasArchive = archived.isNotEmpty;
return ListView.builder(
return Column(
children: [
// Bez budíčků appka na pozadí spí — ať uživatel ví, proč
// mu zprávy chodí, až když Šepot otevře.
const PushHintBanner(),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: active.length + (hasArchive ? 1 : 0),
itemBuilder: (context, index) {
@@ -96,6 +103,9 @@ class ChatListScreen extends ConsumerWidget {
onLongPress: () => _showChatActions(context, ref, conv, name),
);
},
),
),
],
);
},
loading: () => const Center(
@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/providers/push_provider.dart';
import 'package:sepot/providers/storage_provider.dart';
/// Upozornění nad seznamem chatů, když budíčky nefungují.
///
/// Bez pushe Android appku na pozadí uspí a zprávy dorazí, až ji uživatel
/// sám otevře — vypadá to jako výpadek doručování, i když relay má všechno
/// připravené ve frontě. Proužek je proto informace o stavu, ne reklama:
/// zmizí, jakmile si push nastaví, a jde odmítnout natrvalo.
class PushHintBanner extends ConsumerWidget {
const PushHintBanner({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final health = ref.watch(pushHealthProvider).valueOrNull;
final dismissed = ref.watch(pushHintDismissedProvider).valueOrNull ?? true;
if (dismissed || health == null || health == PushHealth.ok) {
return const SizedBox.shrink();
}
final text = health == PushHealth.failed
? context.l10n.pushHintFailed
: context.l10n.pushHintOff;
return Material(
color: SepotColors.surfaceVariant,
child: InkWell(
onTap: () => context.push('/settings'),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 8, 10),
child: Row(
children: [
Icon(
Icons.notifications_off_outlined,
size: 18,
color: health == PushHealth.failed
? SepotColors.ember
: SepotColors.textMuted,
),
const SizedBox(width: 12),
Expanded(
child: Text(
text,
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 13,
color: SepotColors.textSecondary,
),
),
),
IconButton(
icon: const Icon(Icons.close, size: 18),
color: SepotColors.textMuted,
tooltip: context.l10n.commonClose,
onPressed: () async {
await ref
.read(storageProvider)
.setSetting('push_hint_dismissed', true);
ref.invalidate(pushHintDismissedProvider);
},
),
],
),
),
),
);
}
}
@@ -20,6 +20,7 @@ class _PushSettingsTileState extends ConsumerState<PushSettingsTile> {
String _mode = 'off';
String? _distributor;
bool _busy = false;
bool _registrationFailed = false;
@override
void initState() {
@@ -31,10 +32,12 @@ class _PushSettingsTileState extends ConsumerState<PushSettingsTile> {
final push = ref.read(pushServiceProvider);
final mode = await push.currentMode();
final d = await push.currentDistributor();
final failed = await push.registrationFailed();
if (mounted) {
setState(() {
_mode = mode;
_distributor = d;
_registrationFailed = failed;
});
}
}
@@ -160,7 +163,12 @@ class _PushSettingsTileState extends ConsumerState<PushSettingsTile> {
@override
Widget build(BuildContext context) {
final subtitle = switch (_mode) {
// Selhaná registrace přebíjí popis režimu — „zapnuto" bez budíčků je
// horší než vypnuto, protože uživatel čeká zprávy, které nepřijdou.
final failed = _registrationFailed && _mode != 'off';
final subtitle = failed
? context.l10n.pushRegistrationFailed
: switch (_mode) {
'up' => context.l10n.pushSubtitleUnifiedPush(
_distributor ?? context.l10n.pushNoDistributorFallback),
'fcm' => context.l10n.pushSubtitleFcm,
@@ -184,7 +192,10 @@ class _PushSettingsTileState extends ConsumerState<PushSettingsTile> {
style: const TextStyle(fontSize: 15)),
subtitle: Text(
subtitle,
style: const TextStyle(fontSize: 13, color: SepotColors.textMuted),
style: TextStyle(
fontSize: 13,
color: failed ? SepotColors.ember : SepotColors.textMuted,
),
),
trailing: _busy
? const SizedBox(
+12
View File
@@ -1295,6 +1295,18 @@
}
}
},
"pushHintOff": "Zprávy ti přijdou, až Šepot otevřeš — budíčky nejsou nastavené.",
"@pushHintOff": {
"description": "Proužek nad seznamem chatů, když push není vůbec zapnutý."
},
"pushHintFailed": "Budíčky nefungují, zprávy uvidíš až po otevření Šepotu.",
"@pushHintFailed": {
"description": "Proužek nad seznamem chatů, když distributor registraci odmítl."
},
"pushRegistrationFailed": "Budíčky nechodí — distributor registraci odmítl. Zkus push vypnout a znovu zapnout.",
"@pushRegistrationFailed": {
"description": "Podtitulek dlaždice Push v Nastavení, když distributor odmítl registraci. Bez téhle hlášky uživatel vidí push jako zapnutý, ale zprávy mu chodí až po otevření appky."
},
"pushNoDistributorTitle": "Žádný distributor",
"@pushNoDistributorTitle": {
"description": "Titulek AlertDialog, když v systému není nainstalovaný žádný UnifiedPush distributor"
+3
View File
@@ -271,6 +271,9 @@
"pushModeFcm": "Google (FCM)",
"pushModeFcmDesc": "Zuverlässig ohne zusätzliche App. Der Push-Token des Geräts geht an Google; der Inhalt der Nachrichten nie.",
"pushSetupFailed": "Einrichtung der Push-Benachrichtigungen fehlgeschlagen: {error}",
"pushHintOff": "Nachrichten kommen erst an, wenn du Šepot öffnest — keine Weckrufe eingerichtet.",
"pushHintFailed": "Weckrufe funktionieren nicht; Nachrichten siehst du erst beim Öffnen von Šepot.",
"pushRegistrationFailed": "Es kommen keine Weckrufe an — der Distributor hat die Registrierung abgelehnt. Schalte Push aus und wieder ein.",
"pushNoDistributorTitle": "Kein Distributor",
"pushNoDistributorBody": "Für Google-freie Benachrichtigungen installiere einen UnifiedPush-Distributor, z. B. ntfy (F-Droid oder Play). Aktiviere Push danach erneut.",
"commonOk": "OK",
+3
View File
@@ -271,6 +271,9 @@
"pushModeFcm": "Google (FCM)",
"pushModeFcmDesc": "Reliable without an extra app. The device push token goes to Google; message content never does.",
"pushSetupFailed": "Setting up push failed: {error}",
"pushHintOff": "Messages arrive only when you open Šepot — wake-ups aren’t set up.",
"pushHintFailed": "Wake-ups aren’t working; you’ll see messages only after opening Šepot.",
"pushRegistrationFailed": "Wake-ups aren’t arriving — the distributor refused registration. Try turning push off and on again.",
"pushNoDistributorTitle": "No distributor",
"pushNoDistributorBody": "For de-Googled notifications, install a UnifiedPush distributor such as ntfy (F-Droid or Play). Then turn push on again.",
"commonOk": "OK",
+3
View File
@@ -271,6 +271,9 @@
"pushModeFcm": "Google (FCM)",
"pushModeFcmDesc": "Affidabile senza altre app. Il token push del dispositivo va a Google; il contenuto dei messaggi mai.",
"pushSetupFailed": "Configurazione delle notifiche push non riuscita: {error}",
"pushHintOff": "I messaggi arrivano solo quando apri Šepot — le notifiche non sono configurate.",
"pushHintFailed": "Le notifiche non funzionano: vedrai i messaggi solo aprendo Šepot.",
"pushRegistrationFailed": "Le notifiche non arrivano — il distributore ha rifiutato la registrazione. Prova a disattivare e riattivare il push.",
"pushNoDistributorTitle": "Nessun distributore",
"pushNoDistributorBody": "Per notifiche senza Google installa un distributore UnifiedPush, ad es. ntfy (F-Droid o Play). Poi riattiva le notifiche push.",
"commonOk": "OK",
+3
View File
@@ -271,6 +271,9 @@
"pushModeFcm": "Google (FCM)",
"pushModeFcmDesc": "Надёжно и без дополнительных приложений. Push-токен устройства передаётся Google; содержимое сообщений — никогда.",
"pushSetupFailed": "Не удалось настроить push: {error}",
"pushHintOff": "Сообщения придут, только когда откроешь Šepot — сигналы не настроены.",
"pushHintFailed": "Сигналы не работают: сообщения увидишь только после открытия Šepot.",
"pushRegistrationFailed": "Сигналы не приходят — дистрибьютор отклонил регистрацию. Попробуй выключить и снова включить push.",
"pushNoDistributorTitle": "Нет дистрибьютора",
"pushNoDistributorBody": "Для уведомлений без сервисов Google установите дистрибьютор UnifiedPush, например ntfy (F-Droid или Play). Затем включите push снова.",
"commonOk": "OK",
@@ -1730,6 +1730,24 @@ abstract class AppLocalizations {
/// **'Nastavení pushů selhalo: {error}'**
String pushSetupFailed(String error);
/// Proužek nad seznamem chatů, když push není vůbec zapnutý.
///
/// In cs, this message translates to:
/// **'Zprávy ti přijdou, až Šepot otevřeš — budíčky nejsou nastavené.'**
String get pushHintOff;
/// Proužek nad seznamem chatů, když distributor registraci odmítl.
///
/// In cs, this message translates to:
/// **'Budíčky nefungují, zprávy uvidíš až po otevření Šepotu.'**
String get pushHintFailed;
/// Podtitulek dlaždice Push v Nastavení, když distributor odmítl registraci. Bez téhle hlášky uživatel vidí push jako zapnutý, ale zprávy mu chodí až po otevření appky.
///
/// In cs, this message translates to:
/// **'Budíčky nechodí — distributor registraci odmítl. Zkus push vypnout a znovu zapnout.'**
String get pushRegistrationFailed;
/// Titulek AlertDialog, když v systému není nainstalovaný žádný UnifiedPush distributor
///
/// In cs, this message translates to:
@@ -961,6 +961,18 @@ class AppLocalizationsCs extends AppLocalizations {
return 'Nastavení pushů selhalo: $error';
}
@override
String get pushHintOff =>
'Zprávy ti přijdou, až Šepot otevřeš — budíčky nejsou nastavené.';
@override
String get pushHintFailed =>
'Budíčky nefungují, zprávy uvidíš až po otevření Šepotu.';
@override
String get pushRegistrationFailed =>
'Budíčky nechodí — distributor registraci odmítl. Zkus push vypnout a znovu zapnout.';
@override
String get pushNoDistributorTitle => 'Žádný distributor';
@@ -980,6 +980,18 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Einrichtung der Push-Benachrichtigungen fehlgeschlagen: $error';
}
@override
String get pushHintOff =>
'Nachrichten kommen erst an, wenn du Šepot öffnest — keine Weckrufe eingerichtet.';
@override
String get pushHintFailed =>
'Weckrufe funktionieren nicht; Nachrichten siehst du erst beim Öffnen von Šepot.';
@override
String get pushRegistrationFailed =>
'Es kommen keine Weckrufe an — der Distributor hat die Registrierung abgelehnt. Schalte Push aus und wieder ein.';
@override
String get pushNoDistributorTitle => 'Kein Distributor';
@@ -959,6 +959,18 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Setting up push failed: $error';
}
@override
String get pushHintOff =>
'Messages arrive only when you open Šepot — wake-ups aren’t set up.';
@override
String get pushHintFailed =>
'Wake-ups aren’t working; you’ll see messages only after opening Šepot.';
@override
String get pushRegistrationFailed =>
'Wake-ups aren’t arriving — the distributor refused registration. Try turning push off and on again.';
@override
String get pushNoDistributorTitle => 'No distributor';
@@ -969,6 +969,18 @@ class AppLocalizationsIt extends AppLocalizations {
return 'Configurazione delle notifiche push non riuscita: $error';
}
@override
String get pushHintOff =>
'I messaggi arrivano solo quando apri Šepot — le notifiche non sono configurate.';
@override
String get pushHintFailed =>
'Le notifiche non funzionano: vedrai i messaggi solo aprendo Šepot.';
@override
String get pushRegistrationFailed =>
'Le notifiche non arrivano — il distributore ha rifiutato la registrazione. Prova a disattivare e riattivare il push.';
@override
String get pushNoDistributorTitle => 'Nessun distributore';
@@ -976,6 +976,18 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Не удалось настроить push: $error';
}
@override
String get pushHintOff =>
'Сообщения придут, только когда откроешь Šepot — сигналы не настроены.';
@override
String get pushHintFailed =>
'Сигналы не работают: сообщения увидишь только после открытия Šepot.';
@override
String get pushRegistrationFailed =>
'Сигналы не приходят — дистрибьютор отклонил регистрацию. Попробуй выключить и снова включить push.';
@override
String get pushNoDistributorTitle => 'Нет дистрибьютора';
+8 -2
View File
@@ -39,10 +39,16 @@ class Conversation {
.length;
}
/// Přidej zprávu
void addMessage(Message message) {
/// Přidej zprávu. Vrací false, když už zpráva s tímhle id v konverzaci je.
///
/// Id zprávy nese odesílatel (pole `id` na drátě), takže opakované doručení
/// téhož obsahu — retry odesílatele po výpadku spojení nebo redelivery
/// z fronty relaye — nesmí založit druhou bublinu.
bool addMessage(Message message) {
if (messages.any((m) => m.id == message.id)) return false;
messages.add(message);
lastActivity = DateTime.now();
return true;
}
/// Nahraď zprávu se stejným id (změna stavu přílohy, doručenka…).
+29
View File
@@ -1,6 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sepot/core/push/push_service.dart';
import 'package:sepot/providers/messaging_provider.dart';
import 'package:sepot/providers/storage_provider.dart';
/// Push vrstva nad messagingem.
final pushServiceProvider = Provider<PushService>((ref) {
@@ -18,3 +19,31 @@ final pushInitProvider = FutureProvider<void>((ref) async {
// re-registruje u relaye po každém startu.
await push.restoreMode();
});
/// Stav budíčků pro chat list. Bez funkčního pushe appka na pozadí spí a
/// zprávy dorazí, až ji uživatel otevře — o tom má vědět dřív, než mu
/// někdo vyčte, že neodpovídá.
enum PushHealth {
/// Budíčky nastavené a bez chyby.
ok,
/// Push vůbec nezapnutý (výchozí stav po instalaci).
off,
/// Distributor registraci odmítl — zapnuto, ale nefunguje.
failed,
}
final pushHealthProvider = FutureProvider<PushHealth>((ref) async {
await ref.watch(pushInitProvider.future);
final push = ref.read(pushServiceProvider);
if (await push.registrationFailed()) return PushHealth.failed;
return await push.currentMode() == 'off' ? PushHealth.off : PushHealth.ok;
});
/// Skryl si uživatel upozornění na chat listu? Rozhodnutí se respektuje —
/// připomínat donekonečna by bylo otravné.
final pushHintDismissedProvider = FutureProvider<bool>((ref) async {
final storage = ref.watch(storageProvider);
return await storage.getSetting<bool>('push_hint_dismissed') ?? false;
});
+1 -1
View File
@@ -1,7 +1,7 @@
name: sepot
description: "Šepot — Soukromý messenger s E2E šifrováním"
publish_to: 'none'
version: 1.1.1+21
version: 1.1.2+22
environment:
sdk: ^3.10.8
+43
View File
@@ -0,0 +1,43 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sepot/models/conversation.dart';
import 'package:sepot/models/message.dart';
/// Id zprávy nese odesílatel, takže tatáž zpráva může dorazit dvakrát:
/// odesílatel ji po výpadku spojení pošle znovu (fronta odchozích) nebo ji
/// relay doručí opakovaně, než dorazí ACK. Ani v jednom případě nesmí
/// vzniknout druhá bublina.
void main() {
Message zprava(String id, {String text = 'ahoj'}) => Message(
id: id,
senderId: 'alice',
recipientId: 'bob',
type: MessageType.text,
content: text,
timestamp: DateTime.parse('2026-08-13T09:00:00.000'),
);
test('zpráva se stejným id se podruhé nepřidá', () {
final c = Conversation(contactId: 'bob');
expect(c.addMessage(zprava('m1')), isTrue, reason: 'první doručení');
expect(c.addMessage(zprava('m1')), isFalse, reason: 'opakované doručení');
expect(c.messages.length, 1);
});
test('dedup se řídí id, ne obsahem — stejný text jinou zprávou projde', () {
final c = Conversation(contactId: 'bob');
expect(c.addMessage(zprava('m1', text: 'ahoj')), isTrue);
expect(c.addMessage(zprava('m2', text: 'ahoj')), isTrue);
expect(c.messages.length, 2);
});
test('duplicita nechá původní zprávu být', () {
final c = Conversation(contactId: 'bob');
c.addMessage(zprava('m1', text: 'původní'));
c.addMessage(zprava('m1', text: 'přepsaný'));
expect(c.messages.single.content, 'původní');
});
}
@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sepot/features/chat_list/push_hint_banner.dart';
import 'package:sepot/l10n/generated/app_localizations.dart';
import 'package:sepot/providers/push_provider.dart';
/// Proužek říká uživateli, proč mu zprávy chodí až po otevření appky.
/// Musí se ukázat jen tehdy, když budíčky opravdu nefungují — jinak by
/// z informace o stavu byla otravná reklama.
Widget _app({required PushHealth health, required bool dismissed}) =>
ProviderScope(
overrides: [
pushHealthProvider.overrideWith((ref) async => health),
pushHintDismissedProvider.overrideWith((ref) async => dismissed),
],
child: const MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: Locale('cs'),
home: Scaffold(body: PushHintBanner()),
),
);
void main() {
testWidgets('push vypnutý → proužek se ukáže', (tester) async {
await tester.pumpWidget(_app(health: PushHealth.off, dismissed: false));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.notifications_off_outlined), findsOneWidget);
});
testWidgets('registrace selhala → proužek se ukáže', (tester) async {
await tester.pumpWidget(_app(health: PushHealth.failed, dismissed: false));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.notifications_off_outlined), findsOneWidget);
});
testWidgets('budíčky fungují → proužek mlčí', (tester) async {
await tester.pumpWidget(_app(health: PushHealth.ok, dismissed: false));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.notifications_off_outlined), findsNothing);
});
testWidgets('odmítnutý proužek se znovu neukáže', (tester) async {
await tester.pumpWidget(_app(health: PushHealth.off, dismissed: true));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.notifications_off_outlined), findsNothing);
});
}
+7
View File
@@ -22,6 +22,13 @@ services:
DATABASE_URL: postgres://sepot:${POSTGRES_PASSWORD}@db:5432/sepot_relay
SEPOT_BIND: 0.0.0.0:8470
SEPOT_ATTACHMENTS_DIR: /var/lib/sepot/attachments
# Sítě reverzní proxy, které smí určovat klientskou IP. Forwarded
# hlavičky (X-Forwarded-For/X-Real-Ip) se uznají JEN od IP v této sadě,
# jinak platí adresa protistrany — jinak by si anti-abuse klíč mohl
# nastavit sám klient. Musí pokrýt síť, ze které proxy na relay doráží.
# Síť Toru sem NEPATŘÍ: onion klienti mají sdílet jeden bucket.
# Prázdné = fail-safe, vždy adresa protistrany.
SEPOT_TRUSTED_PROXIES: ${SEPOT_TRUSTED_PROXIES:-}
# FCM fast mode (opt-in budíčky přes Google) — SA key ŽIJE JEN NA
# serveru (fcm-sa.json vedle .env, chmod 600, NIKDY do gitu)
SEPOT_FCM_CREDENTIALS: /etc/sepot/fcm-sa.json
+1
View File
@@ -2456,6 +2456,7 @@ dependencies = [
"dashmap",
"futures-util",
"hex",
"ipnet",
"jsonwebtoken",
"libsignal-core",
"libsignal-protocol",
+2
View File
@@ -22,6 +22,8 @@ sqlx = { version = "0.8", default-features = false, features = [
"macros",
] }
dashmap = "6"
# CIDR match trusted proxy sítí pro anti-abuse klíč (viz net.rs)
ipnet = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.22"
+4 -2
View File
@@ -123,11 +123,13 @@ impl ApnsSender {
.body(r#"{"aps":{"content-available":1}}"#)
.send()
.await;
// warn, ne debug — viz shodný komentář ve fcm.rs: na RUST_LOG=info
// bylo selhání pushe neviditelné.
match resp {
Ok(r) if !r.status().is_success() => {
tracing::debug!(status = %r.status(), "APNs budíček odmítnut");
tracing::warn!(status = %r.status(), "APNs budíček odmítnut");
}
Err(e) => tracing::debug!("APNs budíček selhal: {e}"),
Err(e) => tracing::warn!("APNs budíček selhal: {e}"),
_ => {}
}
}
+2
View File
@@ -302,6 +302,7 @@ mod db_tests {
let certs = crate::certs::CertAuthority::init(&pool).await.unwrap();
let dir = std::env::temp_dir().join(format!("sepot-att-test-{}", std::process::id()));
std::fs::create_dir_all(dir.join("tmp")).unwrap();
let trusted = std::sync::Arc::new(crate::net::TrustedProxies::from_env());
AppState::new(
pool,
certs,
@@ -309,6 +310,7 @@ mod db_tests {
dir,
max_bytes: 1024,
},
trusted,
)
}
+17 -6
View File
@@ -19,8 +19,18 @@ pub struct CertAuthority {
impl CertAuthority {
/// Načte klíče z DB; při prvním startu je vygeneruje a uloží.
/// Trust root private v DB je dev pohodlí kostry — v produkci se
/// vygeneruje offline a v DB bude jen server keypair + certifikát.
///
/// BEZPEČNOST (SECURITY audit #2): trust root private se do DB NEUKLÁDÁ.
/// Runtime ho nepotřebuje (podpis SenderCertificate dělá server_private),
/// slouží jen k jednorázovému podpisu server certifikátu při bootstrapu
/// a hned se zahodí. Kompromitace DB tak neprozradí kotvu důvěry, kterou
/// klienti pinnují.
///
/// ZBYTKOVÉ RIZIKO: server_private v DB zůstat MUSÍ (runtime podepisování)
/// a sám stačí na výrobu platného SenderCertificate → kompromitace DB
/// stále dovolí dočasnou impersonaci. Plné řešení = server keypair do
/// KMS/HSM, mimo scope kostry. Rotace server klíče vyžaduje offline trust
/// root (v dev se regeneruje celá kotva → mění se i pin klientů).
pub async fn init(pool: &PgPool) -> anyhow::Result<Self> {
let row: Option<(Vec<u8>, Vec<u8>, Vec<u8>)> = sqlx::query_as(
"SELECT trust_root_public, server_private, server_certificate FROM relay_keys WHERE id = 1",
@@ -52,20 +62,21 @@ impl CertAuthority {
.map_err(|e| anyhow::anyhow!("serialized: {e}"))?
.to_vec();
// trust_root_private se ZÁMĚRNĚ neukládá (audit #2) — sloupec zůstane
// NULL, private klíč kotvy důvěry žije jen po dobu tohoto bootstrapu.
sqlx::query(
"INSERT INTO relay_keys
(id, trust_root_public, trust_root_private, server_public, server_private, server_certificate)
VALUES (1, $1, $2, $3, $4, $5)",
(id, trust_root_public, server_public, server_private, server_certificate)
VALUES (1, $1, $2, $3, $4)",
)
.bind(trust_root.public_key.serialize().to_vec())
.bind(trust_root.private_key.serialize())
.bind(server.public_key.serialize().to_vec())
.bind(server.private_key.serialize())
.bind(&server_cert_bytes)
.execute(pool)
.await?;
tracing::info!("vygenerován nový trust root + server certificate");
tracing::info!("vygenerován nový trust root + server certificate (root private nezachován)");
Ok(Self {
trust_root_public: trust_root.public_key.serialize().to_vec(),
server_private: server.private_key,
+6 -2
View File
@@ -143,11 +143,15 @@ impl FcmSender {
"android": { "priority": "HIGH" }
}
});
// warn, ne debug: relay běží na RUST_LOG=info, takže na debugu bylo
// selhání pushe neviditelné a tiše se hromadila fronta. Protistranou
// je Google, ne telefon uživatele — chyba tu znamená mrtvý token nebo
// rozbitou konfiguraci projektu, obojí je provozní problém.
match http.post(&url).bearer_auth(access).json(&body).send().await {
Ok(resp) if !resp.status().is_success() => {
tracing::debug!(status = %resp.status(), "FCM budíček odmítnut");
tracing::warn!(status = %resp.status(), "FCM budíček odmítnut");
}
Err(e) => tracing::debug!("FCM budíček selhal: {e}"),
Err(e) => tracing::warn!("FCM budíček selhal: {e}"),
_ => {}
}
}
+21 -13
View File
@@ -9,6 +9,7 @@ mod apns;
mod attachments;
mod certs;
mod fcm;
mod net;
mod push;
mod rest;
mod state;
@@ -24,8 +25,9 @@ use axum::routing::{delete, get, post};
use axum::Router;
use sqlx::postgres::PgPoolOptions;
use tower_governor::governor::GovernorConfigBuilder;
use tower_governor::key_extractor::SmartIpKeyExtractor;
use tower_governor::GovernorLayer;
use crate::net::{TrustedProxies, TrustedProxyIp};
use tower_http::timeout::TimeoutLayer;
use tracing_subscriber::EnvFilter;
@@ -34,7 +36,7 @@ const MAX_BODY_BYTES: usize = 64 * 1024;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Log formát bez IP adres a bez identifikátorů klientů.
// Log formát bez IP adres a bez identifikátorů klientů — DoD sprintu.
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
@@ -54,23 +56,29 @@ async fn main() -> anyhow::Result<()> {
let certs = certs::CertAuthority::init(&pool).await?;
let attachments_cfg = attachments::Config::from_env();
tokio::fs::create_dir_all(attachments_cfg.dir.join("tmp")).await?;
let state = Arc::new(state::AppState::new(pool, certs, attachments_cfg));
// Trusted proxy sada je sdílená mezi WS conn-cap (AppState) a všemi
// governor limity — jeden zdroj pravdy o tom, komu se věří XFF.
let trusted = Arc::new(TrustedProxies::from_env());
let state = Arc::new(state::AppState::new(
pool,
certs,
attachments_cfg,
trusted.clone(),
));
attachments::spawn_cleanup(state.clone());
// ── Rate limiting (základní minimum; plný PoW/Privacy Pass později) ─
// ── Rate limiting (minimální sada; plný PoW/Privacy Pass později) ──
//
// Klíč = klientská IP: za traefikem X-Forwarded-For/X-Real-Ip (na
// relay dosáhne jen traefik z docker sítě), jinak peer adresa.
// Klíč = klientská IP přes TrustedProxyIp: X-Forwarded-For/X-Real-Ip se
// uzná JEN od trusted proxy (traefik), jinak peer adresa. Onion klienti
// přicházejí z tor kontejneru (mimo trusted sadu) → sdílí jeden bucket
// a nemohou si IP nastavit hlavičkou (SECURITY audit #1).
// In-memory token bucket — nikdy se nezapisuje na disk (privacy).
//
// Známý limit: Tor onion klienti přicházejí z tor kontejneru bez XFF
// → sdílí jeden bucket. Pro v1 (nízký provoz) přijatelné, řeší až
// Privacy Pass — limity proto raději volnější.
// registrace: zápis do DB bez auth → nejpřísnější (burst 10, +1/5 min)
let gov_register = Arc::new(
GovernorConfigBuilder::default()
.key_extractor(SmartIpKeyExtractor)
.key_extractor(TrustedProxyIp(trusted.clone()))
.burst_size(10)
.period(Duration::from_secs(300))
.finish()
@@ -79,7 +87,7 @@ async fn main() -> anyhow::Result<()> {
// čtecí API (bundle žere OTP klíče → drain ochrana): burst 60, +2/s
let gov_general = Arc::new(
GovernorConfigBuilder::default()
.key_extractor(SmartIpKeyExtractor)
.key_extractor(TrustedProxyIp(trusted.clone()))
.burst_size(60)
.per_second(2)
.finish()
@@ -89,7 +97,7 @@ async fn main() -> anyhow::Result<()> {
// přílohy: upload je autentizovaný, ale drahý (disk) → burst 30, +1/s
let gov_attachments = Arc::new(
GovernorConfigBuilder::default()
.key_extractor(SmartIpKeyExtractor)
.key_extractor(TrustedProxyIp(trusted.clone()))
.burst_size(30)
.per_second(1)
.finish()
+194
View File
@@ -0,0 +1,194 @@
//! Určení klientské IP za reverse proxy — anti-abuse klíč.
//!
//! BEZPEČNOST (SECURITY audit #1): forwarded hlavičky (X-Forwarded-For,
//! X-Real-Ip) smí nastavit kdokoli. Relay je dosažitelný dvěma cestami:
//! clearnet přes traefik (síť `pangolin`), kde traefik klientské X-hlavičky
//! přepisuje a jsou tedy důvěryhodné; a Tor onion přes tor kontejner (síť
//! `tornet`) PŘÍMO na :8470, mimo traefik — tor forwarduje HTTP beze změny,
//! takže hlavičky si nastaví sám klient.
//!
//! Kdybychom hlavičkám věřili vždy, onion klient by si zvolil libovolnou IP
//! a obešel VŠECHNY rate-limity i WS conn-cap (registrace, fetch_bundle,
//! upload, počet spojení). Proto: hlavičku uznej JEN když peer (skutečná
//! TCP adresa spojení) padne do explicitně nakonfigurované sady trusted
//! 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ář).
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use axum::extract::ConnectInfo;
use axum::http::{HeaderMap, Request};
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).
/// Konfiguruje se přes `SEPOT_TRUSTED_PROXIES` (čárkou oddělené IP nebo CIDR).
pub struct TrustedProxies {
nets: Vec<IpNet>,
}
impl TrustedProxies {
/// Načte z env `SEPOT_TRUSTED_PROXIES`. Prázdné/nenastavené = nevěří se
/// nikomu (vždy peer IP) — fail-safe: chybějící konfigurace nesmí otevřít
/// spoofing, maximálně sloučí clearnet klienty do jednoho bucketu.
pub fn from_env() -> Self {
let raw = std::env::var("SEPOT_TRUSTED_PROXIES").unwrap_or_default();
let nets: 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();
if nets.is_empty() {
tracing::warn!(
"SEPOT_TRUSTED_PROXIES prázdné — forwarded hlavičky se ignorují, \
anti-abuse klíč = peer IP. Za reverse proxy nastav CIDR proxy sítě."
);
}
Self { nets }
}
fn trusts(&self, peer: IpAddr) -> bool {
self.nets.iter().any(|n| n.contains(&peer))
}
/// Klientská IP pro anti-abuse. Forwarded hlavičku uznej jen od trusted
/// proxy; jinak (onion, dev, přímé spojení) peer IP. Pořadí X-Forwarded-For
/// → X-Real-Ip drží konzistenci s governor extraktorem. Bere se PRVNÍ
/// (nejlevější) záznam XFF = původní klient — platí jen díky tomu, že
/// traefik pro netrusted klienty příchozí XFF přepisuje (nedopisuje).
pub fn client_ip(&self, peer: IpAddr, headers: &HeaderMap) -> IpAddr {
if !self.trusts(peer) {
return peer;
}
let from_header = |name: &str| {
headers
.get(name)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split(',').next())
.and_then(|v| v.trim().parse::<IpAddr>().ok())
};
from_header("x-forwarded-for")
.or_else(|| from_header("x-real-ip"))
.unwrap_or(peer)
}
}
/// 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)]
mod tests {
use super::*;
use axum::http::header::{HeaderName, HeaderValue};
use axum::http::HeaderMap;
fn proxies(cfg: &[&str]) -> TrustedProxies {
let nets = cfg
.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 {
s.parse().unwrap()
}
fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
let mut h = HeaderMap::new();
for (k, v) in pairs {
h.insert(
HeaderName::from_bytes(k.as_bytes()).unwrap(),
HeaderValue::from_str(v).unwrap(),
);
}
h
}
/// JÁDRO auditu #1: onion peer (mimo trusted sadu) si NESMÍ nastavit IP
/// hlavičkou — anti-abuse klíč = peer IP tor kontejneru, ne spoof.
#[test]
fn onion_peer_nesmi_spoofnout_ip() {
let tp = proxies(&["10.20.0.0/16"]); // jen pangolin síť
let onion_peer = ip("172.30.0.5"); // tor kontejner na jiné síti
let spoof = headers(&[("x-real-ip", "1.2.3.4"), ("x-forwarded-for", "5.6.7.8")]);
assert_eq!(tp.client_ip(onion_peer, &spoof), onion_peer);
}
/// Trusted proxy (traefik) → forwarded hlavička se uzná, klíč = klient.
#[test]
fn trusted_proxy_uzna_forwarded() {
let tp = proxies(&["10.20.0.0/16"]);
let traefik = ip("10.20.0.3");
let h = headers(&[("x-forwarded-for", "203.0.113.9")]);
assert_eq!(tp.client_ip(traefik, &h), ip("203.0.113.9"));
}
/// XFF má přednost před X-Real-Ip (konzistence s governor extraktorem);
/// z řetězce se bere nejlevější (původní klient).
#[test]
fn xff_prednost_a_nejlevejsi() {
let tp = proxies(&["10.20.0.3"]); // holá IP = /32
let traefik = ip("10.20.0.3");
let h = headers(&[
("x-forwarded-for", "203.0.113.9, 10.20.0.3"),
("x-real-ip", "198.51.100.1"),
]);
assert_eq!(tp.client_ip(traefik, &h), ip("203.0.113.9"));
}
/// Trusted proxy bez hlaviček → fallback na peer.
#[test]
fn trusted_bez_hlavicek_fallback_peer() {
let tp = proxies(&["10.20.0.0/16"]);
let traefik = ip("10.20.0.3");
assert_eq!(tp.client_ip(traefik, &HeaderMap::new()), traefik);
}
/// Prázdná konfigurace (fail-safe) → vždy peer, hlavičky se ignorují.
#[test]
fn prazdna_konfigurace_vzdy_peer() {
let tp = proxies(&[]);
let peer = ip("10.20.0.3");
let spoof = headers(&[("x-forwarded-for", "1.2.3.4")]);
assert_eq!(tp.client_ip(peer, &spoof), peer);
}
/// IPv6 trusted CIDR taky funguje.
#[test]
fn ipv6_trusted_cidr() {
let tp = proxies(&["fd00::/8"]);
let peer = ip("fd00::1234");
let h = headers(&[("x-forwarded-for", "2001:db8::1")]);
assert_eq!(tp.client_ip(peer, &h), ip("2001:db8::1"));
}
}
+12 -2
View File
@@ -85,13 +85,23 @@ pub async fn send_wake(
/// Samotný POST budíčku (bez guardu — ten řeší volající). Oddělené kvůli
/// testovatelnosti proti loopback mocku (guard by loopback jinak odmítl).
async fn post_wake(client: &Client, url: &str) {
let _ = client
let resp = client
.post(url)
.header("content-type", "application/json")
.header("ttl", "2419200") // WebPush TTL (28 dní), ntfy ignoruje
.body(WAKE_BODY)
.send()
.await;
// Výsledek se dřív zahazoval (`let _ =`), takže nešlo poznat, jestli
// budíčky vůbec odcházejí. Logujeme jen stavový kód — endpoint je
// identifikátor uživatele a do logu nesmí.
match resp {
Ok(r) if !r.status().is_success() => {
tracing::warn!(status = %r.status(), "UnifiedPush budíček odmítnut");
}
Err(e) => tracing::warn!("UnifiedPush budíček selhal: {e}"),
_ => {}
}
}
/// Povolit jen bezpečné cíle. https vždy; http jen pro .onion (samo šifruje).
@@ -191,7 +201,7 @@ mod tests {
#[test]
fn verejne_adresy_projdou() {
for ip in ["1.1.1.1", "8.8.8.8", "203.0.113.10", "2606:4700:4700::1111"] {
for ip in ["1.1.1.1", "8.8.8.8", "9.9.9.9", "2606:4700:4700::1111"] {
assert!(!is_internal_ip(&ip.parse().unwrap()), "{ip} nemělo být interní");
}
}
+454 -19
View File
@@ -1,4 +1,6 @@
use chrono::{Duration, Utc};
use std::sync::Arc;
use chrono::{DateTime, Duration, Utc};
use dashmap::DashMap;
use prost::Message;
use sepot_proto::v1;
@@ -7,18 +9,80 @@ use sqlx::PgPool;
use tokio::sync::mpsc;
use crate::certs::CertAuthority;
use crate::net::TrustedProxies;
/// TTL offline fronty.
pub const QUEUE_TTL_DAYS: i64 = 30;
/// Strop celkové velikosti fronty na příjemce (SECURITY audit #3). Bez něj
/// autentizovaný útočník nasype neomezeně obálek (256 KiB × N) a při flushi
/// 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 =
/// FLUSH_BATCH × MAX_ENVELOPE_PAYLOAD (~16 MiB), ne celá fronta najednou.
const FLUSH_BATCH: i64 = 64;
/// Po kolika marných pokusech o doručení obálku zahodit.
///
/// Ztracený ACK je jednosměrka: klient obálku dešifruje (ratchet postoupí),
/// uloží a pošle ACK — když ten cestou zmizí (mrtvé spojení), relay obálku
/// drží dál, jenže druhý pokus o dešifrování už libsignal odmítne. Klient ji
/// tedy neACKuje a fronta se otravuje: tatáž obálka se posílá při každém
/// připojení až do TTL 30 dní a příjemce ji nikdy nedostane. Strop tomu dá
/// konec. Osm pokusů je velkoryse nad běžné „appka spadla před ACK".
const MAX_DELIVERY_ATTEMPTS: i16 = 8;
/// Jak dlouho po posledním rámci od klienta se spojení ještě považuje za
/// prokazatelně živé. Klient odpovídá na ping à 30 s (ws::PING_INTERVAL),
/// takže dva zmeškané cykly znamenají, že už nedýchá.
const FRESH_CONNECTION_MS: u64 = 70_000;
/// Připojené zařízení: kanál na jeho WS + kdy od něj naposled něco přišlo.
///
/// Samotný `tx` o životnosti spojení nic neříká — je to mpsc s bufferem, do
/// kterého zápis projde i u dávno mrtvého socketu. Právě proto se dřív vracelo
/// falešné DELIVERED_ONLINE a push budíček se vůbec nezkusil.
pub struct OnlineDevice {
pub tx: mpsc::Sender<v1::ServerFrame>,
/// ms od startu procesu; monotónní, aby s ním neházel NTP.
last_frame_ms: std::sync::atomic::AtomicU64,
}
impl OnlineDevice {
pub fn new(tx: mpsc::Sender<v1::ServerFrame>, now_ms: u64) -> Self {
Self {
tx,
last_frame_ms: std::sync::atomic::AtomicU64::new(now_ms),
}
}
/// Přišel rámec od klienta (Pong/ACK/Envelope) — spojení dýchá.
pub fn touch(&self, now_ms: u64) {
self.last_frame_ms
.store(now_ms, std::sync::atomic::Ordering::Relaxed);
}
fn is_fresh(&self, now_ms: u64) -> bool {
let last = self.last_frame_ms.load(std::sync::atomic::Ordering::Relaxed);
now_ms.saturating_sub(last) <= FRESH_CONNECTION_MS
}
}
pub struct AppState {
pub pool: PgPool,
/// delivery_address → kanál na živé WS spojení zařízení.
pub online: DashMap<Vec<u8>, mpsc::Sender<v1::ServerFrame>>,
/// Kdy proces nastartoval — základ monotónních značek v [`OnlineDevice`].
pub started: std::time::Instant,
/// delivery_address → živé WS spojení zařízení.
pub online: DashMap<Vec<u8>, Arc<OnlineDevice>>,
/// klientská IP → počet souběžných WS spojení (anti-flood cap).
/// Jen v paměti, nikam se neloguje ani neukládá.
pub ws_conns: DashMap<std::net::IpAddr, u32>,
pub certs: CertAuthority,
/// Trusted proxy sada pro určení klientské IP (WS conn-cap). Sdílená
/// s governor limity — komu se věří forwarded hlavička.
pub trusted_proxies: Arc<TrustedProxies>,
/// HTTP klient na push budíčky (bez redirectů, krátký timeout).
pub http: reqwest::Client,
/// Úložiště šifrovaných příloh (adresář + strop velikosti).
@@ -30,12 +94,19 @@ pub struct AppState {
}
impl AppState {
pub fn new(pool: PgPool, certs: CertAuthority, attachments: crate::attachments::Config) -> Self {
pub fn new(
pool: PgPool,
certs: CertAuthority,
attachments: crate::attachments::Config,
trusted_proxies: Arc<TrustedProxies>,
) -> Self {
Self {
pool,
started: std::time::Instant::now(),
online: DashMap::new(),
ws_conns: DashMap::new(),
certs,
trusted_proxies,
http: crate::push::build_client(),
attachments,
fcm: crate::fcm::FcmSender::from_env(),
@@ -71,20 +142,57 @@ impl AppState {
.bind(expires)
.execute(&self.pool)
.await?;
// FIFO strop na příjemce (audit #3). Nově vložená má nejmladší
// enqueued_at → sama se nikdy nezahodí, padne jen starý přebytek.
self.trim_queue(&dst, MAX_QUEUE_BYTES_PER_RECIPIENT).await?;
if let Some(tx) = self.online.get(&dst) {
if let Some(dev) = self.online.get(&dst).map(|d| d.clone()) {
let deliver = v1::ServerFrame {
frame: Some(v1::server_frame::Frame::Deliver(env)),
};
if tx.send(deliver).await.is_ok() {
let fresh = dev.is_fresh(self.now_ms());
let queued = dev.tx.send(deliver).await.is_ok();
// ONLINE hlásíme jen u spojení, které prokazatelně dýchá. Do
// zatuchlého se sice pošle taky (kdyby to byl jen tichý klient),
// ale propadne se i na push — jinak by mrtvé spojení budíček
// spolklo a příjemce se o zprávě nedozvěděl.
if fresh && queued {
return Ok(v1::receipt::Status::DeliveredOnline);
}
}
// Offline (nebo online kanál selhal) → zkusit push budíček.
// Offline, zatuchlé nebo selhalý kanál → zkusit push budíček.
self.wake(&dst).await;
Ok(v1::receipt::Status::StoredOffline)
}
/// 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
/// 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) -> anyhow::Result<u64> {
let res = sqlx::query(
"DELETE FROM message_queue
WHERE dst_address = $1 AND msg_uuid IN (
SELECT msg_uuid FROM (
SELECT msg_uuid,
sum(octet_length(envelope)) OVER (
ORDER BY enqueued_at DESC, msg_uuid DESC
ROWS UNBOUNDED PRECEDING) AS running
FROM message_queue WHERE dst_address = $1
) s WHERE running > $2
)",
)
.bind(dst)
.bind(cap_bytes)
.execute(&self.pool)
.await?;
Ok(res.rows_affected())
}
/// Monotónní značka pro [`OnlineDevice`] — ms od startu procesu.
pub fn now_ms(&self) -> u64 {
self.started.elapsed().as_millis() as u64
}
/// Budíček pro offline příjemce: dohledá jeho push token a pošle prázdný
/// „ťuk". Best-effort, na pozadí — neblokuje receipt odesílateli.
/// Endpoint se NELOGUJE.
@@ -172,32 +280,118 @@ impl AppState {
Ok(())
}
/// Po AUTH: vyleje nedoručenou frontu zařízení (DELIVER stream).
/// Po AUTH: vyleje nedoručenou frontu zařízení (DELIVER stream). Čte po
/// dávkách keyset kurzorem přes (enqueued_at, msg_uuid) — paměť je
/// omezená na FLUSH_BATCH obálek, ne celá fronta najednou (audit #3).
/// Index idx_queue_dst(dst_address, enqueued_at) kurzor pokrývá.
pub async fn flush_queue(
&self,
delivery_address: &[u8],
tx: &mpsc::Sender<v1::ServerFrame>,
) -> anyhow::Result<usize> {
let rows: Vec<(Vec<u8>,)> = sqlx::query_as(
"SELECT envelope FROM message_queue
WHERE dst_address = $1 AND expires_at > now()
ORDER BY enqueued_at",
// Obálky, které strop pokusů vyčerpaly, jsou neprůchodné (skoro vždy
// ztracený ACK po úspěšném dešifrování) — zahodit dřív, než se znovu
// pošlou. Maže se až tady, ne hned po inkrementu: poslední pokus
// dostane šanci doběhnout do ACK.
let dropped = sqlx::query(
"DELETE FROM message_queue WHERE dst_address = $1 AND attempt_count >= $2",
)
.bind(delivery_address)
.fetch_all(&self.pool)
.await?;
.bind(MAX_DELIVERY_ATTEMPTS)
.execute(&self.pool)
.await?
.rows_affected();
if dropped > 0 {
// Bez identifikátoru příjemce — jen že se fronta čistila.
tracing::info!(dropped, "nedoručitelné obálky zahozeny (strop pokusů)");
}
let count = rows.len();
for (encoded,) in rows {
let mut total = 0usize;
// (enqueued_at, msg_uuid) poslední odeslané obálky = kurzor další dávky.
let mut cursor: Option<(DateTime<Utc>, Vec<u8>)> = None;
loop {
let rows: Vec<(Vec<u8>, DateTime<Utc>, Vec<u8>)> = match &cursor {
None => {
sqlx::query_as(
"SELECT envelope, enqueued_at, msg_uuid FROM message_queue
WHERE dst_address = $1 AND expires_at > now()
AND attempt_count < $2
ORDER BY enqueued_at, msg_uuid
LIMIT $3",
)
.bind(delivery_address)
.bind(MAX_DELIVERY_ATTEMPTS)
.bind(FLUSH_BATCH)
.fetch_all(&self.pool)
.await?
}
Some((ts, uuid)) => {
sqlx::query_as(
"SELECT envelope, enqueued_at, msg_uuid FROM message_queue
WHERE dst_address = $1 AND expires_at > now()
AND attempt_count < $2
AND (enqueued_at, msg_uuid) > ($3, $4)
ORDER BY enqueued_at, msg_uuid
LIMIT $5",
)
.bind(delivery_address)
.bind(MAX_DELIVERY_ATTEMPTS)
.bind(ts)
.bind(uuid)
.bind(FLUSH_BATCH)
.fetch_all(&self.pool)
.await?
}
};
let batch_len = rows.len();
if batch_len == 0 {
break;
}
let last = rows
.last()
.map(|(_, ts, uuid)| (*ts, uuid.clone()))
.expect("dávka není prázdná");
let mut sent_uuids: Vec<Vec<u8>> = Vec::with_capacity(batch_len);
for (encoded, _, uuid) in &rows {
let env = v1::Envelope::decode(encoded.as_slice())?;
let frame = v1::ServerFrame {
frame: Some(v1::server_frame::Frame::Deliver(env)),
};
if tx.send(frame).await.is_err() {
break;
// Příjemce odpadl uprostřed dávky — započítat jen to, co
// reálně odešlo, ať pokus „zdarma" nekrátí strop.
self.bump_attempts(&sent_uuids).await;
return Ok(total);
}
sent_uuids.push(uuid.clone());
total += 1;
}
self.bump_attempts(&sent_uuids).await;
if (batch_len as i64) < FLUSH_BATCH {
break; // poslední (neúplná) dávka
}
cursor = Some(last);
}
Ok(total)
}
/// Započítá pokus o doručení odeslaným obálkám. Selhání se jen zaloguje —
/// obálky už jsou na drátě a shodit kvůli tomu flush by bylo horší než
/// nepřesné počítadlo.
async fn bump_attempts(&self, msg_uuids: &[Vec<u8>]) {
if msg_uuids.is_empty() {
return;
}
if let Err(e) = sqlx::query(
"UPDATE message_queue SET attempt_count = attempt_count + 1
WHERE msg_uuid = ANY($1)",
)
.bind(msg_uuids)
.execute(&self.pool)
.await
{
tracing::warn!("attempt_count se nepodařilo zvýšit: {e}");
}
Ok(count)
}
/// ACK příjemce → hard-delete z fronty (žádná retence po doručení).
@@ -228,7 +422,8 @@ mod db_tests {
.await
.expect("dev DB");
let certs = crate::certs::CertAuthority::init(&pool).await.unwrap();
AppState::new(pool, certs, crate::attachments::Config::from_env())
let trusted = Arc::new(crate::net::TrustedProxies::from_env());
AppState::new(pool, certs, crate::attachments::Config::from_env(), trusted)
}
fn rand_bytes(n: usize) -> Vec<u8> {
@@ -413,4 +608,244 @@ mod db_tests {
// idempotence: druhé smazání neexistujícího účtu nesmí spadnout
state.delete_identity(&account_id).await.unwrap();
}
/// Audit #3: flush přes hranici dávky (150 > FLUSH_BATCH) nesmí keyset
/// kurzorem žádnou obálku ztratit ani zdvojit.
#[tokio::test]
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
async fn flush_queue_davkuje_pres_hranici_bez_ztraty() {
let state = test_state().await;
let dst = rand_bytes(32);
let n = 150usize;
let mut expected = std::collections::HashSet::new();
for i in 0..n {
let msg_uuid = rand_bytes(16);
expected.insert(msg_uuid.clone());
let env = v1::Envelope {
dst_address: dst.clone(),
msg_uuid: msg_uuid.clone(),
content_hint: 0,
payload: vec![0x11; 32],
};
sqlx::query(
"INSERT INTO message_queue (msg_uuid, dst_address, envelope, enqueued_at, expires_at)
VALUES ($1, $2, $3, now() + (interval '1 millisecond' * $4), now() + interval '30 days')",
)
.bind(&msg_uuid)
.bind(&dst)
.bind(env.encode_to_vec())
.bind(i as i32)
.execute(&state.pool)
.await
.unwrap();
}
let (tx, mut rx) = mpsc::channel::<v1::ServerFrame>(256);
let count = state.flush_queue(&dst, &tx).await.unwrap();
drop(tx);
assert_eq!(count, n);
let mut got = std::collections::HashSet::new();
while let Some(frame) = rx.recv().await {
if let Some(v1::server_frame::Frame::Deliver(env)) = frame.frame {
assert!(got.insert(env.msg_uuid), "obálka doručena dvakrát");
}
}
assert_eq!(got, expected, "sada doručených ≠ sada zařazených");
sqlx::query("DELETE FROM message_queue WHERE dst_address = $1")
.bind(&dst)
.execute(&state.pool)
.await
.unwrap();
}
/// Zapsat do mpsc bufferu jde i u dávno mrtvého spojení — DELIVERED_ONLINE
/// se proto smí hlásit jen u zařízení, od kterého nedávno něco přišlo.
/// Zatuchlé musí spadnout na offline větev, jinak spolkne push budíček.
#[tokio::test]
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
async fn route_envelope_zatuchle_spojeni_neni_online() {
let mut state = test_state().await;
// Posunout start procesu do minulosti, ať jde značka „naposled se
// ozval na začátku" vyjádřit bez čekání reálných 70 s.
state.started = std::time::Instant::now()
- std::time::Duration::from_millis(FRESH_CONNECTION_MS + 10_000);
let (_account_id, _device_uuid, delivery_address) = seed_device(&state).await;
// Kanál nikdo nečte, ale buffer zápis spolkne — přesně situace
// mrtvého klienta, kterou dřív relay hlásil jako doručeno.
let (tx, _rx) = mpsc::channel::<v1::ServerFrame>(64);
let device = Arc::new(OnlineDevice::new(tx, 0));
state
.online
.insert(delivery_address.clone(), device.clone());
let env = v1::Envelope {
dst_address: delivery_address.clone(),
msg_uuid: rand_bytes(16),
content_hint: 0,
payload: vec![0xCD; 64],
};
assert_eq!(
state.route_envelope(env).await.unwrap(),
v1::receipt::Status::StoredOffline,
"zatuchlé spojení se nesmí hlásit jako doručené online"
);
// A naopak: čerstvě označené zařízení online být má.
device.touch(state.now_ms());
let env2 = v1::Envelope {
dst_address: delivery_address.clone(),
msg_uuid: rand_bytes(16),
content_hint: 0,
payload: vec![0xCE; 64],
};
assert_eq!(
state.route_envelope(env2).await.unwrap(),
v1::receipt::Status::DeliveredOnline,
"živé spojení má být online"
);
state.online.remove(&delivery_address);
sqlx::query("DELETE FROM message_queue WHERE dst_address = $1")
.bind(&delivery_address)
.execute(&state.pool)
.await
.unwrap();
}
/// Flush počítá pokusy — bez toho by strop nikdy nezabral.
#[tokio::test]
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
async fn flush_queue_pocita_pokusy() {
let state = test_state().await;
let dst = rand_bytes(32);
let msg_uuid = rand_bytes(16);
let env = v1::Envelope {
dst_address: dst.clone(),
msg_uuid: msg_uuid.clone(),
content_hint: 0,
payload: vec![0x22; 32],
};
sqlx::query(
"INSERT INTO message_queue (msg_uuid, dst_address, envelope, expires_at)
VALUES ($1, $2, $3, now() + interval '30 days')",
)
.bind(&msg_uuid)
.bind(&dst)
.bind(env.encode_to_vec())
.execute(&state.pool)
.await
.unwrap();
// Dva flushe bez ACK = dva započtené pokusy.
for _ in 0..2 {
let (tx, rx) = mpsc::channel::<v1::ServerFrame>(16);
assert_eq!(state.flush_queue(&dst, &tx).await.unwrap(), 1);
drop(rx);
}
let attempts: (i16,) =
sqlx::query_as("SELECT attempt_count FROM message_queue WHERE msg_uuid = $1")
.bind(&msg_uuid)
.fetch_one(&state.pool)
.await
.unwrap();
assert_eq!(attempts.0, 2, "každý flush musí započítat pokus");
sqlx::query("DELETE FROM message_queue WHERE dst_address = $1")
.bind(&dst)
.execute(&state.pool)
.await
.unwrap();
}
/// Obálka, kterou příjemce nikdy neACKne (ztracený ACK → ratchet už
/// postoupil), musí po stropu zmizet, ne otravovat frontu 30 dní.
#[tokio::test]
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
async fn flush_queue_zahodi_nepruchodnou_obalku() {
let state = test_state().await;
let dst = rand_bytes(32);
let msg_uuid = rand_bytes(16);
let env = v1::Envelope {
dst_address: dst.clone(),
msg_uuid: msg_uuid.clone(),
content_hint: 0,
payload: vec![0x33; 32],
};
// Rovnou na stropu = obálka, která se marně posílala MAX_DELIVERY_ATTEMPTS×.
sqlx::query(
"INSERT INTO message_queue (msg_uuid, dst_address, envelope, expires_at, attempt_count)
VALUES ($1, $2, $3, now() + interval '30 days', $4)",
)
.bind(&msg_uuid)
.bind(&dst)
.bind(env.encode_to_vec())
.bind(MAX_DELIVERY_ATTEMPTS)
.execute(&state.pool)
.await
.unwrap();
let (tx, rx) = mpsc::channel::<v1::ServerFrame>(16);
assert_eq!(
state.flush_queue(&dst, &tx).await.unwrap(),
0,
"vyčerpaná obálka se nesmí znovu poslat"
);
drop(rx);
let zbylo: (i64,) =
sqlx::query_as("SELECT count(*) FROM message_queue WHERE dst_address = $1")
.bind(&dst)
.fetch_one(&state.pool)
.await
.unwrap();
assert_eq!(zbylo.0, 0, "vyčerpaná obálka měla být zahozena");
}
/// Audit #3: FIFO strop zahodí nejstarší přes limit, nejnovější nechá.
#[tokio::test]
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
async fn trim_queue_fifo_zahodi_nejstarsi() {
let state = test_state().await;
let dst = rand_bytes(32);
// 5 obálek po 100 B, rostoucí enqueued_at. Strop 250 B → nejnovější
// dvě (2×100 ≤ 250) zůstanou, tři nejstarší padnou.
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; 100])
.bind(i as i32)
.execute(&state.pool)
.await
.unwrap();
}
let dropped = state.trim_queue(&dst, 250).await.unwrap();
assert_eq!(dropped, 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();
}
}
+63 -30
View File
@@ -12,6 +12,7 @@ use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use std::time::Duration;
use axum::body::Bytes;
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
use axum::extract::{ConnectInfo, State};
use axum::http::{HeaderMap, StatusCode};
@@ -26,38 +27,26 @@ use tokio::time::Instant;
use crate::state::AppState;
/// Max souběžných WS spojení z jedné IP (za traefikem = reálná klientská
/// IP z X-Real-Ip; tor kontejner sdílí jednu — proto ne moc těsně).
/// Max souběžných WS spojení z jedné IP. Klientská IP jde přes trusted-proxy
/// logiku (viz net.rs): za traefikem reálná IP z forwarded hlavičky, onion
/// klienti sdílí IP tor kontejneru (jeden bucket) — proto ne moc těsně.
const MAX_CONNS_PER_IP: u32 = 32;
/// Neautentizované spojení zavřít po tomto čase.
const AUTH_DEADLINE: Duration = Duration::from_secs(10);
/// Rate limit rámců od klienta: max FRAME_WINDOW_MAX za FRAME_WINDOW.
const FRAME_WINDOW: Duration = Duration::from_secs(10);
const FRAME_WINDOW_MAX: u32 = 200;
/// Klientská IP: hlavičky od trusted proxy (na relay dosáhne jen traefik
/// z docker sítě), fallback peer adresa (dev/tor).
/// Jak často server posílá WS Ping autentizovanému klientovi.
///
/// TRUST MODEL — POZOR: X-Real-Ip / X-Forwarded-For se přebírají bezpodmínečně
/// a řídí per-IP limity (cap spojení, rate limit). To je bezpečné VÝHRADNĚ
/// proto, že před relayem stojí důvěryhodná reverzní proxy (traefik), která
/// tyhle hlavičky vždy přepisuje reálnou klientskou IP. Klientem poslané
/// hodnoty tak nikdy neprojdou. Kdyby byl relay vystaven přímo do internetu
/// (bez proxy, která hlavičky sanitizuje), útočník by je mohl libovolně
/// podvrhnout, tím rozprostřít provoz přes falešné IP a všechny per-IP limity
/// obejít. Relay proto NIKDY nenasazovat bez takové proxy.
fn client_ip(headers: &HeaderMap, peer: SocketAddr) -> IpAddr {
let from_header = |name: &str| {
headers
.get(name)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split(',').next())
.and_then(|v| v.trim().parse::<IpAddr>().ok())
};
from_header("x-real-ip")
.or_else(|| from_header("x-forwarded-for"))
.unwrap_or_else(|| peer.ip())
}
/// Mobilní NAT zahazuje nečinné TCP tiše — bez keepalive se ani jedna strana
/// nedozví, že spojení je mrtvé: klient hlásí „připojeno" a píše do prázdna,
/// relay drží záznam v `online` a proto ani nepošle push budíček. Ping je
/// PROTOKOLOVÝ (ne `v1::Ping`), protože na ten dart:io odpovídá Pongem sám —
/// funguje tedy i s klienty, které jsou už venku a aplikační Ping neznají.
const PING_INTERVAL: Duration = Duration::from_secs(30);
/// Bez jediného rámce od klienta (stačí Pong) je spojení mrtvé. Tři zmeškané
/// pingy — velkoryse, ať krátký výpadek mobilní sítě spojení nezabije.
const IDLE_TIMEOUT: Duration = Duration::from_secs(95);
/// Drop-guard: úbytek čítače spojení i při panice/cancelu handleru.
struct ConnGuard {
@@ -80,7 +69,7 @@ pub async fn ws_handler(
headers: HeaderMap,
ws: WebSocketUpgrade,
) -> impl IntoResponse {
let ip = client_ip(&headers, peer);
let ip = state.trusted_proxies.client_ip(peer.ip(), &headers);
let over_cap = {
let mut entry = state.ws_conns.entry(ip).or_insert(0);
@@ -134,19 +123,39 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
let (mut sink, mut stream) = socket.split();
let (tx, mut rx) = mpsc::channel::<v1::ServerFrame>(64);
// Writer drží sink sám, takže si i pinguje — jinak by control rámce
// musely téct přes `tx`, který nese jen aplikační ServerFrame.
let writer = tokio::spawn(async move {
while let Some(frame) = rx.recv().await {
let mut ping = tokio::time::interval(PING_INTERVAL);
// Zpožděné ticky se nedohánějí — po pauze chceme jeden ping, ne dávku.
ping.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
ping.tick().await; // první tick padne hned, ten nechceme
loop {
tokio::select! {
frame = rx.recv() => {
let Some(frame) = frame else { break };
let bytes = frame.encode_to_vec();
if sink.send(WsMessage::Binary(bytes.into())).await.is_err() {
break;
}
}
_ = ping.tick() => {
if sink.send(WsMessage::Ping(Bytes::new())).await.is_err() {
break;
}
}
}
}
});
let mut pending: Option<Pending> = None;
let mut authed_address: Option<Vec<u8>> = None;
// device_uuid ověřeného zařízení — klíč pro push registraci.
let mut authed_device: Option<Vec<u8>> = None;
// Záznam v `online` mapě — značkuje se každým rámcem od klienta, aby
// routing poznal živé spojení od zatuchlého.
let mut online_device: Option<Arc<crate::state::OnlineDevice>> = None;
// Anti-abuse okno: počítá VŠECHNY rámce od klienta (envelope/ack/pong)
let mut window_start = Instant::now();
@@ -163,10 +172,29 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
}
}
} else {
stream.next().await
// Po AUTH hlídá spojení idle timeout: klient, který dýchá, pošle
// aspoň Pong na náš ping. Ticho = mrtvé spojení → zavřít, ať
// záznam zmizí z `online` a další zpráva jde přes push budíček.
// Bez tohohle tam zombie visel, dokud traefiku nedošel readTimeout
// (30 min), a push se celou tu dobu vůbec nezkusil.
match tokio::time::timeout(IDLE_TIMEOUT, stream.next()).await {
Ok(next) => next,
Err(_) => {
// info, ne debug: relay běží na RUST_LOG=info a kolik
// spojení umírá na ticho je provozní signál (kvalita sítě
// klientů), ne ladicí detail. Bez identifikátoru zařízení.
tracing::info!("ws zavřeno: klient neodpovídá na ping");
break;
}
}
};
let Some(Ok(msg)) = next else { break };
// Cokoliv od klienta (i Pong na náš ping) = spojení dýchá.
if let Some(dev) = &online_device {
dev.touch(state.now_ms());
}
let now = Instant::now();
if now.duration_since(window_start) > FRAME_WINDOW {
window_start = now;
@@ -242,9 +270,14 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
break;
}
let device = Arc::new(crate::state::OnlineDevice::new(
tx.clone(),
state.now_ms(),
));
state
.online
.insert(pd.delivery_address.clone(), tx.clone());
.insert(pd.delivery_address.clone(), device.clone());
online_device = Some(device);
authed_address = Some(pd.delivery_address.clone());
// Potvrzení AUTH — klient až teď smí hlásit „připojeno"
@@ -254,7 +287,7 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
)))
.await;
// last_seen jen v hrubé granularitě (privacy — zaokrouhleno na hodinu).
// last_seen jen v hrubé granularitě (privacy, §1.2).
let _ = sqlx::query(
"UPDATE devices SET last_seen_at = date_trunc('hour', now())
WHERE device_uuid = $1",