diff --git a/app/lib/proto/sepot.pb.dart b/app/lib/proto/sepot.pb.dart index 5456d22..86e4299 100644 --- a/app/lib/proto/sepot.pb.dart +++ b/app/lib/proto/sepot.pb.dart @@ -650,10 +650,12 @@ class RegisterPush extends $pb.GeneratedMessage { factory RegisterPush({ RegisterPush_Platform? platform, $core.List<$core.int>? endpoint, + $core.List<$core.int>? voipToken, }) { final result = create(); if (platform != null) result.platform = platform; if (endpoint != null) result.endpoint = endpoint; + if (voipToken != null) result.voipToken = voipToken; return result; } @@ -674,6 +676,8 @@ class RegisterPush extends $pb.GeneratedMessage { enumValues: RegisterPush_Platform.values) ..a<$core.List<$core.int>>( 2, _omitFieldNames ? '' : 'endpoint', $pb.PbFieldType.OY) + ..a<$core.List<$core.int>>( + 3, _omitFieldNames ? '' : 'voipToken', $pb.PbFieldType.OY) ..hasRequiredFields = false; @$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); @$pb.TagNumber(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(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(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 { @@ -1137,6 +1276,7 @@ enum ClientFrame_Frame { ack, pong, registerPush, + turnRequest, notSet } @@ -1148,6 +1288,7 @@ class ClientFrame extends $pb.GeneratedMessage { Ack? ack, Pong? pong, RegisterPush? registerPush, + TurnRequest? turnRequest, }) { final result = create(); if (auth != null) result.auth = auth; @@ -1156,6 +1297,7 @@ class ClientFrame extends $pb.GeneratedMessage { if (ack != null) result.ack = ack; if (pong != null) result.pong = pong; if (registerPush != null) result.registerPush = registerPush; + if (turnRequest != null) result.turnRequest = turnRequest; return result; } @@ -1176,13 +1318,14 @@ class ClientFrame extends $pb.GeneratedMessage { 4: ClientFrame_Frame.ack, 5: ClientFrame_Frame.pong, 6: ClientFrame_Frame.registerPush, + 7: ClientFrame_Frame.turnRequest, 0: ClientFrame_Frame.notSet }; static final $pb.BuilderInfo _i = $pb.BuilderInfo( _omitMessageNames ? '' : 'ClientFrame', package: const $pb.PackageName(_omitMessageNames ? '' : 'sepot.v1'), createEmptyInstance: create) - ..oo(0, [1, 2, 3, 4, 5, 6]) + ..oo(0, [1, 2, 3, 4, 5, 6, 7]) ..aOM(1, _omitFieldNames ? '' : 'auth', subBuilder: Auth.create) ..aOM(2, _omitFieldNames ? '' : 'authProof', subBuilder: AuthProof.create) @@ -1192,6 +1335,8 @@ class ClientFrame extends $pb.GeneratedMessage { ..aOM(5, _omitFieldNames ? '' : 'pong', subBuilder: Pong.create) ..aOM(6, _omitFieldNames ? '' : 'registerPush', subBuilder: RegisterPush.create) + ..aOM(7, _omitFieldNames ? '' : 'turnRequest', + subBuilder: TurnRequest.create) ..hasRequiredFields = false; @$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(5) @$pb.TagNumber(6) + @$pb.TagNumber(7) ClientFrame_Frame whichFrame() => _ClientFrame_FrameByTag[$_whichOneof(0)]!; @$pb.TagNumber(1) @$pb.TagNumber(2) @@ -1226,6 +1372,7 @@ class ClientFrame extends $pb.GeneratedMessage { @$pb.TagNumber(4) @$pb.TagNumber(5) @$pb.TagNumber(6) + @$pb.TagNumber(7) void clearFrame() => $_clearField($_whichOneof(0)); @$pb.TagNumber(1) @@ -1293,6 +1440,17 @@ class ClientFrame extends $pb.GeneratedMessage { void clearRegisterPush() => $_clearField(6); @$pb.TagNumber(6) 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 { @@ -1303,6 +1461,7 @@ enum ServerFrame_Frame { ping, error, authOk, + turnCredentials, notSet } @@ -1315,6 +1474,7 @@ class ServerFrame extends $pb.GeneratedMessage { Ping? ping, Error? error, AuthOk? authOk, + TurnCredentials? turnCredentials, }) { final result = create(); if (challenge != null) result.challenge = challenge; @@ -1324,6 +1484,7 @@ class ServerFrame extends $pb.GeneratedMessage { if (ping != null) result.ping = ping; if (error != null) result.error = error; if (authOk != null) result.authOk = authOk; + if (turnCredentials != null) result.turnCredentials = turnCredentials; return result; } @@ -1345,13 +1506,14 @@ class ServerFrame extends $pb.GeneratedMessage { 5: ServerFrame_Frame.ping, 6: ServerFrame_Frame.error, 7: ServerFrame_Frame.authOk, + 8: ServerFrame_Frame.turnCredentials, 0: ServerFrame_Frame.notSet }; static final $pb.BuilderInfo _i = $pb.BuilderInfo( _omitMessageNames ? '' : 'ServerFrame', package: const $pb.PackageName(_omitMessageNames ? '' : 'sepot.v1'), createEmptyInstance: create) - ..oo(0, [1, 2, 3, 4, 5, 6, 7]) + ..oo(0, [1, 2, 3, 4, 5, 6, 7, 8]) ..aOM(1, _omitFieldNames ? '' : 'challenge', subBuilder: Challenge.create) ..aOM(2, _omitFieldNames ? '' : 'receipt', @@ -1363,6 +1525,8 @@ class ServerFrame extends $pb.GeneratedMessage { ..aOM(5, _omitFieldNames ? '' : 'ping', subBuilder: Ping.create) ..aOM(6, _omitFieldNames ? '' : 'error', subBuilder: Error.create) ..aOM(7, _omitFieldNames ? '' : 'authOk', subBuilder: AuthOk.create) + ..aOM(8, _omitFieldNames ? '' : 'turnCredentials', + subBuilder: TurnCredentials.create) ..hasRequiredFields = false; @$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(6) @$pb.TagNumber(7) + @$pb.TagNumber(8) ServerFrame_Frame whichFrame() => _ServerFrame_FrameByTag[$_whichOneof(0)]!; @$pb.TagNumber(1) @$pb.TagNumber(2) @@ -1399,6 +1564,7 @@ class ServerFrame extends $pb.GeneratedMessage { @$pb.TagNumber(5) @$pb.TagNumber(6) @$pb.TagNumber(7) + @$pb.TagNumber(8) void clearFrame() => $_clearField($_whichOneof(0)); @$pb.TagNumber(1) @@ -1477,6 +1643,17 @@ class ServerFrame extends $pb.GeneratedMessage { void clearAuthOk() => $_clearField(7); @$pb.TagNumber(7) 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 = diff --git a/app/lib/proto/sepot.pbenum.dart b/app/lib/proto/sepot.pbenum.dart index a0fb94a..3c90c41 100644 --- a/app/lib/proto/sepot.pbenum.dart +++ b/app/lib/proto/sepot.pbenum.dart @@ -22,14 +22,27 @@ class ContentHint extends $pb.ProtobufEnum { static const ContentHint 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 values = [ CONTENT_HINT_DEFAULT, CONTENT_HINT_RESYNC, CONTENT_HINT_SUPPLEMENTARY, + CONTENT_HINT_EPHEMERAL, + CONTENT_HINT_CALL, ]; static final $core.List _byValue = - $pb.ProtobufEnum.$_initByValueList(values, 2); + $pb.ProtobufEnum.$_initByValueList(values, 4); static ContentHint? valueOf($core.int 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'); static const Receipt_Status 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 values = [ STATUS_QUEUED, STATUS_DELIVERED_ONLINE, STATUS_STORED_OFFLINE, + STATUS_DROPPED, ]; static final $core.List _byValue = - $pb.ProtobufEnum.$_initByValueList(values, 2); + $pb.ProtobufEnum.$_initByValueList(values, 3); static Receipt_Status? valueOf($core.int value) => value < 0 || value >= _byValue.length ? null : _byValue[value]; diff --git a/app/lib/proto/sepot.pbjson.dart b/app/lib/proto/sepot.pbjson.dart index 11fa81c..7e545d0 100644 --- a/app/lib/proto/sepot.pbjson.dart +++ b/app/lib/proto/sepot.pbjson.dart @@ -22,13 +22,16 @@ const ContentHint$json = { {'1': 'CONTENT_HINT_DEFAULT', '2': 0}, {'1': 'CONTENT_HINT_RESYNC', '2': 1}, {'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`. final $typed_data.Uint8List contentHintDescriptor = $convert.base64Decode( 'CgtDb250ZW50SGludBIYChRDT05URU5UX0hJTlRfREVGQVVMVBAAEhcKE0NPTlRFTlRfSElOVF' - '9SRVNZTkMQARIeChpDT05URU5UX0hJTlRfU1VQUExFTUVOVEFSWRAC'); + '9SRVNZTkMQARIeChpDT05URU5UX0hJTlRfU1VQUExFTUVOVEFSWRACEhoKFkNPTlRFTlRfSElO' + 'VF9FUEhFTUVSQUwQAxIVChFDT05URU5UX0hJTlRfQ0FMTBAE'); @$core.Deprecated('Use envelopeDescriptor instead') const Envelope$json = { @@ -90,15 +93,16 @@ const Receipt_Status$json = { {'1': 'STATUS_QUEUED', '2': 0}, {'1': 'STATUS_DELIVERED_ONLINE', '2': 1}, {'1': 'STATUS_STORED_OFFLINE', '2': 2}, + {'1': 'STATUS_DROPPED', '2': 3}, ], }; /// Descriptor for `Receipt`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List receiptDescriptor = $convert.base64Decode( 'CgdSZWNlaXB0EhkKCG1zZ191dWlkGAEgASgMUgdtc2dVdWlkEjAKBnN0YXR1cxgCIAEoDjIYLn' - 'NlcG90LnYxLlJlY2VpcHQuU3RhdHVzUgZzdGF0dXMiUwoGU3RhdHVzEhEKDVNUQVRVU19RVUVV' + 'NlcG90LnYxLlJlY2VpcHQuU3RhdHVzUgZzdGF0dXMiZwoGU3RhdHVzEhEKDVNUQVRVU19RVUVV' 'RUQQABIbChdTVEFUVVNfREVMSVZFUkVEX09OTElORRABEhkKFVNUQVRVU19TVE9SRURfT0ZGTE' - 'lORRAC'); + 'lORRACEhIKDlNUQVRVU19EUk9QUEVEEAM='); @$core.Deprecated('Use authDescriptor instead') const Auth$json = { @@ -201,6 +205,7 @@ const RegisterPush$json = { '10': 'platform' }, {'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], }; @@ -220,9 +225,36 @@ const RegisterPush_Platform$json = { /// Descriptor for `RegisterPush`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List registerPushDescriptor = $convert.base64Decode( 'CgxSZWdpc3RlclB1c2gSOwoIcGxhdGZvcm0YASABKA4yHy5zZXBvdC52MS5SZWdpc3RlclB1c2' - 'guUGxhdGZvcm1SCHBsYXRmb3JtEhoKCGVuZHBvaW50GAIgASgMUghlbmRwb2ludCJyCghQbGF0' - 'Zm9ybRIRCg1QTEFURk9STV9OT05FEAASEAoMUExBVEZPUk1fRkNNEAESEQoNUExBVEZPUk1fQV' - 'BOUxACEhgKFFBMQVRGT1JNX1VOSUZJRURQVVNIEAMSFAoQUExBVEZPUk1fV0VCUFVTSBAE'); + 'guUGxhdGZvcm1SCHBsYXRmb3JtEhoKCGVuZHBvaW50GAIgASgMUghlbmRwb2ludBIdCgp2b2lw' + 'X3Rva2VuGAMgASgMUgl2b2lwVG9rZW4icgoIUGxhdGZvcm0SEQoNUExBVEZPUk1fTk9ORRAAEh' + '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') const SignedPreKey$json = { @@ -391,6 +423,15 @@ const ClientFrame$json = { '9': 0, '10': 'registerPush' }, + { + '1': 'turn_request', + '3': 7, + '4': 1, + '5': 11, + '6': '.sepot.v1.TurnRequest', + '9': 0, + '10': 'turnRequest' + }, ], '8': [ {'1': 'frame'}, @@ -404,7 +445,8 @@ final $typed_data.Uint8List clientFrameDescriptor = $convert.base64Decode( 'dmVsb3BlGAMgASgLMhIuc2Vwb3QudjEuRW52ZWxvcGVIAFIIZW52ZWxvcGUSIQoDYWNrGAQgAS' 'gLMg0uc2Vwb3QudjEuQWNrSABSA2FjaxIkCgRwb25nGAUgASgLMg4uc2Vwb3QudjEuUG9uZ0gA' 'UgRwb25nEj0KDXJlZ2lzdGVyX3B1c2gYBiABKAsyFi5zZXBvdC52MS5SZWdpc3RlclB1c2hIAF' - 'IMcmVnaXN0ZXJQdXNoQgcKBWZyYW1l'); + 'IMcmVnaXN0ZXJQdXNoEjoKDHR1cm5fcmVxdWVzdBgHIAEoCzIVLnNlcG90LnYxLlR1cm5SZXF1' + 'ZXN0SABSC3R1cm5SZXF1ZXN0QgcKBWZyYW1l'); @$core.Deprecated('Use serverFrameDescriptor instead') const ServerFrame$json = { @@ -473,6 +515,15 @@ const ServerFrame$json = { '9': 0, '10': 'authOk' }, + { + '1': 'turn_credentials', + '3': 8, + '4': 1, + '5': 11, + '6': '.sepot.v1.TurnCredentials', + '9': 0, + '10': 'turnCredentials' + }, ], '8': [ {'1': 'frame'}, @@ -487,4 +538,5 @@ final $typed_data.Uint8List serverFrameDescriptor = $convert.base64Decode( 'Jla2V5c19sb3cYBCABKAsyFC5zZXBvdC52MS5QcmVrZXlzTG93SABSCnByZWtleXNMb3cSJAoE' 'cGluZxgFIAEoCzIOLnNlcG90LnYxLlBpbmdIAFIEcGluZxInCgVlcnJvchgGIAEoCzIPLnNlcG' '90LnYxLkVycm9ySABSBWVycm9yEisKB2F1dGhfb2sYByABKAsyEC5zZXBvdC52MS5BdXRoT2tI' - 'AFIGYXV0aE9rQgcKBWZyYW1l'); + 'AFIGYXV0aE9rEkYKEHR1cm5fY3JlZGVudGlhbHMYCCABKAsyGS5zZXBvdC52MS5UdXJuQ3JlZG' + 'VudGlhbHNIAFIPdHVybkNyZWRlbnRpYWxzQgcKBWZyYW1l'); diff --git a/deploy/relay/README.md b/deploy/relay/README.md index 011e40c..87fb87d 100644 --- a/deploy/relay/README.md +++ b/deploy/relay/README.md @@ -46,7 +46,9 @@ Tuhle `.onion` adresu pak nastav v klientu (`app/lib/core/constants/app_constant |---|---| | `POSTGRES_PASSWORD` | heslo DB — **povinné**, nikdy necommituj | | `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 si zprávy stáhne po otevření. diff --git a/proto/sepot.proto b/proto/sepot.proto index b371dda..393b542 100644 --- a/proto/sepot.proto +++ b/proto/sepot.proto @@ -15,6 +15,13 @@ enum ContentHint { CONTENT_HINT_DEFAULT = 0; // běžná zpráva CONTENT_HINT_RESYNC = 1; // řízení retry po session poruše 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 { @@ -35,6 +42,7 @@ message Receipt { STATUS_QUEUED = 0; STATUS_DELIVERED_ONLINE = 1; STATUS_STORED_OFFLINE = 2; + STATUS_DROPPED = 3; // EPHEMERAL a příjemce nebyl online } bytes msg_uuid = 1; Status status = 2; @@ -91,6 +99,20 @@ message RegisterPush { // UnifiedPush/WebPush endpoint URL (utf-8 bytes), nebo FCM/APNs token. // Prázdné, když platform=NONE. 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; // ":" + string credential = 3; // base64(HMAC-SHA1(secret, username)) + uint32 ttl_seconds = 4; } // ── Prekey typy (REST /v1/register, /v1/keys — sdílené s bundle fetch) ─ @@ -137,6 +159,7 @@ message ClientFrame { Ack ack = 4; Pong pong = 5; RegisterPush register_push = 6; + TurnRequest turn_request = 7; } } @@ -149,5 +172,6 @@ message ServerFrame { Ping ping = 5; Error error = 6; AuthOk auth_ok = 7; + TurnCredentials turn_credentials = 8; } } diff --git a/rust/sepot-relay/Cargo.toml b/rust/sepot-relay/Cargo.toml index 94f1105..95fccde 100644 --- a/rust/sepot-relay/Cargo.toml +++ b/rust/sepot-relay/Cargo.toml @@ -27,6 +27,9 @@ ipnet = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" 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" chrono = "0.4" rand = "0.9" diff --git a/rust/sepot-relay/migrations/0004_push_voip_token.sql b/rust/sepot-relay/migrations/0004_push_voip_token.sql new file mode 100644 index 0000000..585cad8 --- /dev/null +++ b/rust/sepot-relay/migrations/0004_push_voip_token.sql @@ -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; diff --git a/rust/sepot-relay/src/apns.rs b/rust/sepot-relay/src/apns.rs index c48c0a0..d7672f3 100644 --- a/rust/sepot-relay/src/apns.rs +++ b/rust/sepot-relay/src/apns.rs @@ -35,6 +35,11 @@ fn wake_expiration(now: DateTime) -> i64 { (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)] struct Claims<'a> { 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 `.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)] diff --git a/rust/sepot-relay/src/fcm.rs b/rust/sepot-relay/src/fcm.rs index b2ab658..6ad586d 100644 --- a/rust/sepot-relay/src/fcm.rs +++ b/rust/sepot-relay/src/fcm.rs @@ -129,13 +129,6 @@ impl FcmSender { /// Pošle budíček na FCM registrační token. Best-effort; loguje se jen /// stavový kód (nikdy token). 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!({ "message": { "token": device_token, @@ -143,6 +136,33 @@ impl FcmSender { "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 // selhání pushe neviditelné a tiše se hromadila fronta. Protistranou // je Google, ne telefon uživatele — chyba tu znamená mrtvý token nebo diff --git a/rust/sepot-relay/src/main.rs b/rust/sepot-relay/src/main.rs index 11c655d..211b491 100644 --- a/rust/sepot-relay/src/main.rs +++ b/rust/sepot-relay/src/main.rs @@ -13,6 +13,7 @@ mod net; mod push; mod rest; mod state; +mod turn; mod ws; use std::net::SocketAddr; diff --git a/rust/sepot-relay/src/push.rs b/rust/sepot-relay/src/push.rs index d3cfe90..c3ced34 100644 --- a/rust/sepot-relay/src/push.rs +++ b/rust/sepot-relay/src/push.rs @@ -3,6 +3,10 @@ //! Klient se po probuzení sám připojí na WS a vyzvedne frontu sealed-sender //! 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: //! - UnifiedPush (3) / WebPush (4): HTTP POST na endpoint URL distributora. //! - 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. 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>, + pub voip_token: Option>, +} + +/// 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ý /// timeout, generický UA. pub fn build_client() -> Client { diff --git a/rust/sepot-relay/src/state.rs b/rust/sepot-relay/src/state.rs index 7d62886..f182ed3 100644 --- a/rust/sepot-relay/src/state.rs +++ b/rust/sepot-relay/src/state.rs @@ -124,6 +124,8 @@ pub struct AppState { pub fcm: Option>, /// APNs nativní iOS push (None = nenakonfigurováno). pub apns: Option>, + /// TURN pro hovory (None = relay údaje nevydává). + pub turn: Option, } impl AppState { @@ -144,6 +146,7 @@ impl AppState { attachments, fcm: crate::fcm::FcmSender::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"); } + if env.content_hint == v1::ContentHint::Ephemeral as i32 { + return Ok(self.route_ephemeral(env).await); + } + let dst = env.dst_address.clone(); let encoded = env.encode_to_vec(); 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. 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) { let deliver = v1::ServerFrame { frame: Some(v1::server_frame::Frame::Deliver(env)), @@ -194,10 +202,34 @@ impl AppState { } } // 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) } + /// 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` /// (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. @@ -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 { + let row: Option<(i16, Option>, Option>)> = 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. /// `pub(crate)` kvůli integračnímu testu correctness JOINu. pub(crate) async fn push_target(&self, dst_address: &[u8]) -> Option<(i16, Vec)> { @@ -268,6 +342,7 @@ impl AppState { device_uuid: &[u8], platform: i16, endpoint: Option<&[u8]>, + voip_token: Option<&[u8]>, ) -> anyhow::Result<()> { if platform == crate::push::PLATFORM_NONE || endpoint.is_none() { sqlx::query("DELETE FROM push_tokens WHERE device_uuid = $1") @@ -277,16 +352,18 @@ impl AppState { return Ok(()); } sqlx::query( - "INSERT INTO push_tokens (device_uuid, platform, token, updated_at) - VALUES ($1, $2, $3, now()) + "INSERT INTO push_tokens (device_uuid, platform, token, voip_token, updated_at) + VALUES ($1, $2, $3, $4, now()) ON CONFLICT (device_uuid) DO UPDATE SET platform = excluded.platform, token = excluded.token, + voip_token = excluded.voip_token, updated_at = now()", ) .bind(device_uuid) .bind(platform) .bind(endpoint) + .bind(voip_token) .execute(&self.pool) .await?; Ok(()) @@ -556,7 +633,7 @@ mod db_tests { // UnifiedPush endpoint let ep1 = b"https://ntfy.sh/UPabc".to_vec(); state - .register_push(&device_uuid, crate::push::PLATFORM_UNIFIEDPUSH, Some(&ep1)) + .register_push(&device_uuid, crate::push::PLATFORM_UNIFIEDPUSH, Some(&ep1), None) .await .unwrap(); assert_eq!( @@ -567,7 +644,7 @@ mod db_tests { // upsert na WebPush (jiná platforma i token) let ep2 = b"https://push.example/w".to_vec(); state - .register_push(&device_uuid, crate::push::PLATFORM_WEBPUSH, Some(&ep2)) + .register_push(&device_uuid, crate::push::PLATFORM_WEBPUSH, Some(&ep2), None) .await .unwrap(); assert_eq!( @@ -577,7 +654,7 @@ mod db_tests { // NONE → smaže (anonymní režim) state - .register_push(&device_uuid, crate::push::PLATFORM_NONE, None) + .register_push(&device_uuid, crate::push::PLATFORM_NONE, None, None) .await .unwrap(); assert!(state.push_target(&delivery_address).await.is_none()); @@ -585,6 +662,64 @@ mod db_tests { 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] #[ignore = "vyžaduje běžící Postgres (dev DB)"] async fn route_envelope_offline_ulozi_a_spusti_wake() { @@ -598,6 +733,7 @@ mod db_tests { &device_uuid, crate::push::PLATFORM_UNIFIEDPUSH, Some(b"https://localhost/up"), + None, ) .await .unwrap(); @@ -623,6 +759,33 @@ mod db_tests { 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] #[ignore = "vyžaduje běžící Postgres (dev DB)"] async fn delete_identity_smaze_ucet_frontu_i_push() { @@ -635,6 +798,7 @@ mod db_tests { &device_uuid, crate::push::PLATFORM_UNIFIEDPUSH, Some(b"https://localhost/up"), + None, ) .await .unwrap(); diff --git a/rust/sepot-relay/src/turn.rs b/rust/sepot-relay/src/turn.rs new file mode 100644 index 0000000..091d6f0 --- /dev/null +++ b/rust/sepot-relay/src/turn.rs @@ -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, + urls: Vec, +} + +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 { + 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 = 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, urls: Vec) -> 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::::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::().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); + } +} diff --git a/rust/sepot-relay/src/ws.rs b/rust/sepot-relay/src/ws.rs index b599262..b3c18d8 100644 --- a/rust/sepot-relay/src/ws.rs +++ b/rust/sepot-relay/src/ws.rs @@ -314,9 +314,13 @@ async fn handle(state: Arc, socket: WebSocket) { } else { let endpoint = (!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). 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}"); } @@ -351,6 +355,16 @@ async fn handle(state: Arc, 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(_)) => {} _ => {