SHA256
Перевести звонки на SendSignal с client key
This commit is contained in:
+22
-3
@@ -26,6 +26,7 @@ import java.util.Set;
|
||||
public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler {
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private static final int TYPE_INVITE = 100;
|
||||
private static final int TYPE_CONNECT_START = 170;
|
||||
private static final long PUSH_CALL_TTL_MS = 10_000L;
|
||||
|
||||
@Override
|
||||
@@ -38,8 +39,11 @@ public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler {
|
||||
String toRequest = req.getToLogin() == null ? "" : req.getToLogin().trim();
|
||||
String callId = req.getCallId() == null ? "" : req.getCallId().trim();
|
||||
int type = req.getType() == null ? TYPE_INVITE : req.getType();
|
||||
if (toRequest.isBlank() || callId.isBlank() || type != TYPE_INVITE) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "toLogin/callId/type=100 обязательны");
|
||||
String data = req.getData() == null ? "" : req.getData().trim();
|
||||
boolean isInvite = type == TYPE_INVITE;
|
||||
boolean isConnectStart = type == TYPE_CONNECT_START;
|
||||
if (toRequest.isBlank() || callId.isBlank() || (!isInvite && !isConnectStart)) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "toLogin/callId/type=100|170 обязательны");
|
||||
}
|
||||
|
||||
CurrentUserEntry targetUser = CurrentUsersDAO.getInstance().getByLogin(toRequest);
|
||||
@@ -69,13 +73,28 @@ public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler {
|
||||
payload.put("fromSessionId", ctx.getSessionId());
|
||||
payload.put("toLogin", to);
|
||||
payload.put("callId", callId);
|
||||
payload.put("type", TYPE_INVITE);
|
||||
payload.put("type", type);
|
||||
payload.put("timeMs", timeMs);
|
||||
if (!data.isBlank()) {
|
||||
payload.put("data", data);
|
||||
}
|
||||
|
||||
boolean sent = WsEventSender.sendEvent(targetCtx, "IncomingCallInvite", eventId, payload);
|
||||
if (sent) wsDelivered++;
|
||||
}
|
||||
|
||||
if (isConnectStart) {
|
||||
Net_CallInviteBroadcast_Response resp = new Net_CallInviteBroadcast_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setCallId(callId);
|
||||
resp.setDeliveredWsSessions(wsDelivered);
|
||||
resp.setDeliveredFcmSessions(0);
|
||||
resp.setDeliveredWebPushSessions(0);
|
||||
return resp;
|
||||
}
|
||||
|
||||
for (ActiveSessionEntry session : allTargetSessions) {
|
||||
String sessionId = String.valueOf(session.getSessionId() == null ? "" : session.getSessionId()).trim();
|
||||
if (!sessionId.isBlank() && activeSessionIds.contains(sessionId)) {
|
||||
|
||||
+204
-3
@@ -12,10 +12,12 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Response;
|
||||
import server.logic.ws_protocol.JSON.push.WebPushSender;
|
||||
import server.logic.ws_protocol.JSON.push.WsEventSender;
|
||||
import server.logic.ws_protocol.JSON.utils.AuthKeyUtils;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.dao.ActiveSessionsDAO;
|
||||
import shine.db.dao.CurrentUsersDAO;
|
||||
import shine.db.entities.ActiveSessionEntry;
|
||||
import shine.db.entities.CurrentUserEntry;
|
||||
@@ -34,6 +36,13 @@ public class Net_SendSignal_Handler implements JsonMessageHandler {
|
||||
private static final String TARGET_MODE_SINGLE = "single_session";
|
||||
private static final String TARGET_MODE_ALL = "all_sessions";
|
||||
private static final long ALLOWED_SKEW_MS = 30_000L;
|
||||
private static final long PUSH_CALL_TTL_MS = 10_000L;
|
||||
private static final String SIGNAL_CALL_INVITE = "call_invite";
|
||||
private static final String SIGNAL_CALL_ACCEPT = "call_accept";
|
||||
private static final String SIGNAL_CALL_DECLINE_BUSY = "call_decline_busy";
|
||||
private static final String SIGNAL_CALL_TIMEOUT = "call_timeout";
|
||||
private static final String SIGNAL_CALL_HANGUP = "call_hangup";
|
||||
private static final String SIGNAL_CALL_CONNECT_START = "call_connect_start";
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
|
||||
@@ -99,6 +108,10 @@ public class Net_SendSignal_Handler implements JsonMessageHandler {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_SESSION_SIGNATURE", "Некорректная подпись session key");
|
||||
}
|
||||
|
||||
if (isCallSignalType(signalType) && clientSignatureB64.isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "CLIENT_SIGNATURE_REQUIRED", "Для call_* сигналов обязательна подпись client key");
|
||||
}
|
||||
|
||||
if (!clientSignatureB64.isBlank()) {
|
||||
String clientPreimage = buildClientPreimage(fromLogin, fromSessionId, toLogin, targetMode, targetSessionId, signalType, signalRequestId, timeMs, digestB64);
|
||||
if (!verifySignature(senderUser.getClientKey(), clientPreimage, clientSignatureB64, "clientKey")) {
|
||||
@@ -107,7 +120,13 @@ public class Net_SendSignal_Handler implements JsonMessageHandler {
|
||||
}
|
||||
|
||||
List<ConnectionContext> targets = resolveTargets(targetMode, toLogin, targetSessionId);
|
||||
if (targets.isEmpty()) {
|
||||
boolean isCallInviteAllSessions = TARGET_MODE_ALL.equals(targetMode) && SIGNAL_CALL_INVITE.equals(signalType);
|
||||
boolean isCallAcceptSingleSession = TARGET_MODE_SINGLE.equals(targetMode) && SIGNAL_CALL_ACCEPT.equals(signalType);
|
||||
boolean isCallTerminalSingleSession = TARGET_MODE_SINGLE.equals(targetMode)
|
||||
&& (SIGNAL_CALL_DECLINE_BUSY.equals(signalType)
|
||||
|| SIGNAL_CALL_TIMEOUT.equals(signalType)
|
||||
|| SIGNAL_CALL_HANGUP.equals(signalType));
|
||||
if (targets.isEmpty() && !isCallInviteAllSessions) {
|
||||
String code = TARGET_MODE_SINGLE.equals(targetMode) ? "SESSION_NOT_FOUND" : "NO_TARGET_SESSIONS";
|
||||
String msg = TARGET_MODE_SINGLE.equals(targetMode) ? "Целевая сессия не найдена" : "Нет активных сессий для доставки сигнала";
|
||||
return NetExceptionResponseFactory.error(req, 404, code, msg);
|
||||
@@ -135,16 +154,62 @@ public class Net_SendSignal_Handler implements JsonMessageHandler {
|
||||
}
|
||||
}
|
||||
|
||||
if (deliveredSessionIds.isEmpty()) {
|
||||
int webPushDelivered = 0;
|
||||
if (isCallInviteAllSessions) {
|
||||
webPushDelivered = sendIncomingCallPushToOfflineSessions(
|
||||
toLogin,
|
||||
fromLogin,
|
||||
fromSessionId,
|
||||
signalRequestId,
|
||||
deliveredSessionIds
|
||||
);
|
||||
}
|
||||
|
||||
if (deliveredSessionIds.isEmpty() && webPushDelivered <= 0) {
|
||||
return NetExceptionResponseFactory.error(req, 404, "DELIVERY_FAILED", "Не удалось доставить сигнал ни в одну целевую сессию");
|
||||
}
|
||||
|
||||
if (isCallAcceptSingleSession) {
|
||||
notifyStopOnOtherSessions(
|
||||
fromLogin,
|
||||
fromSessionId,
|
||||
fromLogin,
|
||||
fromSessionId,
|
||||
signalRequestId,
|
||||
"accepted_on_other_device"
|
||||
);
|
||||
}
|
||||
|
||||
if (isCallTerminalSingleSession) {
|
||||
String deliveredTargetSessionId = deliveredSessionIds.isEmpty() ? targetSessionId : deliveredSessionIds.get(0);
|
||||
String reason = "terminal_call_signal_" + signalType;
|
||||
notifyStopOnOtherSessions(
|
||||
fromLogin,
|
||||
fromSessionId,
|
||||
fromLogin,
|
||||
fromSessionId,
|
||||
signalRequestId,
|
||||
reason
|
||||
);
|
||||
notifyStopOnOtherSessions(
|
||||
toLogin,
|
||||
deliveredTargetSessionId,
|
||||
fromLogin,
|
||||
fromSessionId,
|
||||
signalRequestId,
|
||||
reason
|
||||
);
|
||||
}
|
||||
|
||||
Net_SendSignal_Response resp = new Net_SendSignal_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setDeliveredCount(deliveredSessionIds.size());
|
||||
resp.setDeliveredCount(deliveredSessionIds.size() + webPushDelivered);
|
||||
resp.setDeliveredSessionIds(deliveredSessionIds);
|
||||
resp.setDeliveredWsSessions(deliveredSessionIds.size());
|
||||
resp.setDeliveredFcmSessions(webPushDelivered);
|
||||
resp.setDeliveredWebPushSessions(webPushDelivered);
|
||||
return resp;
|
||||
}
|
||||
|
||||
@@ -190,6 +255,19 @@ public class Net_SendSignal_Handler implements JsonMessageHandler {
|
||||
+ dataSha256B64;
|
||||
}
|
||||
|
||||
private static boolean isCallSignalType(String signalType) {
|
||||
return SIGNAL_CALL_INVITE.equals(signalType)
|
||||
|| "call_ringing".equals(signalType)
|
||||
|| SIGNAL_CALL_ACCEPT.equals(signalType)
|
||||
|| SIGNAL_CALL_DECLINE_BUSY.equals(signalType)
|
||||
|| SIGNAL_CALL_TIMEOUT.equals(signalType)
|
||||
|| SIGNAL_CALL_HANGUP.equals(signalType)
|
||||
|| SIGNAL_CALL_CONNECT_START.equals(signalType)
|
||||
|| "call_offer".equals(signalType)
|
||||
|| "call_answer".equals(signalType)
|
||||
|| "call_ice".equals(signalType);
|
||||
}
|
||||
|
||||
private static boolean verifySignature(String publicKeyValue, String preimage, String signatureB64, String fieldName) throws Exception {
|
||||
byte[] publicKey32 = AuthKeyUtils.parseEd25519PublicKey(publicKeyValue, fieldName);
|
||||
byte[] signature64 = Base64Ws.decodeLen(signatureB64, 64, "signatureB64");
|
||||
@@ -214,7 +292,130 @@ public class Net_SendSignal_Handler implements JsonMessageHandler {
|
||||
return targets;
|
||||
}
|
||||
|
||||
private int sendIncomingCallPushToOfflineSessions(
|
||||
String toLogin,
|
||||
String fromLogin,
|
||||
String fromSessionId,
|
||||
String callId,
|
||||
List<String> deliveredOnlineSessionIds
|
||||
) throws Exception {
|
||||
List<ActiveSessionEntry> persistedSessions = ActiveSessionsDAO.getInstance().getByLogin(toLogin);
|
||||
long sentAtMs = System.currentTimeMillis();
|
||||
long expiresAtMs = sentAtMs + PUSH_CALL_TTL_MS;
|
||||
int delivered = 0;
|
||||
for (ActiveSessionEntry session : persistedSessions) {
|
||||
String sessionId = safe(session.getSessionId());
|
||||
if (!sessionId.isBlank() && deliveredOnlineSessionIds.contains(sessionId)) {
|
||||
continue;
|
||||
}
|
||||
if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) {
|
||||
continue;
|
||||
}
|
||||
String payload = "{\"kind\":\"incoming_call\""
|
||||
+ ",\"title\":\"SHiNE: входящий звонок\""
|
||||
+ ",\"text\":\"Вам звонит " + jsonEscape(fromLogin) + "\""
|
||||
+ ",\"fromLogin\":\"" + jsonEscape(fromLogin) + "\""
|
||||
+ ",\"fromSessionId\":\"" + jsonEscape(fromSessionId) + "\""
|
||||
+ ",\"targetSessionId\":\"" + jsonEscape(sessionId) + "\""
|
||||
+ ",\"toLogin\":\"" + jsonEscape(toLogin) + "\""
|
||||
+ ",\"callId\":\"" + jsonEscape(callId) + "\""
|
||||
+ ",\"sentAtMs\":" + sentAtMs
|
||||
+ ",\"expiresAtMs\":" + expiresAtMs
|
||||
+ "}";
|
||||
boolean pushed = WebPushSender.sendBase64Payload(
|
||||
session.getPushEndpoint(),
|
||||
session.getPushP256dhKey(),
|
||||
session.getPushAuthKey(),
|
||||
payload
|
||||
);
|
||||
if (pushed) {
|
||||
delivered++;
|
||||
}
|
||||
}
|
||||
return delivered;
|
||||
}
|
||||
|
||||
private void notifyStopOnOtherSessions(
|
||||
String targetLogin,
|
||||
String excludeSessionId,
|
||||
String fromLogin,
|
||||
String fromSessionId,
|
||||
String callId,
|
||||
String reason
|
||||
) throws Exception {
|
||||
if (isBlank(targetLogin) || isBlank(callId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Set<String> onlineSessionIds = new java.util.HashSet<>();
|
||||
Set<ConnectionContext> sameUserSessions = ActiveConnectionsRegistry.getInstance().getByLogin(targetLogin);
|
||||
for (ConnectionContext siblingCtx : sameUserSessions) {
|
||||
if (siblingCtx == null || siblingCtx.getWsSession() == null || !siblingCtx.getWsSession().isOpen()) continue;
|
||||
onlineSessionIds.add(safe(siblingCtx.getSessionId()));
|
||||
if (!isBlank(excludeSessionId) && excludeSessionId.equals(siblingCtx.getSessionId())) continue;
|
||||
|
||||
String siblingEventId = server.logic.ws_protocol.JSON.utils.NetIdGenerator.eventId("evt");
|
||||
ObjectNode siblingPayload = MAPPER.createObjectNode();
|
||||
siblingPayload.put("eventId", siblingEventId);
|
||||
siblingPayload.put("fromLogin", fromLogin);
|
||||
siblingPayload.put("fromSessionId", fromSessionId);
|
||||
siblingPayload.put("toLogin", targetLogin);
|
||||
siblingPayload.put("targetMode", TARGET_MODE_SINGLE);
|
||||
siblingPayload.put("targetSessionId", safe(siblingCtx.getSessionId()));
|
||||
siblingPayload.put("signalType", SIGNAL_CALL_HANGUP);
|
||||
siblingPayload.put("signalRequestId", callId);
|
||||
siblingPayload.put("data", "{\"callId\":\"" + jsonEscape(callId) + "\",\"type\":150,\"data\":\"" + jsonEscape(reason) + "\"}");
|
||||
siblingPayload.put("timeMs", System.currentTimeMillis());
|
||||
WsEventSender.sendEvent(siblingCtx, "IncomingSignal", siblingEventId, siblingPayload);
|
||||
}
|
||||
|
||||
List<ActiveSessionEntry> persistedSessions = ActiveSessionsDAO.getInstance().getByLogin(targetLogin);
|
||||
long sentAtMs = System.currentTimeMillis();
|
||||
for (ActiveSessionEntry session : persistedSessions) {
|
||||
String sessionId = safe(session.getSessionId());
|
||||
if (!isBlank(excludeSessionId) && excludeSessionId.equals(sessionId)) continue;
|
||||
if (!sessionId.isBlank() && onlineSessionIds.contains(sessionId)) continue;
|
||||
if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) {
|
||||
continue;
|
||||
}
|
||||
String pushPayload = "{\"kind\":\"stop_call\""
|
||||
+ ",\"callId\":\"" + jsonEscape(callId) + "\""
|
||||
+ ",\"reason\":\"" + jsonEscape(reason) + "\""
|
||||
+ ",\"fromLogin\":\"" + jsonEscape(fromLogin) + "\""
|
||||
+ ",\"fromSessionId\":\"" + jsonEscape(fromSessionId) + "\""
|
||||
+ ",\"targetSessionId\":\"" + jsonEscape(sessionId) + "\""
|
||||
+ ",\"toLogin\":\"" + jsonEscape(targetLogin) + "\""
|
||||
+ ",\"sentAtMs\":" + sentAtMs
|
||||
+ "}";
|
||||
WebPushSender.sendBase64Payload(
|
||||
session.getPushEndpoint(),
|
||||
session.getPushP256dhKey(),
|
||||
session.getPushAuthKey(),
|
||||
pushPayload
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static String safe(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private static boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
private static String jsonEscape(String s) {
|
||||
if (s == null) return "";
|
||||
StringBuilder out = new StringBuilder();
|
||||
for (int i = 0; i < s.length(); i++) {
|
||||
char c = s.charAt(i);
|
||||
if (c == '\\') out.append("\\\\");
|
||||
else if (c == '"') out.append("\\\"");
|
||||
else if (c == '\n') out.append("\\n");
|
||||
else if (c == '\r') out.append("\\r");
|
||||
else if (c == '\t') out.append("\\t");
|
||||
else out.append(c);
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -6,6 +6,7 @@ public class Net_CallInviteBroadcast_Request extends Net_Request {
|
||||
private String toLogin;
|
||||
private String callId;
|
||||
private Integer type;
|
||||
private String data;
|
||||
|
||||
public String getToLogin() { return toLogin; }
|
||||
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
|
||||
@@ -15,4 +16,7 @@ public class Net_CallInviteBroadcast_Request extends Net_Request {
|
||||
|
||||
public Integer getType() { return type; }
|
||||
public void setType(Integer type) { this.type = type; }
|
||||
|
||||
public String getData() { return data; }
|
||||
public void setData(String data) { this.data = data; }
|
||||
}
|
||||
|
||||
+27
@@ -8,6 +8,9 @@ import java.util.List;
|
||||
public class Net_SendSignal_Response extends Net_Response {
|
||||
private int deliveredCount;
|
||||
private List<String> deliveredSessionIds = new ArrayList<>();
|
||||
private int deliveredWsSessions;
|
||||
private int deliveredFcmSessions;
|
||||
private int deliveredWebPushSessions;
|
||||
|
||||
public int getDeliveredCount() {
|
||||
return deliveredCount;
|
||||
@@ -24,4 +27,28 @@ public class Net_SendSignal_Response extends Net_Response {
|
||||
public void setDeliveredSessionIds(List<String> deliveredSessionIds) {
|
||||
this.deliveredSessionIds = deliveredSessionIds;
|
||||
}
|
||||
|
||||
public int getDeliveredWsSessions() {
|
||||
return deliveredWsSessions;
|
||||
}
|
||||
|
||||
public void setDeliveredWsSessions(int deliveredWsSessions) {
|
||||
this.deliveredWsSessions = deliveredWsSessions;
|
||||
}
|
||||
|
||||
public int getDeliveredFcmSessions() {
|
||||
return deliveredFcmSessions;
|
||||
}
|
||||
|
||||
public void setDeliveredFcmSessions(int deliveredFcmSessions) {
|
||||
this.deliveredFcmSessions = deliveredFcmSessions;
|
||||
}
|
||||
|
||||
public int getDeliveredWebPushSessions() {
|
||||
return deliveredWebPushSessions;
|
||||
}
|
||||
|
||||
public void setDeliveredWebPushSessions(int deliveredWebPushSessions) {
|
||||
this.deliveredWebPushSessions = deliveredWebPushSessions;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
- `medium/2026-05-26_0029_esp32s3_file_storage.md` - ESP32S3 как личное файловое хранилище SHiNE для файлов переписок и вложений.
|
||||
- `medium/2026-06-02_сессионные_homeserver_в_pda.md` - несколько homeserver-ов пользователя как типизированные сессии в PDA с версией записи.
|
||||
- `medium/2026-06-03_подключение_других_устройств_через_qr.md` - довести подключение других устройств через QR: сейчас заготовка есть, но сценарий работает нестабильно и его нужно будет отдельно доделать.
|
||||
- `medium/2026-08-09_esp32_переход_на_sendsignal_и_client_key.md` - перевести ESP32 и wallet-extension на единый `SendSignal`, обязательный `client key` и подготовить почву для будущего E2E-шифрования payload.
|
||||
- `medium/2026-07-22_переход_с_sqlite_на_postgresql.md` - завершить зачистку хвостов после перевода серверной БД с `SQLite` на `PostgreSQL`.
|
||||
|
||||
### dao_запуск
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# ESP32 и wallet-extension: перейти на единый `SendSignal` и обязательную подпись `client key`
|
||||
|
||||
## Зачем
|
||||
|
||||
Сейчас в проекте уже есть универсальный transport `SendSignal`, который умеет работать и в режим `all_sessions`, и в режим `single_session`.
|
||||
|
||||
При этом в коде всё ещё живут старые call-like транспорты:
|
||||
|
||||
- `CallInviteBroadcast`
|
||||
- `CallSignalToSession`
|
||||
|
||||
Для web-call логики их можно постепенно убрать в пользу одного `SendSignal`.
|
||||
|
||||
Отдельный хвост остался в ESP32 homeserver/wallet-сценариях:
|
||||
|
||||
- в ESP32-скетче технический ответ wallet RPC всё ещё уходит через `CallSignalToSession`;
|
||||
- в ESP32 send-signal сценарии сейчас допускается режим только с `sessionSignatureB64`, без обязательной подписи `client key`;
|
||||
- такое поведение выбивается из общей модели безопасности и создаёт лишнюю специальную ветку.
|
||||
|
||||
## Что уже есть в коде
|
||||
|
||||
- универсальный серверный transport `SendSignal` уже существует и поддерживает:
|
||||
- `single_session`;
|
||||
- `all_sessions`.
|
||||
- web UI уже использует `SendSignal` в части межсессионных сценариев.
|
||||
- звонки пока ещё используют старые call-specific методы.
|
||||
- ESP32 homeserver main использует:
|
||||
- `CallSignalToSession` для wallet RPC response;
|
||||
- `SendSignal` для части технических ответов.
|
||||
- в ESP32 есть код генерации:
|
||||
- `sessionSignatureB64`;
|
||||
- `clientSignatureB64`;
|
||||
но для некоторых send-signal вызовов `client key` сейчас не обязателен.
|
||||
|
||||
## Что сделать
|
||||
|
||||
1. Перевести ESP32 сценарии со старого `CallSignalToSession` на `SendSignal(single_session)`.
|
||||
2. Убрать из ESP32-special flow странное исключение, где можно жить без `client key`.
|
||||
3. Переделать wallet-extension / связанный ESP32-клиент так, чтобы он тоже хранил `client key` и подписывал им `SendSignal`, как обычные клиенты SHiNE.
|
||||
4. Сделать `SendSignal` контрактно обязательным по подписи `client key`, а не только `session key`.
|
||||
5. После этого зачистить legacy call-like транспорт там, где он больше не нужен.
|
||||
6. Отдельно проверить, не осталось ли старых обработчиков, завязанных на:
|
||||
- `IncomingCallSignal`;
|
||||
- `IncomingCallInvite`;
|
||||
там, где должен работать общий `IncomingSignal`.
|
||||
|
||||
## Отдельно про шифрование
|
||||
|
||||
Нужно продумать будущее расширение: чтобы через `SendSignal` можно было передавать не только подписанные, но и зашифрованные payload.
|
||||
|
||||
Предварительный вывод по текущей архитектуре:
|
||||
|
||||
- для сервера это почти не должно требовать специальной логики;
|
||||
- сервер в `SendSignal` в основном:
|
||||
- валидирует подписи;
|
||||
- маршрутизирует событие в нужные сессии;
|
||||
- пересылает `data` дальше.
|
||||
|
||||
Если UI/ESP32 начнут передавать уже клиентски зашифрованный `data`, сервер должен это пережить без серьёзных изменений, пока:
|
||||
|
||||
- формат `data` остаётся строковым/JSON-совместимым;
|
||||
- сервер продолжает считать `SHA-256` от фактической передаваемой строки;
|
||||
- подписи строятся по уже зашифрованному payload.
|
||||
|
||||
То есть возможное E2E-шифрование `SendSignal` в будущем скорее относится к клиентским участникам протокола, а не к серверной маршрутизации.
|
||||
|
||||
## Что важно учесть при миграции
|
||||
|
||||
- не ломать текущие звонки и текущий wallet RPC сценарий одним большим рывком;
|
||||
- сначала перевести ESP32 и wallet-extension на единый `SendSignal`;
|
||||
- только потом убирать старые call-specific методы;
|
||||
- не оставлять “особый ESP32-режим” без `client key`;
|
||||
- проверить, что у расширения/ESP32 есть безопасное хранение `client key` и понятный сценарий восстановления/перепривязки.
|
||||
|
||||
## Откуда продолжать
|
||||
|
||||
- от `TODO/medium/2026-06-28_send_signal_перенос_старых_сигналов.md`;
|
||||
- от текущего ESP32 homeserver-скетча:
|
||||
- `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino`
|
||||
- от web UI auth/call transport логики:
|
||||
- `shine-UI/js/services/auth-service.js`
|
||||
- `shine-UI/js/services/call-service.js`
|
||||
|
||||
## Какие документы потом обновить
|
||||
|
||||
- `TODO/medium/2026-06-28_send_signal_перенос_старых_сигналов.md`;
|
||||
- `TODO/README.md`;
|
||||
- документацию по ESP32 homeserver/wallet flow, если появится отдельный утверждённый transport contract;
|
||||
- документацию по межсессионным сигналам, если будет зафиксирован единый список `signalType`.
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
client.version=1.5.4
|
||||
server.version=1.4.6
|
||||
client.version=1.5.5
|
||||
server.version=1.4.7
|
||||
|
||||
@@ -297,6 +297,7 @@
|
||||
Первый целевой сценарий:
|
||||
|
||||
- `remote AddBlock via homeserver session`
|
||||
- межсессионная доставка звонковых `call_*` сигналов между устройствами пользователя
|
||||
|
||||
То есть телефон без локального `blockchain.key` может:
|
||||
|
||||
@@ -336,6 +337,12 @@
|
||||
- запрос должен быть подписан и `session key`, и `client key`;
|
||||
- в будущем для отдельных wallet-сценариев `clientSignatureB64` может быть пустой.
|
||||
|
||||
Для звонковых сигналов `signalType = call_*` правило строже:
|
||||
|
||||
- `clientSignatureB64` обязателен;
|
||||
- сервер отклоняет такой `SendSignal`, если `client key` подпись не передана;
|
||||
- это правило действует и для `single_session`, и для `all_sessions`.
|
||||
|
||||
### Запрос в одну сессию
|
||||
|
||||
```json
|
||||
@@ -366,11 +373,20 @@
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"deliveredCount": 1,
|
||||
"deliveredSessionIds": ["sess-hs-001"]
|
||||
"deliveredSessionIds": ["sess-hs-001"],
|
||||
"deliveredWsSessions": 1,
|
||||
"deliveredFcmSessions": 0,
|
||||
"deliveredWebPushSessions": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Для `call_invite` с `targetMode = "all_sessions"` сервер может дополнительно отправить offline web-push в те сессии адресата, которые сейчас не подключены по WebSocket. В таком случае:
|
||||
|
||||
- `deliveredWsSessions` — сколько сессий получили `IncomingSignal` по WebSocket;
|
||||
- `deliveredWebPushSessions` — сколько offline-сессий получили web-push;
|
||||
- `deliveredFcmSessions` пока дублирует тот же счётчик push-доставки и зарезервирован под отдельные push-каналы.
|
||||
|
||||
### Событие на принимающей стороне
|
||||
|
||||
```json
|
||||
@@ -428,6 +444,7 @@
|
||||
- `404 / USER_NOT_FOUND` — логин адресата не найден.
|
||||
- `400 / BAD_DATA` — сервер не смог обработать `data`.
|
||||
- `400 / BAD_SESSION_SIGNATURE` — некорректная подпись `session key`.
|
||||
- `400 / CLIENT_SIGNATURE_REQUIRED` — для `call_*` сигнала не передана обязательная подпись `client key`.
|
||||
- `400 / BAD_CLIENT_SIGNATURE` — некорректная подпись `client key`.
|
||||
- `404 / SESSION_NOT_FOUND` — при `single_session` целевая сессия не найдена или не онлайн.
|
||||
- `404 / NO_TARGET_SESSIONS` — при `all_sessions` у пользователя сейчас нет активных онлайн-сессий.
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
handleIncomingCallInvite,
|
||||
handleIncomingCallPush,
|
||||
handleIncomingCallSignal,
|
||||
handleIncomingCallSignalViaSendSignal,
|
||||
handleStopCallPush,
|
||||
setCallDebugReporter,
|
||||
startDebugConnectionAsInitiator,
|
||||
@@ -1313,6 +1314,10 @@ async function init() {
|
||||
try { await handleIncomingCallSignal(evt); } catch {}
|
||||
});
|
||||
|
||||
authService.onEvent('IncomingSignal', async (evt) => {
|
||||
try { await handleIncomingCallSignalViaSendSignal(evt); } catch {}
|
||||
});
|
||||
|
||||
authService.onEvent('DebugConnectPrepareResponder', async (evt) => {
|
||||
try {
|
||||
const p = evt?.payload || {};
|
||||
|
||||
@@ -2631,8 +2631,8 @@ export class AuthService {
|
||||
}
|
||||
|
||||
|
||||
async callInviteBroadcast({ toLogin, callId, type = 100 }) {
|
||||
const response = await this.ws.request('CallInviteBroadcast', { toLogin, callId, type });
|
||||
async callInviteBroadcast({ toLogin, callId, type = 100, data = '' }) {
|
||||
const response = await this.ws.request('CallInviteBroadcast', { toLogin, callId, type, data });
|
||||
if (response.status !== 200) throw opError('CallInviteBroadcast', response);
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
@@ -8,10 +8,25 @@ const TYPES = {
|
||||
DECLINE_BUSY: 130,
|
||||
TIMEOUT: 140,
|
||||
HANGUP: 150,
|
||||
CONNECT_START: 170,
|
||||
OFFER: 200,
|
||||
ANSWER: 210,
|
||||
ICE: 220,
|
||||
};
|
||||
const SIGNAL_TARGET_SINGLE = 'single_session';
|
||||
const SIGNAL_TARGET_ALL = 'all_sessions';
|
||||
const CALL_SIGNAL_TYPES = Object.freeze({
|
||||
[TYPES.INVITE]: 'call_invite',
|
||||
[TYPES.RINGING]: 'call_ringing',
|
||||
[TYPES.ACCEPT]: 'call_accept',
|
||||
[TYPES.DECLINE_BUSY]: 'call_decline_busy',
|
||||
[TYPES.TIMEOUT]: 'call_timeout',
|
||||
[TYPES.HANGUP]: 'call_hangup',
|
||||
[TYPES.CONNECT_START]: 'call_connect_start',
|
||||
[TYPES.OFFER]: 'call_offer',
|
||||
[TYPES.ANSWER]: 'call_answer',
|
||||
[TYPES.ICE]: 'call_ice',
|
||||
});
|
||||
|
||||
const calls = new Map();
|
||||
const callStateListeners = new Set();
|
||||
@@ -71,12 +86,54 @@ function getSignalTypeName(type) {
|
||||
if (normalized === TYPES.DECLINE_BUSY) return 'decline_busy';
|
||||
if (normalized === TYPES.TIMEOUT) return 'timeout';
|
||||
if (normalized === TYPES.HANGUP) return 'hangup';
|
||||
if (normalized === TYPES.CONNECT_START) return 'connect_start';
|
||||
if (normalized === TYPES.OFFER) return 'offer';
|
||||
if (normalized === TYPES.ANSWER) return 'answer';
|
||||
if (normalized === TYPES.ICE) return 'ice';
|
||||
return `type_${String(type || '')}`;
|
||||
}
|
||||
|
||||
function buildConnectStartData(winnerSessionId) {
|
||||
return `session=${String(winnerSessionId || '').trim()}`;
|
||||
}
|
||||
|
||||
function parseConnectStartWinnerSessionId(data = '') {
|
||||
const raw = String(data || '').trim();
|
||||
if (!raw) return '';
|
||||
if (raw.startsWith('session=')) return raw.slice('session='.length).trim();
|
||||
if (raw.startsWith('winnerSessionId=')) return raw.slice('winnerSessionId='.length).trim();
|
||||
return '';
|
||||
}
|
||||
|
||||
function getCallSignalType(type) {
|
||||
return CALL_SIGNAL_TYPES[Number(type)] || '';
|
||||
}
|
||||
|
||||
function encodeCallSignalData(callId, type, data = '') {
|
||||
return JSON.stringify({
|
||||
callId: String(callId || '').trim(),
|
||||
type: Number(type),
|
||||
data: String(data || ''),
|
||||
});
|
||||
}
|
||||
|
||||
function decodeCallSignalData(raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(String(raw || '{}'));
|
||||
return {
|
||||
callId: String(parsed?.callId || '').trim(),
|
||||
type: Number(parsed?.type),
|
||||
data: String(parsed?.data || ''),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
callId: '',
|
||||
type: Number.NaN,
|
||||
data: '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function ensureCallTimeline(call) {
|
||||
if (!call) return;
|
||||
if (!Array.isArray(call.timelineEvents)) call.timelineEvents = [];
|
||||
@@ -1168,13 +1225,7 @@ async function finalizeCall(call, {
|
||||
const dataValue = notifyRemoteHangup
|
||||
? ''
|
||||
: `setup_failed:${String(localReasonCode || 'error')}:${String(debugReason || '').slice(0, 80)}`;
|
||||
await authService.callSignalToSession({
|
||||
toLogin: call.peerLogin,
|
||||
targetSessionId: call.remoteSessionId,
|
||||
callId: call.callId,
|
||||
type: TYPES.HANGUP,
|
||||
data: dataValue,
|
||||
});
|
||||
await sendSignal(call, TYPES.HANGUP, dataValue);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -1255,13 +1306,22 @@ async function emitDebug(call, level, message, details = '') {
|
||||
async function sendSignal(call, type, data = '') {
|
||||
if (!call.remoteSessionId) return;
|
||||
const signalName = getSignalTypeName(type);
|
||||
const signalType = getCallSignalType(type);
|
||||
const login = String(state?.session?.login || '').trim();
|
||||
const storagePwd = String(state?.session?.storagePwdInMemory || '').trim();
|
||||
if (!login || !storagePwd || !signalType) {
|
||||
throw new Error('Call SendSignal: missing login/storagePwd/signalType');
|
||||
}
|
||||
try {
|
||||
await authService.callSignalToSession({
|
||||
await authService.sendSignal({
|
||||
toLogin: call.peerLogin,
|
||||
targetMode: SIGNAL_TARGET_SINGLE,
|
||||
targetSessionId: call.remoteSessionId,
|
||||
callId: call.callId,
|
||||
type,
|
||||
data,
|
||||
signalType,
|
||||
signalRequestId: call.callId,
|
||||
data: encodeCallSignalData(call.callId, type, data),
|
||||
storagePwd,
|
||||
includeClientSignature: true,
|
||||
});
|
||||
recordCallTimeline(call, `signal_out_${signalName}`, `toSession=${call.remoteSessionId}; len=${String(data || '').length}`);
|
||||
await emitDebug(call, 'info', `signal_sent_${type}`, `len=${String(data || '').length}`);
|
||||
@@ -1280,6 +1340,50 @@ async function sendSignal(call, type, data = '') {
|
||||
}
|
||||
}
|
||||
|
||||
async function broadcastSignal(call, type, data = '') {
|
||||
const signalName = getSignalTypeName(type);
|
||||
const signalType = getCallSignalType(type);
|
||||
const login = String(state?.session?.login || '').trim();
|
||||
const storagePwd = String(state?.session?.storagePwdInMemory || '').trim();
|
||||
if (!call?.peerLogin || !login || !storagePwd || !signalType) {
|
||||
throw new Error('Call SendSignal broadcast: missing peer/login/storagePwd/signalType');
|
||||
}
|
||||
try {
|
||||
const response = await authService.sendSignal({
|
||||
toLogin: call.peerLogin,
|
||||
targetMode: SIGNAL_TARGET_ALL,
|
||||
signalType,
|
||||
signalRequestId: call.callId,
|
||||
data: encodeCallSignalData(call.callId, type, data),
|
||||
storagePwd,
|
||||
includeClientSignature: true,
|
||||
});
|
||||
recordCallTimeline(
|
||||
call,
|
||||
`signal_broadcast_${signalName}`,
|
||||
`ws=${Number(response?.deliveredWsSessions || response?.deliveredCount || 0)}; push=${Number(response?.deliveredWebPushSessions || response?.deliveredFcmSessions || 0)}`,
|
||||
);
|
||||
return response || {};
|
||||
} catch (error) {
|
||||
recordCallTimeline(call, `signal_broadcast_${signalName}_failed`, toErrorText(error), 'warn');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function notifyPeerSessionsAboutConnectStart(call) {
|
||||
const winnerSessionId = String(call?.remoteSessionId || '').trim();
|
||||
if (!call || !winnerSessionId) return;
|
||||
const data = buildConnectStartData(winnerSessionId);
|
||||
try {
|
||||
await broadcastSignal(call, TYPES.CONNECT_START, data);
|
||||
recordCallTimeline(call, 'connect_start_broadcast_sent', `winnerSession=${winnerSessionId}`);
|
||||
await emitDebug(call, 'info', 'connect_start_broadcast_sent', `winnerSession=${winnerSessionId}`);
|
||||
} catch (error) {
|
||||
recordCallTimeline(call, 'connect_start_broadcast_failed', `winnerSession=${winnerSessionId}; error=${toErrorText(error)}`, 'warn');
|
||||
await emitDebug(call, 'warn', 'connect_start_broadcast_failed', toErrorText(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function ensurePeerConnection(call) {
|
||||
if (call.pc) return call.pc;
|
||||
|
||||
@@ -1451,6 +1555,7 @@ async function onAccept(call) {
|
||||
cleanupTimers(call);
|
||||
setStatus(call, 'Соединяем…', 'connecting');
|
||||
try {
|
||||
await notifyPeerSessionsAboutConnectStart(call);
|
||||
const pc = await ensurePeerConnection(call);
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
@@ -1496,16 +1601,40 @@ async function handleIncomingInvitePayload(payload, { source = 'ws' } = {}) {
|
||||
const callId = String(payload?.callId || '').trim();
|
||||
const fromLogin = String(payload?.fromLogin || '').trim();
|
||||
const fromSessionId = String(payload?.fromSessionId || '').trim();
|
||||
const type = Number(payload?.type || TYPES.INVITE);
|
||||
if (type === TYPES.CONNECT_START) {
|
||||
const call = getCall(callId);
|
||||
if (!call || call.direction !== 'in' || call.phase === 'ended') return call;
|
||||
const winnerSessionId = parseConnectStartWinnerSessionId(payload?.data || '');
|
||||
const currentSessionId = String(state?.session?.sessionId || '').trim();
|
||||
recordCallTimeline(call, 'connect_start_broadcast_received', `winnerSession=${winnerSessionId || '-'}; currentSession=${currentSessionId || '-'}`);
|
||||
if (winnerSessionId && currentSessionId && winnerSessionId === currentSessionId) {
|
||||
await emitDebug(call, 'info', 'connect_start_kept_on_winner_session', winnerSessionId);
|
||||
return call;
|
||||
}
|
||||
dismissCallUiLocally(call);
|
||||
await finalizeCall(call, {
|
||||
localReasonCode: 'completed',
|
||||
debugReason: 'answered_elsewhere_session_selected',
|
||||
suppressRemoteSignal: true,
|
||||
suppressReports: true,
|
||||
suppressSummary: true,
|
||||
});
|
||||
return call;
|
||||
}
|
||||
if (!callId || !fromLogin || !fromSessionId) return null;
|
||||
|
||||
if (activeCallId && activeCallId !== callId) {
|
||||
try {
|
||||
await authService.callSignalToSession({
|
||||
await authService.sendSignal({
|
||||
toLogin: fromLogin,
|
||||
targetMode: SIGNAL_TARGET_SINGLE,
|
||||
targetSessionId: fromSessionId,
|
||||
callId,
|
||||
type: TYPES.DECLINE_BUSY,
|
||||
data: 'busy',
|
||||
signalType: getCallSignalType(TYPES.DECLINE_BUSY),
|
||||
signalRequestId: callId,
|
||||
data: encodeCallSignalData(callId, TYPES.DECLINE_BUSY, 'busy'),
|
||||
storagePwd: state?.session?.storagePwdInMemory || '',
|
||||
includeClientSignature: true,
|
||||
});
|
||||
} catch {}
|
||||
return null;
|
||||
@@ -1804,7 +1933,7 @@ export async function startOutgoingCall(peerLogin) {
|
||||
}, 35000);
|
||||
|
||||
try {
|
||||
call.inviteDelivery = await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE });
|
||||
call.inviteDelivery = await broadcastSignal(call, TYPES.INVITE);
|
||||
recordCallTimeline(
|
||||
call,
|
||||
'invite_broadcast_ok',
|
||||
@@ -1822,7 +1951,7 @@ export async function startOutgoingCall(peerLogin) {
|
||||
const recovered = await ensureSessionForCall({ timeoutMs: preflightTimeoutMs, force: true });
|
||||
if (recovered) {
|
||||
try {
|
||||
call.inviteDelivery = await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE });
|
||||
call.inviteDelivery = await broadcastSignal(call, TYPES.INVITE);
|
||||
if (getInviteDeliveredCount(call) > 0 && call.phase === 'searching') {
|
||||
setStatus(call, 'Вызываем…', 'ringing');
|
||||
}
|
||||
@@ -1842,6 +1971,33 @@ export async function handleIncomingCallInvite(evt) {
|
||||
await handleIncomingInvitePayload(evt?.payload || {}, { source: 'ws' });
|
||||
}
|
||||
|
||||
export async function handleIncomingCallSignalViaSendSignal(evt) {
|
||||
const payload = evt?.payload || {};
|
||||
const signalType = String(payload?.signalType || '').trim();
|
||||
if (!signalType.startsWith('call_')) return false;
|
||||
|
||||
const decoded = decodeCallSignalData(payload?.data || '');
|
||||
if (!Number.isFinite(decoded.type)) return true;
|
||||
|
||||
const normalizedEvent = {
|
||||
payload: {
|
||||
callId: decoded.callId || String(payload?.signalRequestId || '').trim(),
|
||||
fromLogin: payload?.fromLogin,
|
||||
fromSessionId: payload?.fromSessionId,
|
||||
type: decoded.type,
|
||||
data: decoded.data,
|
||||
},
|
||||
};
|
||||
|
||||
if (decoded.type === TYPES.INVITE || decoded.type === TYPES.CONNECT_START) {
|
||||
await handleIncomingInvitePayload(normalizedEvent.payload, { source: 'send_signal' });
|
||||
return true;
|
||||
}
|
||||
|
||||
await handleIncomingCallSignal(normalizedEvent);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function acceptIncomingCall() {
|
||||
const call = getActiveCall();
|
||||
if (!call || call.direction !== 'in' || call.phase !== 'incoming') return;
|
||||
@@ -1885,23 +2041,11 @@ export async function handleIncomingCallSignal(evt) {
|
||||
recordCallTimeline(call, `signal_in_${signalName}`, `from=${fromSessionId || '-'}; len=${data.length}`);
|
||||
if (call.direction === 'out') {
|
||||
if (type === TYPES.RINGING) {
|
||||
if (!call.remoteSessionId && fromSessionId) {
|
||||
setRemoteSessionId(call, fromSessionId, 'ringing');
|
||||
}
|
||||
if (call.remoteSessionId && fromSessionId && call.remoteSessionId !== fromSessionId) {
|
||||
recordCallTimeline(call, 'signal_in_ringing_ignored', `selected=${call.remoteSessionId}; from=${fromSessionId}`);
|
||||
await emitDebug(
|
||||
call,
|
||||
'info',
|
||||
'ringing_from_non_selected_session_ignored',
|
||||
`selected=${call.remoteSessionId}; from=${fromSessionId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
recordCallTimeline(call, 'ringing_received', `from=${fromSessionId || '-'}`);
|
||||
} else if (type === TYPES.ACCEPT) {
|
||||
if (fromSessionId) {
|
||||
if (!call.remoteSessionId || !call.initialOfferSent) {
|
||||
setRemoteSessionId(call, fromSessionId, 'accept_before_offer_lock');
|
||||
if (!call.remoteSessionId) {
|
||||
setRemoteSessionId(call, fromSessionId, 'accept');
|
||||
} else if (call.remoteSessionId !== fromSessionId) {
|
||||
recordCallTimeline(call, 'signal_in_accept_ignored', `selected=${call.remoteSessionId}; from=${fromSessionId}`, 'warn');
|
||||
await emitDebug(
|
||||
@@ -1961,6 +2105,12 @@ export async function handleIncomingCallSignal(evt) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === TYPES.CONNECT_START) {
|
||||
const winnerSessionId = parseConnectStartWinnerSessionId(data);
|
||||
recordCallTimeline(call, 'connect_start_received', `winnerSession=${winnerSessionId || '-'}; currentRemoteSession=${call.remoteSessionId || '-'}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === TYPES.ACCEPT) {
|
||||
if (call.direction !== 'out') {
|
||||
await emitDebug(call, 'warn', 'accept_ignored_for_non_outgoing_call', `direction=${call.direction || ''}`);
|
||||
|
||||
Reference in New Issue
Block a user