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:
+179
-2
@@ -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<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 {
|
||||
@@ -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<Auth>(1, _omitFieldNames ? '' : 'auth', subBuilder: Auth.create)
|
||||
..aOM<AuthProof>(2, _omitFieldNames ? '' : 'authProof',
|
||||
subBuilder: AuthProof.create)
|
||||
@@ -1192,6 +1335,8 @@ class ClientFrame extends $pb.GeneratedMessage {
|
||||
..aOM<Pong>(5, _omitFieldNames ? '' : 'pong', subBuilder: Pong.create)
|
||||
..aOM<RegisterPush>(6, _omitFieldNames ? '' : 'registerPush',
|
||||
subBuilder: RegisterPush.create)
|
||||
..aOM<TurnRequest>(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<Challenge>(1, _omitFieldNames ? '' : 'challenge',
|
||||
subBuilder: Challenge.create)
|
||||
..aOM<Receipt>(2, _omitFieldNames ? '' : 'receipt',
|
||||
@@ -1363,6 +1525,8 @@ class ServerFrame extends $pb.GeneratedMessage {
|
||||
..aOM<Ping>(5, _omitFieldNames ? '' : 'ping', subBuilder: Ping.create)
|
||||
..aOM<Error>(6, _omitFieldNames ? '' : 'error', subBuilder: Error.create)
|
||||
..aOM<AuthOk>(7, _omitFieldNames ? '' : 'authOk', subBuilder: AuthOk.create)
|
||||
..aOM<TurnCredentials>(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 =
|
||||
|
||||
@@ -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<ContentHint> values = <ContentHint>[
|
||||
CONTENT_HINT_DEFAULT,
|
||||
CONTENT_HINT_RESYNC,
|
||||
CONTENT_HINT_SUPPLEMENTARY,
|
||||
CONTENT_HINT_EPHEMERAL,
|
||||
CONTENT_HINT_CALL,
|
||||
];
|
||||
|
||||
static final $core.List<ContentHint?> _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<Receipt_Status> values = <Receipt_Status>[
|
||||
STATUS_QUEUED,
|
||||
STATUS_DELIVERED_ONLINE,
|
||||
STATUS_STORED_OFFLINE,
|
||||
STATUS_DROPPED,
|
||||
];
|
||||
|
||||
static final $core.List<Receipt_Status?> _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];
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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í.
|
||||
|
||||
@@ -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; // "<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) ─
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -35,6 +35,11 @@ fn wake_expiration(now: DateTime<Utc>) -> 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 `<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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,6 +13,7 @@ mod net;
|
||||
mod push;
|
||||
mod rest;
|
||||
mod state;
|
||||
mod turn;
|
||||
mod ws;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
@@ -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<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ý
|
||||
/// timeout, generický UA.
|
||||
pub fn build_client() -> Client {
|
||||
|
||||
@@ -124,6 +124,8 @@ pub struct AppState {
|
||||
pub fcm: Option<std::sync::Arc<crate::fcm::FcmSender>>,
|
||||
/// APNs nativní iOS push (None = nenakonfigurováno).
|
||||
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 {
|
||||
@@ -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.
|
||||
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<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.
|
||||
/// `pub(crate)` kvůli integračnímu testu correctness JOINu.
|
||||
pub(crate) async fn push_target(&self, dst_address: &[u8]) -> Option<(i16, Vec<u8>)> {
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -314,9 +314,13 @@ async fn handle(state: Arc<AppState>, 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<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(_)) => {}
|
||||
|
||||
_ => {
|
||||
|
||||
Reference in New Issue
Block a user