feat(relay): ephemeral delivery, call wake-ups and TURN credentials

Protocol and relay groundwork for typing indicators and 1:1 calls.

- ContentHint.EPHEMERAL: delivered only to an online recipient, never
  queued or pushed; the sender gets Receipt.STATUS_DROPPED otherwise.
- ContentHint.CALL: a call offer is queued like a normal message, but an
  offline recipient is woken immediately — a PushKit VoIP push on iOS
  (priority 10, 45 s expiry) or a short-lived FCM data message on
  Android. The relay learns that a device is being called, not by whom.
- RegisterPush.voip_token stores the iOS VoIP token next to the regular
  APNs token (migration 0004).
- TurnRequest/TurnCredentials: logged-in devices receive time-limited
  coturn credentials (use-auth-secret REST scheme, HMAC-SHA1), so the
  TURN server is never an open relay. Configured with SEPOT_TURN_URLS and
  SEPOT_TURN_SECRET or SEPOT_TURN_SECRET_FILE; without them calls fall
  back to direct connections and STUN.
This commit is contained in:
Julius Joska
2026-10-03 22:43:53 +02:00
parent 4e3d7de32c
commit 69687d0acf
14 changed files with 698 additions and 27 deletions
+179 -2
View File
@@ -650,10 +650,12 @@ class RegisterPush extends $pb.GeneratedMessage {
factory RegisterPush({ factory RegisterPush({
RegisterPush_Platform? platform, RegisterPush_Platform? platform,
$core.List<$core.int>? endpoint, $core.List<$core.int>? endpoint,
$core.List<$core.int>? voipToken,
}) { }) {
final result = create(); final result = create();
if (platform != null) result.platform = platform; if (platform != null) result.platform = platform;
if (endpoint != null) result.endpoint = endpoint; if (endpoint != null) result.endpoint = endpoint;
if (voipToken != null) result.voipToken = voipToken;
return result; return result;
} }
@@ -674,6 +676,8 @@ class RegisterPush extends $pb.GeneratedMessage {
enumValues: RegisterPush_Platform.values) enumValues: RegisterPush_Platform.values)
..a<$core.List<$core.int>>( ..a<$core.List<$core.int>>(
2, _omitFieldNames ? '' : 'endpoint', $pb.PbFieldType.OY) 2, _omitFieldNames ? '' : 'endpoint', $pb.PbFieldType.OY)
..a<$core.List<$core.int>>(
3, _omitFieldNames ? '' : 'voipToken', $pb.PbFieldType.OY)
..hasRequiredFields = false; ..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
@@ -714,6 +718,141 @@ class RegisterPush extends $pb.GeneratedMessage {
$core.bool hasEndpoint() => $_has(1); $core.bool hasEndpoint() => $_has(1);
@$pb.TagNumber(2) @$pb.TagNumber(2)
void clearEndpoint() => $_clearField(2); void clearEndpoint() => $_clearField(2);
/// iOS: PushKit VoIP token pro příchozí hovory (vedle běžného APNs tokenu
/// v `endpoint`). Prázdné = zařízení VoIP push nemá.
@$pb.TagNumber(3)
$core.List<$core.int> get voipToken => $_getN(2);
@$pb.TagNumber(3)
set voipToken($core.List<$core.int> value) => $_setBytes(2, value);
@$pb.TagNumber(3)
$core.bool hasVoipToken() => $_has(2);
@$pb.TagNumber(3)
void clearVoipToken() => $_clearField(3);
}
/// Dočasné údaje k TURN serveru pro hovory (REST schéma coturnu). Vydává je
/// relay jen přihlášenému zařízení, aby TURN nebyl otevřený pro kohokoli.
class TurnRequest extends $pb.GeneratedMessage {
factory TurnRequest() => create();
TurnRequest._();
factory TurnRequest.fromBuffer($core.List<$core.int> data,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromBuffer(data, registry);
factory TurnRequest.fromJson($core.String json,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromJson(json, registry);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
_omitMessageNames ? '' : 'TurnRequest',
package: const $pb.PackageName(_omitMessageNames ? '' : 'sepot.v1'),
createEmptyInstance: create)
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
TurnRequest clone() => deepCopy();
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
TurnRequest copyWith(void Function(TurnRequest) updates) =>
super.copyWith((message) => updates(message as TurnRequest))
as TurnRequest;
@$core.override
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static TurnRequest create() => TurnRequest._();
@$core.override
TurnRequest createEmptyInstance() => create();
@$core.pragma('dart2js:noInline')
static TurnRequest getDefault() => _defaultInstance ??=
$pb.GeneratedMessage.$_defaultFor<TurnRequest>(create);
static TurnRequest? _defaultInstance;
}
class TurnCredentials extends $pb.GeneratedMessage {
factory TurnCredentials({
$core.Iterable<$core.String>? urls,
$core.String? username,
$core.String? credential,
$core.int? ttlSeconds,
}) {
final result = create();
if (urls != null) result.urls.addAll(urls);
if (username != null) result.username = username;
if (credential != null) result.credential = credential;
if (ttlSeconds != null) result.ttlSeconds = ttlSeconds;
return result;
}
TurnCredentials._();
factory TurnCredentials.fromBuffer($core.List<$core.int> data,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromBuffer(data, registry);
factory TurnCredentials.fromJson($core.String json,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromJson(json, registry);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
_omitMessageNames ? '' : 'TurnCredentials',
package: const $pb.PackageName(_omitMessageNames ? '' : 'sepot.v1'),
createEmptyInstance: create)
..pPS(1, _omitFieldNames ? '' : 'urls')
..aOS(2, _omitFieldNames ? '' : 'username')
..aOS(3, _omitFieldNames ? '' : 'credential')
..aI(4, _omitFieldNames ? '' : 'ttlSeconds', fieldType: $pb.PbFieldType.OU3)
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
TurnCredentials clone() => deepCopy();
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
TurnCredentials copyWith(void Function(TurnCredentials) updates) =>
super.copyWith((message) => updates(message as TurnCredentials))
as TurnCredentials;
@$core.override
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static TurnCredentials create() => TurnCredentials._();
@$core.override
TurnCredentials createEmptyInstance() => create();
@$core.pragma('dart2js:noInline')
static TurnCredentials getDefault() => _defaultInstance ??=
$pb.GeneratedMessage.$_defaultFor<TurnCredentials>(create);
static TurnCredentials? _defaultInstance;
@$pb.TagNumber(1)
$pb.PbList<$core.String> get urls => $_getList(0);
@$pb.TagNumber(2)
$core.String get username => $_getSZ(1);
@$pb.TagNumber(2)
set username($core.String value) => $_setString(1, value);
@$pb.TagNumber(2)
$core.bool hasUsername() => $_has(1);
@$pb.TagNumber(2)
void clearUsername() => $_clearField(2);
@$pb.TagNumber(3)
$core.String get credential => $_getSZ(2);
@$pb.TagNumber(3)
set credential($core.String value) => $_setString(2, value);
@$pb.TagNumber(3)
$core.bool hasCredential() => $_has(2);
@$pb.TagNumber(3)
void clearCredential() => $_clearField(3);
@$pb.TagNumber(4)
$core.int get ttlSeconds => $_getIZ(3);
@$pb.TagNumber(4)
set ttlSeconds($core.int value) => $_setUnsignedInt32(3, value);
@$pb.TagNumber(4)
$core.bool hasTtlSeconds() => $_has(3);
@$pb.TagNumber(4)
void clearTtlSeconds() => $_clearField(4);
} }
class SignedPreKey extends $pb.GeneratedMessage { class SignedPreKey extends $pb.GeneratedMessage {
@@ -1137,6 +1276,7 @@ enum ClientFrame_Frame {
ack, ack,
pong, pong,
registerPush, registerPush,
turnRequest,
notSet notSet
} }
@@ -1148,6 +1288,7 @@ class ClientFrame extends $pb.GeneratedMessage {
Ack? ack, Ack? ack,
Pong? pong, Pong? pong,
RegisterPush? registerPush, RegisterPush? registerPush,
TurnRequest? turnRequest,
}) { }) {
final result = create(); final result = create();
if (auth != null) result.auth = auth; if (auth != null) result.auth = auth;
@@ -1156,6 +1297,7 @@ class ClientFrame extends $pb.GeneratedMessage {
if (ack != null) result.ack = ack; if (ack != null) result.ack = ack;
if (pong != null) result.pong = pong; if (pong != null) result.pong = pong;
if (registerPush != null) result.registerPush = registerPush; if (registerPush != null) result.registerPush = registerPush;
if (turnRequest != null) result.turnRequest = turnRequest;
return result; return result;
} }
@@ -1176,13 +1318,14 @@ class ClientFrame extends $pb.GeneratedMessage {
4: ClientFrame_Frame.ack, 4: ClientFrame_Frame.ack,
5: ClientFrame_Frame.pong, 5: ClientFrame_Frame.pong,
6: ClientFrame_Frame.registerPush, 6: ClientFrame_Frame.registerPush,
7: ClientFrame_Frame.turnRequest,
0: ClientFrame_Frame.notSet 0: ClientFrame_Frame.notSet
}; };
static final $pb.BuilderInfo _i = $pb.BuilderInfo( static final $pb.BuilderInfo _i = $pb.BuilderInfo(
_omitMessageNames ? '' : 'ClientFrame', _omitMessageNames ? '' : 'ClientFrame',
package: const $pb.PackageName(_omitMessageNames ? '' : 'sepot.v1'), package: const $pb.PackageName(_omitMessageNames ? '' : 'sepot.v1'),
createEmptyInstance: create) createEmptyInstance: create)
..oo(0, [1, 2, 3, 4, 5, 6]) ..oo(0, [1, 2, 3, 4, 5, 6, 7])
..aOM<Auth>(1, _omitFieldNames ? '' : 'auth', subBuilder: Auth.create) ..aOM<Auth>(1, _omitFieldNames ? '' : 'auth', subBuilder: Auth.create)
..aOM<AuthProof>(2, _omitFieldNames ? '' : 'authProof', ..aOM<AuthProof>(2, _omitFieldNames ? '' : 'authProof',
subBuilder: AuthProof.create) subBuilder: AuthProof.create)
@@ -1192,6 +1335,8 @@ class ClientFrame extends $pb.GeneratedMessage {
..aOM<Pong>(5, _omitFieldNames ? '' : 'pong', subBuilder: Pong.create) ..aOM<Pong>(5, _omitFieldNames ? '' : 'pong', subBuilder: Pong.create)
..aOM<RegisterPush>(6, _omitFieldNames ? '' : 'registerPush', ..aOM<RegisterPush>(6, _omitFieldNames ? '' : 'registerPush',
subBuilder: RegisterPush.create) subBuilder: RegisterPush.create)
..aOM<TurnRequest>(7, _omitFieldNames ? '' : 'turnRequest',
subBuilder: TurnRequest.create)
..hasRequiredFields = false; ..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
@@ -1219,6 +1364,7 @@ class ClientFrame extends $pb.GeneratedMessage {
@$pb.TagNumber(4) @$pb.TagNumber(4)
@$pb.TagNumber(5) @$pb.TagNumber(5)
@$pb.TagNumber(6) @$pb.TagNumber(6)
@$pb.TagNumber(7)
ClientFrame_Frame whichFrame() => _ClientFrame_FrameByTag[$_whichOneof(0)]!; ClientFrame_Frame whichFrame() => _ClientFrame_FrameByTag[$_whichOneof(0)]!;
@$pb.TagNumber(1) @$pb.TagNumber(1)
@$pb.TagNumber(2) @$pb.TagNumber(2)
@@ -1226,6 +1372,7 @@ class ClientFrame extends $pb.GeneratedMessage {
@$pb.TagNumber(4) @$pb.TagNumber(4)
@$pb.TagNumber(5) @$pb.TagNumber(5)
@$pb.TagNumber(6) @$pb.TagNumber(6)
@$pb.TagNumber(7)
void clearFrame() => $_clearField($_whichOneof(0)); void clearFrame() => $_clearField($_whichOneof(0));
@$pb.TagNumber(1) @$pb.TagNumber(1)
@@ -1293,6 +1440,17 @@ class ClientFrame extends $pb.GeneratedMessage {
void clearRegisterPush() => $_clearField(6); void clearRegisterPush() => $_clearField(6);
@$pb.TagNumber(6) @$pb.TagNumber(6)
RegisterPush ensureRegisterPush() => $_ensure(5); RegisterPush ensureRegisterPush() => $_ensure(5);
@$pb.TagNumber(7)
TurnRequest get turnRequest => $_getN(6);
@$pb.TagNumber(7)
set turnRequest(TurnRequest value) => $_setField(7, value);
@$pb.TagNumber(7)
$core.bool hasTurnRequest() => $_has(6);
@$pb.TagNumber(7)
void clearTurnRequest() => $_clearField(7);
@$pb.TagNumber(7)
TurnRequest ensureTurnRequest() => $_ensure(6);
} }
enum ServerFrame_Frame { enum ServerFrame_Frame {
@@ -1303,6 +1461,7 @@ enum ServerFrame_Frame {
ping, ping,
error, error,
authOk, authOk,
turnCredentials,
notSet notSet
} }
@@ -1315,6 +1474,7 @@ class ServerFrame extends $pb.GeneratedMessage {
Ping? ping, Ping? ping,
Error? error, Error? error,
AuthOk? authOk, AuthOk? authOk,
TurnCredentials? turnCredentials,
}) { }) {
final result = create(); final result = create();
if (challenge != null) result.challenge = challenge; if (challenge != null) result.challenge = challenge;
@@ -1324,6 +1484,7 @@ class ServerFrame extends $pb.GeneratedMessage {
if (ping != null) result.ping = ping; if (ping != null) result.ping = ping;
if (error != null) result.error = error; if (error != null) result.error = error;
if (authOk != null) result.authOk = authOk; if (authOk != null) result.authOk = authOk;
if (turnCredentials != null) result.turnCredentials = turnCredentials;
return result; return result;
} }
@@ -1345,13 +1506,14 @@ class ServerFrame extends $pb.GeneratedMessage {
5: ServerFrame_Frame.ping, 5: ServerFrame_Frame.ping,
6: ServerFrame_Frame.error, 6: ServerFrame_Frame.error,
7: ServerFrame_Frame.authOk, 7: ServerFrame_Frame.authOk,
8: ServerFrame_Frame.turnCredentials,
0: ServerFrame_Frame.notSet 0: ServerFrame_Frame.notSet
}; };
static final $pb.BuilderInfo _i = $pb.BuilderInfo( static final $pb.BuilderInfo _i = $pb.BuilderInfo(
_omitMessageNames ? '' : 'ServerFrame', _omitMessageNames ? '' : 'ServerFrame',
package: const $pb.PackageName(_omitMessageNames ? '' : 'sepot.v1'), package: const $pb.PackageName(_omitMessageNames ? '' : 'sepot.v1'),
createEmptyInstance: create) createEmptyInstance: create)
..oo(0, [1, 2, 3, 4, 5, 6, 7]) ..oo(0, [1, 2, 3, 4, 5, 6, 7, 8])
..aOM<Challenge>(1, _omitFieldNames ? '' : 'challenge', ..aOM<Challenge>(1, _omitFieldNames ? '' : 'challenge',
subBuilder: Challenge.create) subBuilder: Challenge.create)
..aOM<Receipt>(2, _omitFieldNames ? '' : 'receipt', ..aOM<Receipt>(2, _omitFieldNames ? '' : 'receipt',
@@ -1363,6 +1525,8 @@ class ServerFrame extends $pb.GeneratedMessage {
..aOM<Ping>(5, _omitFieldNames ? '' : 'ping', subBuilder: Ping.create) ..aOM<Ping>(5, _omitFieldNames ? '' : 'ping', subBuilder: Ping.create)
..aOM<Error>(6, _omitFieldNames ? '' : 'error', subBuilder: Error.create) ..aOM<Error>(6, _omitFieldNames ? '' : 'error', subBuilder: Error.create)
..aOM<AuthOk>(7, _omitFieldNames ? '' : 'authOk', subBuilder: AuthOk.create) ..aOM<AuthOk>(7, _omitFieldNames ? '' : 'authOk', subBuilder: AuthOk.create)
..aOM<TurnCredentials>(8, _omitFieldNames ? '' : 'turnCredentials',
subBuilder: TurnCredentials.create)
..hasRequiredFields = false; ..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
@@ -1391,6 +1555,7 @@ class ServerFrame extends $pb.GeneratedMessage {
@$pb.TagNumber(5) @$pb.TagNumber(5)
@$pb.TagNumber(6) @$pb.TagNumber(6)
@$pb.TagNumber(7) @$pb.TagNumber(7)
@$pb.TagNumber(8)
ServerFrame_Frame whichFrame() => _ServerFrame_FrameByTag[$_whichOneof(0)]!; ServerFrame_Frame whichFrame() => _ServerFrame_FrameByTag[$_whichOneof(0)]!;
@$pb.TagNumber(1) @$pb.TagNumber(1)
@$pb.TagNumber(2) @$pb.TagNumber(2)
@@ -1399,6 +1564,7 @@ class ServerFrame extends $pb.GeneratedMessage {
@$pb.TagNumber(5) @$pb.TagNumber(5)
@$pb.TagNumber(6) @$pb.TagNumber(6)
@$pb.TagNumber(7) @$pb.TagNumber(7)
@$pb.TagNumber(8)
void clearFrame() => $_clearField($_whichOneof(0)); void clearFrame() => $_clearField($_whichOneof(0));
@$pb.TagNumber(1) @$pb.TagNumber(1)
@@ -1477,6 +1643,17 @@ class ServerFrame extends $pb.GeneratedMessage {
void clearAuthOk() => $_clearField(7); void clearAuthOk() => $_clearField(7);
@$pb.TagNumber(7) @$pb.TagNumber(7)
AuthOk ensureAuthOk() => $_ensure(6); AuthOk ensureAuthOk() => $_ensure(6);
@$pb.TagNumber(8)
TurnCredentials get turnCredentials => $_getN(7);
@$pb.TagNumber(8)
set turnCredentials(TurnCredentials value) => $_setField(8, value);
@$pb.TagNumber(8)
$core.bool hasTurnCredentials() => $_has(7);
@$pb.TagNumber(8)
void clearTurnCredentials() => $_clearField(8);
@$pb.TagNumber(8)
TurnCredentials ensureTurnCredentials() => $_ensure(7);
} }
const $core.bool _omitFieldNames = const $core.bool _omitFieldNames =
+18 -2
View File
@@ -22,14 +22,27 @@ class ContentHint extends $pb.ProtobufEnum {
static const ContentHint CONTENT_HINT_SUPPLEMENTARY = static const ContentHint CONTENT_HINT_SUPPLEMENTARY =
ContentHint._(2, _omitEnumNames ? '' : 'CONTENT_HINT_SUPPLEMENTARY'); ContentHint._(2, _omitEnumNames ? '' : 'CONTENT_HINT_SUPPLEMENTARY');
/// Jen živé doručení (např. „píše…“): offline příjemci se neukládá do
/// fronty ani nebudí pushem — zastaralá by po vyzvednutí jen mátla.
static const ContentHint CONTENT_HINT_EPHEMERAL =
ContentHint._(3, _omitEnumNames ? '' : 'CONTENT_HINT_EPHEMERAL');
/// Nabídka hovoru: ukládá se jako běžná zpráva, ale offline příjemce se
/// budí VoIP pushem (iOS) nebo FCM s krátkou platností — zvonit má hned.
/// Relay se tím dozví, že zařízení někdo volá; kdo, dál neví.
static const ContentHint CONTENT_HINT_CALL =
ContentHint._(4, _omitEnumNames ? '' : 'CONTENT_HINT_CALL');
static const $core.List<ContentHint> values = <ContentHint>[ static const $core.List<ContentHint> values = <ContentHint>[
CONTENT_HINT_DEFAULT, CONTENT_HINT_DEFAULT,
CONTENT_HINT_RESYNC, CONTENT_HINT_RESYNC,
CONTENT_HINT_SUPPLEMENTARY, CONTENT_HINT_SUPPLEMENTARY,
CONTENT_HINT_EPHEMERAL,
CONTENT_HINT_CALL,
]; ];
static final $core.List<ContentHint?> _byValue = static final $core.List<ContentHint?> _byValue =
$pb.ProtobufEnum.$_initByValueList(values, 2); $pb.ProtobufEnum.$_initByValueList(values, 4);
static ContentHint? valueOf($core.int value) => static ContentHint? valueOf($core.int value) =>
value < 0 || value >= _byValue.length ? null : _byValue[value]; value < 0 || value >= _byValue.length ? null : _byValue[value];
@@ -43,15 +56,18 @@ class Receipt_Status extends $pb.ProtobufEnum {
Receipt_Status._(1, _omitEnumNames ? '' : 'STATUS_DELIVERED_ONLINE'); Receipt_Status._(1, _omitEnumNames ? '' : 'STATUS_DELIVERED_ONLINE');
static const Receipt_Status STATUS_STORED_OFFLINE = static const Receipt_Status STATUS_STORED_OFFLINE =
Receipt_Status._(2, _omitEnumNames ? '' : 'STATUS_STORED_OFFLINE'); Receipt_Status._(2, _omitEnumNames ? '' : 'STATUS_STORED_OFFLINE');
static const Receipt_Status STATUS_DROPPED =
Receipt_Status._(3, _omitEnumNames ? '' : 'STATUS_DROPPED');
static const $core.List<Receipt_Status> values = <Receipt_Status>[ static const $core.List<Receipt_Status> values = <Receipt_Status>[
STATUS_QUEUED, STATUS_QUEUED,
STATUS_DELIVERED_ONLINE, STATUS_DELIVERED_ONLINE,
STATUS_STORED_OFFLINE, STATUS_STORED_OFFLINE,
STATUS_DROPPED,
]; ];
static final $core.List<Receipt_Status?> _byValue = static final $core.List<Receipt_Status?> _byValue =
$pb.ProtobufEnum.$_initByValueList(values, 2); $pb.ProtobufEnum.$_initByValueList(values, 3);
static Receipt_Status? valueOf($core.int value) => static Receipt_Status? valueOf($core.int value) =>
value < 0 || value >= _byValue.length ? null : _byValue[value]; value < 0 || value >= _byValue.length ? null : _byValue[value];
+60 -8
View File
@@ -22,13 +22,16 @@ const ContentHint$json = {
{'1': 'CONTENT_HINT_DEFAULT', '2': 0}, {'1': 'CONTENT_HINT_DEFAULT', '2': 0},
{'1': 'CONTENT_HINT_RESYNC', '2': 1}, {'1': 'CONTENT_HINT_RESYNC', '2': 1},
{'1': 'CONTENT_HINT_SUPPLEMENTARY', '2': 2}, {'1': 'CONTENT_HINT_SUPPLEMENTARY', '2': 2},
{'1': 'CONTENT_HINT_EPHEMERAL', '2': 3},
{'1': 'CONTENT_HINT_CALL', '2': 4},
], ],
}; };
/// Descriptor for `ContentHint`. Decode as a `google.protobuf.EnumDescriptorProto`. /// Descriptor for `ContentHint`. Decode as a `google.protobuf.EnumDescriptorProto`.
final $typed_data.Uint8List contentHintDescriptor = $convert.base64Decode( final $typed_data.Uint8List contentHintDescriptor = $convert.base64Decode(
'CgtDb250ZW50SGludBIYChRDT05URU5UX0hJTlRfREVGQVVMVBAAEhcKE0NPTlRFTlRfSElOVF' 'CgtDb250ZW50SGludBIYChRDT05URU5UX0hJTlRfREVGQVVMVBAAEhcKE0NPTlRFTlRfSElOVF'
'9SRVNZTkMQARIeChpDT05URU5UX0hJTlRfU1VQUExFTUVOVEFSWRAC'); '9SRVNZTkMQARIeChpDT05URU5UX0hJTlRfU1VQUExFTUVOVEFSWRACEhoKFkNPTlRFTlRfSElO'
'VF9FUEhFTUVSQUwQAxIVChFDT05URU5UX0hJTlRfQ0FMTBAE');
@$core.Deprecated('Use envelopeDescriptor instead') @$core.Deprecated('Use envelopeDescriptor instead')
const Envelope$json = { const Envelope$json = {
@@ -90,15 +93,16 @@ const Receipt_Status$json = {
{'1': 'STATUS_QUEUED', '2': 0}, {'1': 'STATUS_QUEUED', '2': 0},
{'1': 'STATUS_DELIVERED_ONLINE', '2': 1}, {'1': 'STATUS_DELIVERED_ONLINE', '2': 1},
{'1': 'STATUS_STORED_OFFLINE', '2': 2}, {'1': 'STATUS_STORED_OFFLINE', '2': 2},
{'1': 'STATUS_DROPPED', '2': 3},
], ],
}; };
/// Descriptor for `Receipt`. Decode as a `google.protobuf.DescriptorProto`. /// Descriptor for `Receipt`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List receiptDescriptor = $convert.base64Decode( final $typed_data.Uint8List receiptDescriptor = $convert.base64Decode(
'CgdSZWNlaXB0EhkKCG1zZ191dWlkGAEgASgMUgdtc2dVdWlkEjAKBnN0YXR1cxgCIAEoDjIYLn' 'CgdSZWNlaXB0EhkKCG1zZ191dWlkGAEgASgMUgdtc2dVdWlkEjAKBnN0YXR1cxgCIAEoDjIYLn'
'NlcG90LnYxLlJlY2VpcHQuU3RhdHVzUgZzdGF0dXMiUwoGU3RhdHVzEhEKDVNUQVRVU19RVUVV' 'NlcG90LnYxLlJlY2VpcHQuU3RhdHVzUgZzdGF0dXMiZwoGU3RhdHVzEhEKDVNUQVRVU19RVUVV'
'RUQQABIbChdTVEFUVVNfREVMSVZFUkVEX09OTElORRABEhkKFVNUQVRVU19TVE9SRURfT0ZGTE' 'RUQQABIbChdTVEFUVVNfREVMSVZFUkVEX09OTElORRABEhkKFVNUQVRVU19TVE9SRURfT0ZGTE'
'lORRAC'); 'lORRACEhIKDlNUQVRVU19EUk9QUEVEEAM=');
@$core.Deprecated('Use authDescriptor instead') @$core.Deprecated('Use authDescriptor instead')
const Auth$json = { const Auth$json = {
@@ -201,6 +205,7 @@ const RegisterPush$json = {
'10': 'platform' '10': 'platform'
}, },
{'1': 'endpoint', '3': 2, '4': 1, '5': 12, '10': 'endpoint'}, {'1': 'endpoint', '3': 2, '4': 1, '5': 12, '10': 'endpoint'},
{'1': 'voip_token', '3': 3, '4': 1, '5': 12, '10': 'voipToken'},
], ],
'4': [RegisterPush_Platform$json], '4': [RegisterPush_Platform$json],
}; };
@@ -220,9 +225,36 @@ const RegisterPush_Platform$json = {
/// Descriptor for `RegisterPush`. Decode as a `google.protobuf.DescriptorProto`. /// Descriptor for `RegisterPush`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List registerPushDescriptor = $convert.base64Decode( final $typed_data.Uint8List registerPushDescriptor = $convert.base64Decode(
'CgxSZWdpc3RlclB1c2gSOwoIcGxhdGZvcm0YASABKA4yHy5zZXBvdC52MS5SZWdpc3RlclB1c2' 'CgxSZWdpc3RlclB1c2gSOwoIcGxhdGZvcm0YASABKA4yHy5zZXBvdC52MS5SZWdpc3RlclB1c2'
'guUGxhdGZvcm1SCHBsYXRmb3JtEhoKCGVuZHBvaW50GAIgASgMUghlbmRwb2ludCJyCghQbGF0' 'guUGxhdGZvcm1SCHBsYXRmb3JtEhoKCGVuZHBvaW50GAIgASgMUghlbmRwb2ludBIdCgp2b2lw'
'Zm9ybRIRCg1QTEFURk9STV9OT05FEAASEAoMUExBVEZPUk1fRkNNEAESEQoNUExBVEZPUk1fQV' 'X3Rva2VuGAMgASgMUgl2b2lwVG9rZW4icgoIUGxhdGZvcm0SEQoNUExBVEZPUk1fTk9ORRAAEh'
'BOUxACEhgKFFBMQVRGT1JNX1VOSUZJRURQVVNIEAMSFAoQUExBVEZPUk1fV0VCUFVTSBAE'); 'AKDFBMQVRGT1JNX0ZDTRABEhEKDVBMQVRGT1JNX0FQTlMQAhIYChRQTEFURk9STV9VTklGSUVE'
'UFVTSBADEhQKEFBMQVRGT1JNX1dFQlBVU0gQBA==');
@$core.Deprecated('Use turnRequestDescriptor instead')
const TurnRequest$json = {
'1': 'TurnRequest',
};
/// Descriptor for `TurnRequest`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List turnRequestDescriptor =
$convert.base64Decode('CgtUdXJuUmVxdWVzdA==');
@$core.Deprecated('Use turnCredentialsDescriptor instead')
const TurnCredentials$json = {
'1': 'TurnCredentials',
'2': [
{'1': 'urls', '3': 1, '4': 3, '5': 9, '10': 'urls'},
{'1': 'username', '3': 2, '4': 1, '5': 9, '10': 'username'},
{'1': 'credential', '3': 3, '4': 1, '5': 9, '10': 'credential'},
{'1': 'ttl_seconds', '3': 4, '4': 1, '5': 13, '10': 'ttlSeconds'},
],
};
/// Descriptor for `TurnCredentials`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List turnCredentialsDescriptor = $convert.base64Decode(
'Cg9UdXJuQ3JlZGVudGlhbHMSEgoEdXJscxgBIAMoCVIEdXJscxIaCgh1c2VybmFtZRgCIAEoCV'
'IIdXNlcm5hbWUSHgoKY3JlZGVudGlhbBgDIAEoCVIKY3JlZGVudGlhbBIfCgt0dGxfc2Vjb25k'
'cxgEIAEoDVIKdHRsU2Vjb25kcw==');
@$core.Deprecated('Use signedPreKeyDescriptor instead') @$core.Deprecated('Use signedPreKeyDescriptor instead')
const SignedPreKey$json = { const SignedPreKey$json = {
@@ -391,6 +423,15 @@ const ClientFrame$json = {
'9': 0, '9': 0,
'10': 'registerPush' '10': 'registerPush'
}, },
{
'1': 'turn_request',
'3': 7,
'4': 1,
'5': 11,
'6': '.sepot.v1.TurnRequest',
'9': 0,
'10': 'turnRequest'
},
], ],
'8': [ '8': [
{'1': 'frame'}, {'1': 'frame'},
@@ -404,7 +445,8 @@ final $typed_data.Uint8List clientFrameDescriptor = $convert.base64Decode(
'dmVsb3BlGAMgASgLMhIuc2Vwb3QudjEuRW52ZWxvcGVIAFIIZW52ZWxvcGUSIQoDYWNrGAQgAS' 'dmVsb3BlGAMgASgLMhIuc2Vwb3QudjEuRW52ZWxvcGVIAFIIZW52ZWxvcGUSIQoDYWNrGAQgAS'
'gLMg0uc2Vwb3QudjEuQWNrSABSA2FjaxIkCgRwb25nGAUgASgLMg4uc2Vwb3QudjEuUG9uZ0gA' 'gLMg0uc2Vwb3QudjEuQWNrSABSA2FjaxIkCgRwb25nGAUgASgLMg4uc2Vwb3QudjEuUG9uZ0gA'
'UgRwb25nEj0KDXJlZ2lzdGVyX3B1c2gYBiABKAsyFi5zZXBvdC52MS5SZWdpc3RlclB1c2hIAF' 'UgRwb25nEj0KDXJlZ2lzdGVyX3B1c2gYBiABKAsyFi5zZXBvdC52MS5SZWdpc3RlclB1c2hIAF'
'IMcmVnaXN0ZXJQdXNoQgcKBWZyYW1l'); 'IMcmVnaXN0ZXJQdXNoEjoKDHR1cm5fcmVxdWVzdBgHIAEoCzIVLnNlcG90LnYxLlR1cm5SZXF1'
'ZXN0SABSC3R1cm5SZXF1ZXN0QgcKBWZyYW1l');
@$core.Deprecated('Use serverFrameDescriptor instead') @$core.Deprecated('Use serverFrameDescriptor instead')
const ServerFrame$json = { const ServerFrame$json = {
@@ -473,6 +515,15 @@ const ServerFrame$json = {
'9': 0, '9': 0,
'10': 'authOk' '10': 'authOk'
}, },
{
'1': 'turn_credentials',
'3': 8,
'4': 1,
'5': 11,
'6': '.sepot.v1.TurnCredentials',
'9': 0,
'10': 'turnCredentials'
},
], ],
'8': [ '8': [
{'1': 'frame'}, {'1': 'frame'},
@@ -487,4 +538,5 @@ final $typed_data.Uint8List serverFrameDescriptor = $convert.base64Decode(
'Jla2V5c19sb3cYBCABKAsyFC5zZXBvdC52MS5QcmVrZXlzTG93SABSCnByZWtleXNMb3cSJAoE' 'Jla2V5c19sb3cYBCABKAsyFC5zZXBvdC52MS5QcmVrZXlzTG93SABSCnByZWtleXNMb3cSJAoE'
'cGluZxgFIAEoCzIOLnNlcG90LnYxLlBpbmdIAFIEcGluZxInCgVlcnJvchgGIAEoCzIPLnNlcG' 'cGluZxgFIAEoCzIOLnNlcG90LnYxLlBpbmdIAFIEcGluZxInCgVlcnJvchgGIAEoCzIPLnNlcG'
'90LnYxLkVycm9ySABSBWVycm9yEisKB2F1dGhfb2sYByABKAsyEC5zZXBvdC52MS5BdXRoT2tI' '90LnYxLkVycm9ySABSBWVycm9yEisKB2F1dGhfb2sYByABKAsyEC5zZXBvdC52MS5BdXRoT2tI'
'AFIGYXV0aE9rQgcKBWZyYW1l'); 'AFIGYXV0aE9rEkYKEHR1cm5fY3JlZGVudGlhbHMYCCABKAsyGS5zZXBvdC52MS5UdXJuQ3JlZG'
'VudGlhbHNIAFIPdHVybkNyZWRlbnRpYWxzQgcKBWZyYW1l');
+3 -1
View File
@@ -46,7 +46,9 @@ Tuhle `.onion` adresu pak nastav v klientu (`app/lib/core/constants/app_constant
|---|---| |---|---|
| `POSTGRES_PASSWORD` | heslo DB — **povinné**, nikdy necommituj | | `POSTGRES_PASSWORD` | heslo DB — **povinné**, nikdy necommituj |
| `SEPOT_APNS_*` | volitelné, jen pro iOS APNs push (Key ID, Team ID, cesta ke klíči) | | `SEPOT_APNS_*` | volitelné, jen pro iOS APNs push (Key ID, Team ID, cesta ke klíči) |
| FCM service account | volitelné, jen pro Android FCM „fast mode" (mimo repo) | | FCM service account | volitelné, pro Android budíčky přes FCM (mimo repo) |
| `SEPOT_TURN_URLS` | volitelné, pro hovory: čárkou oddělené `turn:`/`turns:` adresy vašeho coturnu |
| `SEPOT_TURN_SECRET_FILE` / `SEPOT_TURN_SECRET` | sdílené tajemství coturnu (`use-auth-secret`, aspoň 32 znaků); bez něj relay TURN údaje nevydává a hovory jdou jen napřímo |
Push je volitelný. Bez něj appka funguje přes UnifiedPush (degooglované) nebo Push je volitelný. Bez něj appka funguje přes UnifiedPush (degooglované) nebo
si zprávy stáhne po otevření. si zprávy stáhne po otevření.
+24
View File
@@ -15,6 +15,13 @@ enum ContentHint {
CONTENT_HINT_DEFAULT = 0; // běžná zpráva CONTENT_HINT_DEFAULT = 0; // běžná zpráva
CONTENT_HINT_RESYNC = 1; // řízení retry po session poruše CONTENT_HINT_RESYNC = 1; // řízení retry po session poruše
CONTENT_HINT_SUPPLEMENTARY = 2; // doprovodná (receipt apod.) CONTENT_HINT_SUPPLEMENTARY = 2; // doprovodná (receipt apod.)
// Jen živé doručení (např. „píše…“): offline příjemci se neukládá do
// fronty ani nebudí pushem — zastaralá by po vyzvednutí jen mátla.
CONTENT_HINT_EPHEMERAL = 3;
// Nabídka hovoru: ukládá se jako běžná zpráva, ale offline příjemce se
// budí VoIP pushem (iOS) nebo FCM s krátkou platností — zvonit má hned.
// Relay se tím dozví, že zařízení někdo volá; kdo, dál neví.
CONTENT_HINT_CALL = 4;
} }
message Envelope { message Envelope {
@@ -35,6 +42,7 @@ message Receipt {
STATUS_QUEUED = 0; STATUS_QUEUED = 0;
STATUS_DELIVERED_ONLINE = 1; STATUS_DELIVERED_ONLINE = 1;
STATUS_STORED_OFFLINE = 2; STATUS_STORED_OFFLINE = 2;
STATUS_DROPPED = 3; // EPHEMERAL a příjemce nebyl online
} }
bytes msg_uuid = 1; bytes msg_uuid = 1;
Status status = 2; Status status = 2;
@@ -91,6 +99,20 @@ message RegisterPush {
// UnifiedPush/WebPush endpoint URL (utf-8 bytes), nebo FCM/APNs token. // UnifiedPush/WebPush endpoint URL (utf-8 bytes), nebo FCM/APNs token.
// Prázdné, když platform=NONE. // Prázdné, když platform=NONE.
bytes endpoint = 2; bytes endpoint = 2;
// iOS: PushKit VoIP token pro příchozí hovory (vedle běžného APNs tokenu
// v `endpoint`). Prázdné = zařízení VoIP push nemá.
bytes voip_token = 3;
}
// Dočasné údaje k TURN serveru pro hovory (REST schéma coturnu). Vydává je
// relay jen přihlášenému zařízení, aby TURN nebyl otevřený pro kohokoli.
message TurnRequest {}
message TurnCredentials {
repeated string urls = 1; // turn:/turns: adresy
string username = 2; // "<unix expirace>:<náhodné id>"
string credential = 3; // base64(HMAC-SHA1(secret, username))
uint32 ttl_seconds = 4;
} }
// ── Prekey typy (REST /v1/register, /v1/keys — sdílené s bundle fetch) ─ // ── Prekey typy (REST /v1/register, /v1/keys — sdílené s bundle fetch) ─
@@ -137,6 +159,7 @@ message ClientFrame {
Ack ack = 4; Ack ack = 4;
Pong pong = 5; Pong pong = 5;
RegisterPush register_push = 6; RegisterPush register_push = 6;
TurnRequest turn_request = 7;
} }
} }
@@ -149,5 +172,6 @@ message ServerFrame {
Ping ping = 5; Ping ping = 5;
Error error = 6; Error error = 6;
AuthOk auth_ok = 7; AuthOk auth_ok = 7;
TurnCredentials turn_credentials = 8;
} }
} }
+3
View File
@@ -27,6 +27,9 @@ ipnet = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
base64 = "0.22" base64 = "0.22"
# TURN údaje pro hovory: coturn ověřuje HMAC-SHA1 (REST schéma use-auth-secret)
hmac = "0.12"
sha1 = "0.10"
hex = "0.4" hex = "0.4"
chrono = "0.4" chrono = "0.4"
rand = "0.9" rand = "0.9"
@@ -0,0 +1,4 @@
-- iOS PushKit token pro příchozí hovory. Vedle běžného tokenu, ne místo něj:
-- VoIP push smí nést jen hovor (iOS jinak appce VoIP pushe zastaví), zprávy
-- dál budí běžný APNs.
ALTER TABLE push_tokens ADD COLUMN voip_token BYTEA;
+49
View File
@@ -35,6 +35,11 @@ fn wake_expiration(now: DateTime<Utc>) -> i64 {
(now + chrono::Duration::days(crate::state::QUEUE_TTL_DAYS)).timestamp() (now + chrono::Duration::days(crate::state::QUEUE_TTL_DAYS)).timestamp()
} }
/// Jak dlouho může zvonit hovor, který telefon nezastihl hned. Stejná mez
/// jako u klienta: nabídka starší než tohle se už nezvoní, jen zapíše jako
/// zmeškaná.
pub const CALL_RING_SECONDS: i64 = 45;
#[derive(Serialize)] #[derive(Serialize)]
struct Claims<'a> { struct Claims<'a> {
iss: &'a str, iss: &'a str,
@@ -148,6 +153,50 @@ impl ApnsSender {
_ => {} _ => {}
} }
} }
/// VoIP push (PushKit) pro příchozí hovor. iOS po něm MUSÍ hned ukázat
/// hovor v CallKit — to zařídí appka. Topic je `<bundle>.voip`, priorita
/// 10 (u typu voip povolená) a krátká expirace: zvonit po minutě nemá
/// smysl.
pub async fn send_voip_call(&self, client: &reqwest::Client, voip_token: &str) {
let Some(jwt) = self.auth_token().await else {
return;
};
let url = format!("{}/3/device/{}", self.host, voip_token);
let expiration = Utc::now().timestamp() + CALL_RING_SECONDS;
let resp = client
.post(&url)
.header("authorization", format!("bearer {jwt}"))
.header("apns-topic", format!("{}.voip", self.topic))
.header("apns-push-type", "voip")
.header("apns-priority", "10")
.header("apns-expiration", expiration.to_string())
.body(r#"{"aps":{},"k":"call"}"#)
.send()
.await;
match resp {
Ok(r) if !r.status().is_success() => {
tracing::warn!(status = %r.status(), "APNs VoIP push odmítnut");
}
Err(e) => tracing::warn!("APNs VoIP push selhal: {e}"),
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn budicek_drzi_apns_stejne_dlouho_jako_fronta() {
let ted = DateTime::parse_from_rfc3339("2026-09-29T18:00:00Z")
.unwrap()
.with_timezone(&Utc);
let limit = wake_expiration(ted) - ted.timestamp();
assert_eq!(limit, crate::state::QUEUE_TTL_DAYS * 86_400);
assert!(limit <= 30 * 86_400, "APNs nedrží notifikace déle než 30 dní");
}
} }
#[cfg(test)] #[cfg(test)]
+27 -7
View File
@@ -129,13 +129,6 @@ impl FcmSender {
/// Pošle budíček na FCM registrační token. Best-effort; loguje se jen /// Pošle budíček na FCM registrační token. Best-effort; loguje se jen
/// stavový kód (nikdy token). /// stavový kód (nikdy token).
pub async fn send_wake(&self, http: &reqwest::Client, device_token: &str) { pub async fn send_wake(&self, http: &reqwest::Client, device_token: &str) {
let Some(access) = self.access_token(http).await else {
return;
};
let url = format!(
"https://fcm.googleapis.com/v1/projects/{}/messages:send",
self.project_id
);
let body = serde_json::json!({ let body = serde_json::json!({
"message": { "message": {
"token": device_token, "token": device_token,
@@ -143,6 +136,33 @@ impl FcmSender {
"android": { "priority": "HIGH" } "android": { "priority": "HIGH" }
} }
}); });
self.send(http, body).await;
}
/// Budíček pro příchozí hovor: `k=call` spustí na telefonu zvonění
/// a platnost je krátká — zmeškaný hovor po návratu do sítě nezvoní.
pub async fn send_call_wake(&self, http: &reqwest::Client, device_token: &str) {
let body = serde_json::json!({
"message": {
"token": device_token,
"data": { "v": "1", "k": "call" },
"android": {
"priority": "HIGH",
"ttl": format!("{}s", crate::apns::CALL_RING_SECONDS)
}
}
});
self.send(http, body).await;
}
async fn send(&self, http: &reqwest::Client, body: serde_json::Value) {
let Some(access) = self.access_token(http).await else {
return;
};
let url = format!(
"https://fcm.googleapis.com/v1/projects/{}/messages:send",
self.project_id
);
// warn, ne debug: relay běží na RUST_LOG=info, takže na debugu bylo // 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 // selhání pushe neviditelné a tiše se hromadila fronta. Protistranou
// je Google, ne telefon uživatele — chyba tu znamená mrtvý token nebo // je Google, ne telefon uživatele — chyba tu znamená mrtvý token nebo
+1
View File
@@ -13,6 +13,7 @@ mod net;
mod push; mod push;
mod rest; mod rest;
mod state; mod state;
mod turn;
mod ws; mod ws;
use std::net::SocketAddr; use std::net::SocketAddr;
+40
View File
@@ -3,6 +3,10 @@
//! Klient se po probuzení sám připojí na WS a vyzvedne frontu sealed-sender //! Klient se po probuzení sám připojí na WS a vyzvedne frontu sealed-sender
//! kanálem. Endpoint se nikde neloguje. //! kanálem. Endpoint se nikde neloguje.
//! //!
//! Jediná výjimka je příchozí hovor (`ContentHint::Call`): budíček nese
//! `"k":"call"`, aby telefon začal zvonit hned (CallKit / celoobrazovkové
//! zvonění) místo tichého probuzení. Kdo volá, v něm dál není.
//!
//! Podporované cesty: //! Podporované cesty:
//! - UnifiedPush (3) / WebPush (4): HTTP POST na endpoint URL distributora. //! - UnifiedPush (3) / WebPush (4): HTTP POST na endpoint URL distributora.
//! - FCM (1) / APNs (2): opt-in „fast mode" — vyžaduje externí credentials //! - FCM (1) / APNs (2): opt-in „fast mode" — vyžaduje externí credentials
@@ -31,6 +35,42 @@ pub const PLATFORM_MAX: i16 = 4;
/// není co chránit. /// není co chránit.
const WAKE_BODY: &str = "{\"v\":1}"; const WAKE_BODY: &str = "{\"v\":1}";
/// Push cíl zařízení včetně iOS VoIP tokenu (jen pro hovory).
pub struct PushTarget {
pub platform: i16,
pub token: Option<Vec<u8>>,
pub voip_token: Option<Vec<u8>>,
}
/// Budíček pro příchozí hovor. iPhone s VoIP tokenem → VoIP push (jediná
/// cesta, jak iOS nechá zazvonit zabitou appku); Android FCM → budíček
/// `k=call` s krátkou platností; ostatní (UnifiedPush, starší iOS appka)
/// → běžný budíček, zazvoní až po vyzvednutí fronty.
pub async fn send_call_wake(
client: &Client,
target: &PushTarget,
fcm: Option<&crate::fcm::FcmSender>,
apns: Option<&crate::apns::ApnsSender>,
) {
if let (Some(sender), Some(Ok(voip))) = (
apns,
target.voip_token.as_deref().map(std::str::from_utf8),
) {
sender.send_voip_call(client, voip).await;
return;
}
let Some(token) = target.token.as_deref() else {
return;
};
if target.platform == PLATFORM_FCM {
if let (Some(sender), Ok(device_token)) = (fcm, std::str::from_utf8(token)) {
sender.send_call_wake(client, device_token).await;
}
return;
}
send_wake(client, target.platform, token, fcm, apns).await;
}
/// HTTP klient pro budíčky: bez redirectů (anti-SSRF přes 3xx), krátký /// HTTP klient pro budíčky: bez redirectů (anti-SSRF přes 3xx), krátký
/// timeout, generický UA. /// timeout, generický UA.
pub fn build_client() -> Client { pub fn build_client() -> Client {
+170 -6
View File
@@ -124,6 +124,8 @@ pub struct AppState {
pub fcm: Option<std::sync::Arc<crate::fcm::FcmSender>>, pub fcm: Option<std::sync::Arc<crate::fcm::FcmSender>>,
/// APNs nativní iOS push (None = nenakonfigurováno). /// APNs nativní iOS push (None = nenakonfigurováno).
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á).
pub turn: Option<crate::turn::TurnConfig>,
} }
impl AppState { impl AppState {
@@ -144,6 +146,7 @@ impl AppState {
attachments, attachments,
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(),
} }
} }
@@ -162,6 +165,10 @@ impl AppState {
anyhow::bail!("neplatná obálka"); anyhow::bail!("neplatná obálka");
} }
if env.content_hint == v1::ContentHint::Ephemeral as i32 {
return Ok(self.route_ephemeral(env).await);
}
let dst = env.dst_address.clone(); let dst = env.dst_address.clone();
let encoded = env.encode_to_vec(); let encoded = env.encode_to_vec();
let expires = Utc::now() + Duration::days(QUEUE_TTL_DAYS); let expires = Utc::now() + Duration::days(QUEUE_TTL_DAYS);
@@ -179,6 +186,7 @@ impl AppState {
// 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, MAX_QUEUE_BYTES_PER_RECIPIENT).await?;
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) {
let deliver = v1::ServerFrame { let deliver = v1::ServerFrame {
frame: Some(v1::server_frame::Frame::Deliver(env)), frame: Some(v1::server_frame::Frame::Deliver(env)),
@@ -194,10 +202,34 @@ impl AppState {
} }
} }
// Offline, zatuchlé nebo selhalý kanál → zkusit push budíček. // Offline, zatuchlé nebo selhalý kanál → zkusit push budíček.
self.wake(&dst).await; if is_call {
self.wake_call(&dst).await;
} else {
self.wake(&dst).await;
}
Ok(v1::receipt::Status::StoredOffline) Ok(v1::receipt::Status::StoredOffline)
} }
/// 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ě
/// a odesílatel se má dozvědět, že příjemce nikdo neposlouchá.
async fn route_ephemeral(&self, env: v1::Envelope) -> v1::receipt::Status {
let Some(dev) = self.online.get(&env.dst_address) else {
return v1::receipt::Status::Dropped;
};
if !dev.is_fresh(self.now_ms()) {
return v1::receipt::Status::Dropped;
}
let deliver = v1::ServerFrame {
frame: Some(v1::server_frame::Frame::Deliver(env)),
};
if dev.tx.send(deliver).await.is_ok() {
v1::receipt::Status::DeliveredOnline
} else {
v1::receipt::Status::Dropped
}
}
/// 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ší), starší přebytek zahoď. Vrací počet
/// zahozených. Oddělené kvůli přímému testu bez vkládání 50 MiB. /// zahozených. Oddělené kvůli přímému testu bez vkládání 50 MiB.
@@ -242,6 +274,48 @@ impl AppState {
}); });
} }
/// Budíček pro příchozí hovor: iPhone s VoIP tokenem dostane VoIP push
/// (CallKit zazvoní i u zabité appky), ostatní běžný budíček — FCM
/// s krátkou platností, ať po hodině nezvoní hovor, který dávno skončil.
async fn wake_call(&self, dst_address: &[u8]) {
let Some(target) = self.push_target_full(dst_address).await else {
return;
};
let client = self.http.clone();
let fcm = self.fcm.clone();
let apns = self.apns.clone();
tokio::spawn(async move {
crate::push::send_call_wake(&client, &target, fcm.as_deref(), apns.as_deref())
.await;
});
}
/// Push cíl včetně VoIP tokenu (hovory).
pub(crate) async fn push_target_full(
&self,
dst_address: &[u8],
) -> Option<crate::push::PushTarget> {
let row: Option<(i16, Option<Vec<u8>>, Option<Vec<u8>>)> = sqlx::query_as(
"SELECT p.platform, p.token, p.voip_token FROM push_tokens p
JOIN devices d USING (device_uuid)
WHERE d.delivery_address = $1",
)
.bind(dst_address)
.fetch_optional(&self.pool)
.await
.ok()
.flatten();
let (platform, token, voip_token) = row?;
if token.is_none() && voip_token.is_none() {
return None;
}
Some(crate::push::PushTarget {
platform,
token,
voip_token,
})
}
/// Dohledá push cíl (platform, token) pro delivery_address příjemce. /// Dohledá push cíl (platform, token) pro delivery_address příjemce.
/// `pub(crate)` kvůli integračnímu testu correctness JOINu. /// `pub(crate)` kvůli integračnímu testu correctness JOINu.
pub(crate) async fn push_target(&self, dst_address: &[u8]) -> Option<(i16, Vec<u8>)> { pub(crate) async fn push_target(&self, dst_address: &[u8]) -> Option<(i16, Vec<u8>)> {
@@ -268,6 +342,7 @@ impl AppState {
device_uuid: &[u8], device_uuid: &[u8],
platform: i16, platform: i16,
endpoint: Option<&[u8]>, endpoint: Option<&[u8]>,
voip_token: Option<&[u8]>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
if platform == crate::push::PLATFORM_NONE || endpoint.is_none() { if platform == crate::push::PLATFORM_NONE || endpoint.is_none() {
sqlx::query("DELETE FROM push_tokens WHERE device_uuid = $1") sqlx::query("DELETE FROM push_tokens WHERE device_uuid = $1")
@@ -277,16 +352,18 @@ impl AppState {
return Ok(()); return Ok(());
} }
sqlx::query( sqlx::query(
"INSERT INTO push_tokens (device_uuid, platform, token, updated_at) "INSERT INTO push_tokens (device_uuid, platform, token, voip_token, updated_at)
VALUES ($1, $2, $3, now()) VALUES ($1, $2, $3, $4, now())
ON CONFLICT (device_uuid) ON CONFLICT (device_uuid)
DO UPDATE SET platform = excluded.platform, DO UPDATE SET platform = excluded.platform,
token = excluded.token, token = excluded.token,
voip_token = excluded.voip_token,
updated_at = now()", updated_at = now()",
) )
.bind(device_uuid) .bind(device_uuid)
.bind(platform) .bind(platform)
.bind(endpoint) .bind(endpoint)
.bind(voip_token)
.execute(&self.pool) .execute(&self.pool)
.await?; .await?;
Ok(()) Ok(())
@@ -556,7 +633,7 @@ mod db_tests {
// UnifiedPush endpoint // UnifiedPush endpoint
let ep1 = b"https://ntfy.sh/UPabc".to_vec(); let ep1 = b"https://ntfy.sh/UPabc".to_vec();
state state
.register_push(&device_uuid, crate::push::PLATFORM_UNIFIEDPUSH, Some(&ep1)) .register_push(&device_uuid, crate::push::PLATFORM_UNIFIEDPUSH, Some(&ep1), None)
.await .await
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
@@ -567,7 +644,7 @@ mod db_tests {
// upsert na WebPush (jiná platforma i token) // upsert na WebPush (jiná platforma i token)
let ep2 = b"https://push.example/w".to_vec(); let ep2 = b"https://push.example/w".to_vec();
state state
.register_push(&device_uuid, crate::push::PLATFORM_WEBPUSH, Some(&ep2)) .register_push(&device_uuid, crate::push::PLATFORM_WEBPUSH, Some(&ep2), None)
.await .await
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
@@ -577,7 +654,7 @@ mod db_tests {
// NONE → smaže (anonymní režim) // NONE → smaže (anonymní režim)
state state
.register_push(&device_uuid, crate::push::PLATFORM_NONE, None) .register_push(&device_uuid, crate::push::PLATFORM_NONE, None, None)
.await .await
.unwrap(); .unwrap();
assert!(state.push_target(&delivery_address).await.is_none()); assert!(state.push_target(&delivery_address).await.is_none());
@@ -585,6 +662,64 @@ mod db_tests {
cleanup(&state, &account_id, &delivery_address).await; cleanup(&state, &account_id, &delivery_address).await;
} }
#[tokio::test]
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
async fn voip_token_se_ulozi_a_hovor_jde_do_fronty() {
let state = test_state().await;
let (account_id, device_uuid, delivery_address) = seed_device(&state).await;
state
.register_push(
&device_uuid,
crate::push::PLATFORM_APNS,
Some(b"apns-token"),
Some(b"voip-token"),
)
.await
.unwrap();
let target = state.push_target_full(&delivery_address).await.unwrap();
assert_eq!(target.platform, crate::push::PLATFORM_APNS);
assert_eq!(target.token.as_deref(), Some(&b"apns-token"[..]));
assert_eq!(target.voip_token.as_deref(), Some(&b"voip-token"[..]));
// Běžné zprávy dál budí běžný token, ne VoIP.
assert_eq!(
state.push_target(&delivery_address).await,
Some((crate::push::PLATFORM_APNS, b"apns-token".to_vec()))
);
// Nabídka hovoru offline příjemci: uloží se jako běžná obálka
// (vyzvedne si ji po probuzení), jen budíček je jiný.
let env = v1::Envelope {
dst_address: delivery_address.clone(),
msg_uuid: rand_bytes(16),
content_hint: v1::ContentHint::Call as i32,
payload: vec![0xCD; 64],
};
let status = state.route_envelope(env).await.unwrap();
assert_eq!(status, v1::receipt::Status::StoredOffline);
let queued: i64 =
sqlx::query_scalar("SELECT count(*) FROM message_queue WHERE dst_address = $1")
.bind(&delivery_address)
.fetch_one(&state.pool)
.await
.unwrap();
assert_eq!(queued, 1);
// Nová registrace bez VoIP tokenu ho smaže (appka PushKit vypnula).
state
.register_push(&device_uuid, crate::push::PLATFORM_APNS, Some(b"apns-token"), None)
.await
.unwrap();
assert!(state
.push_target_full(&delivery_address)
.await
.unwrap()
.voip_token
.is_none());
cleanup(&state, &account_id, &delivery_address).await;
}
#[tokio::test] #[tokio::test]
#[ignore = "vyžaduje běžící Postgres (dev DB)"] #[ignore = "vyžaduje běžící Postgres (dev DB)"]
async fn route_envelope_offline_ulozi_a_spusti_wake() { async fn route_envelope_offline_ulozi_a_spusti_wake() {
@@ -598,6 +733,7 @@ mod db_tests {
&device_uuid, &device_uuid,
crate::push::PLATFORM_UNIFIEDPUSH, crate::push::PLATFORM_UNIFIEDPUSH,
Some(b"https://localhost/up"), Some(b"https://localhost/up"),
None,
) )
.await .await
.unwrap(); .unwrap();
@@ -623,6 +759,33 @@ mod db_tests {
cleanup(&state, &account_id, &delivery_address).await; cleanup(&state, &account_id, &delivery_address).await;
} }
#[tokio::test]
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
async fn ephemeral_offline_se_zahodi_bez_fronty() {
let state = test_state().await;
let (account_id, _device_uuid, delivery_address) = seed_device(&state).await;
let env = v1::Envelope {
dst_address: delivery_address.clone(),
msg_uuid: rand_bytes(16),
content_hint: v1::ContentHint::Ephemeral as i32,
payload: vec![0xAB; 64],
};
let status = state.route_envelope(env).await.unwrap();
assert_eq!(status, v1::receipt::Status::Dropped);
// „píše…“ pro offline příjemce nesmí skončit ve frontě
let queued: i64 =
sqlx::query_scalar("SELECT count(*) FROM message_queue WHERE dst_address = $1")
.bind(&delivery_address)
.fetch_one(&state.pool)
.await
.unwrap();
assert_eq!(queued, 0);
cleanup(&state, &account_id, &delivery_address).await;
}
#[tokio::test] #[tokio::test]
#[ignore = "vyžaduje běžící Postgres (dev DB)"] #[ignore = "vyžaduje běžící Postgres (dev DB)"]
async fn delete_identity_smaze_ucet_frontu_i_push() { async fn delete_identity_smaze_ucet_frontu_i_push() {
@@ -635,6 +798,7 @@ mod db_tests {
&device_uuid, &device_uuid,
crate::push::PLATFORM_UNIFIEDPUSH, crate::push::PLATFORM_UNIFIEDPUSH,
Some(b"https://localhost/up"), Some(b"https://localhost/up"),
None,
) )
.await .await
.unwrap(); .unwrap();
+105
View File
@@ -0,0 +1,105 @@
//! Dočasné údaje k TURN serveru pro hovory (REST schéma coturnu,
//! `use-auth-secret`). Relay i coturn sdílí tajemství; coturn údaje ověří
//! sám, bez dotazu na relay, a po expiraci je odmítne.
//!
//! Údaje dostane jen přihlášené zařízení přes WS — TURN tak neslouží jako
//! otevřené přeposílání provozu pro kohokoli z internetu.
use base64::Engine;
use hmac::{Hmac, Mac};
use rand::Rng;
use sepot_proto::v1;
use sha1::Sha1;
/// Platnost údajů. Pokrývá i dlouhý hovor — coturn kontroluje expiraci jen
/// při alokaci a jejím obnovení, ne u běžícího hovoru.
pub const TTL_SECONDS: u32 = 24 * 3600;
pub struct TurnConfig {
secret: Vec<u8>,
urls: Vec<String>,
}
impl TurnConfig {
/// Tajemství z `SEPOT_TURN_SECRET_FILE` (soubor mimo compose, stejně
/// jako FCM a APNs klíče) nebo `SEPOT_TURN_SECRET`, adresy z `SEPOT_TURN_URLS`
/// (čárkou oddělené turn:/turns:). Bez nich = hovory jen napřímo / přes
/// STUN, relay údaje nevydává.
pub fn from_env() -> Option<Self> {
let secret = match std::env::var("SEPOT_TURN_SECRET_FILE") {
Ok(path) if !path.is_empty() => std::fs::read_to_string(&path)
.map_err(|e| tracing::warn!("TURN tajemství nejde přečíst: {e}"))
.ok()?
.trim()
.to_string(),
_ => std::env::var("SEPOT_TURN_SECRET").ok()?,
};
let urls: Vec<String> = std::env::var("SEPOT_TURN_URLS")
.ok()?
.split(',')
.map(str::trim)
.filter(|u| u.starts_with("turn:") || u.starts_with("turns:"))
.map(String::from)
.collect();
if secret.len() < 32 || urls.is_empty() {
tracing::warn!("TURN nenakonfigurován správně (secret < 32 znaků nebo bez URL)");
return None;
}
tracing::info!(urls = urls.len(), "TURN údaje pro hovory aktivní");
Some(Self::new(secret.into_bytes(), urls))
}
pub fn new(secret: Vec<u8>, urls: Vec<String>) -> Self {
Self { secret, urls }
}
/// Údaje platné `TTL_SECONDS` od `now_unix`. Náhodná část jména, ne id
/// zařízení — coturn si jména loguje a nemá z nich poznat, kdo volá.
pub fn credentials(&self, now_unix: i64) -> v1::TurnCredentials {
let expires = now_unix + i64::from(TTL_SECONDS);
let nonce: u64 = rand::rng().random();
let username = format!("{expires}:{nonce:016x}");
v1::TurnCredentials {
urls: self.urls.clone(),
credential: sign(&self.secret, &username),
username,
ttl_seconds: TTL_SECONDS,
}
}
}
/// base64(HMAC-SHA1(secret, username)) — přesně to, co počítá coturn.
fn sign(secret: &[u8], username: &str) -> String {
let mut mac =
Hmac::<Sha1>::new_from_slice(secret).expect("HMAC bere klíč libovolné délky");
mac.update(username.as_bytes());
base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn podpis_odpovida_referencni_hodnote() {
// Referenční hodnota: printf '%s' '1700000000:abc' |
// openssl dgst -sha1 -hmac 'tajne' -binary | base64
assert_eq!(sign(b"tajne", "1700000000:abc"), "2PhtGxNe/gV7QGWYKw750CWo8fw=");
}
#[test]
fn udaje_nesou_expiraci_a_sedi_k_podpisu() {
let cfg = TurnConfig::new(b"x".repeat(32), vec!["turn:t.example:3478".into()]);
let c = cfg.credentials(1_000);
let (exp, _) = c.username.split_once(':').unwrap();
assert_eq!(exp.parse::<i64>().unwrap(), 1_000 + i64::from(TTL_SECONDS));
assert_eq!(c.credential, sign(&b"x".repeat(32), &c.username));
assert_eq!(c.urls, vec!["turn:t.example:3478".to_string()]);
}
#[test]
fn jmena_se_neopakuji() {
let cfg = TurnConfig::new(b"x".repeat(32), vec!["turn:t:3478".into()]);
assert_ne!(cfg.credentials(1).username, cfg.credentials(1).username);
}
}
+15 -1
View File
@@ -314,9 +314,13 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
} else { } else {
let endpoint = let endpoint =
(!reg.endpoint.is_empty()).then_some(reg.endpoint.as_slice()); (!reg.endpoint.is_empty()).then_some(reg.endpoint.as_slice());
// VoIP token dává smysl jen vedle APNs (PushKit je iOS).
let voip = (platform == crate::push::PLATFORM_APNS
&& !reg.voip_token.is_empty())
.then_some(reg.voip_token.as_slice());
// Endpoint se ZÁMĚRNĚ neloguje (metadatový bod). // Endpoint se ZÁMĚRNĚ neloguje (metadatový bod).
if let Err(e) = if let Err(e) =
state.register_push(device, platform, endpoint).await state.register_push(device, platform, endpoint, voip).await
{ {
tracing::warn!("register_push selhal: {e}"); tracing::warn!("register_push selhal: {e}");
} }
@@ -351,6 +355,16 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
} }
} }
Some(v1::client_frame::Frame::TurnRequest(_)) if authed_address.is_some() => {
let frame = match &state.turn {
Some(turn) => server_frame(v1::server_frame::Frame::TurnCredentials(
turn.credentials(chrono::Utc::now().timestamp()),
)),
None => error_frame(503, "TURN není nakonfigurován"),
};
let _ = tx.send(frame).await;
}
Some(v1::client_frame::Frame::Pong(_)) => {} Some(v1::client_frame::Frame::Pong(_)) => {}
_ => { _ => {