Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac37710adc | ||
|
|
455bbf88d4 | ||
|
|
d629de54e8 | ||
|
|
c8bb3dcbc3 | ||
|
|
189c61c3de | ||
|
|
9b4ef4d83b | ||
|
|
3fee6e6f82 | ||
|
|
6f261cd5df | ||
|
|
9d12bbad39 | ||
|
|
8b5f8ae944 | ||
|
|
8f3a0f7867 | ||
|
|
c05a213d0d | ||
|
|
e357a035ab | ||
|
|
1edd009068 | ||
|
|
8dc0195d53 | ||
|
|
d83ab168cb | ||
|
|
8633b35388 | ||
|
|
9dcfa17de8 | ||
|
|
6baec7f311 | ||
|
|
54da6e554b | ||
|
|
d652706047 | ||
|
|
d27665262f | ||
|
|
15ea4ff37e | ||
|
|
c24412276f | ||
|
|
96410eb606 | ||
|
|
d33aea6305 | ||
|
|
eb9d509c47 | ||
|
|
2650eec1cf | ||
|
|
3e3f3096ae | ||
|
|
c23f2d2058 | ||
|
|
459a811bce | ||
|
|
778b548c9c | ||
|
|
ddf570c791 | ||
|
|
797e5d39c4 | ||
|
|
525e8e127c | ||
|
|
4a4244e417 | ||
|
|
33e1a588e8 | ||
|
|
23a6b2ed9e | ||
|
|
07858614c8 | ||
|
|
0669cd7b06 | ||
|
|
fc6dc6a93b | ||
|
|
8b3fb5f137 | ||
|
|
235bef7f7e | ||
|
|
eb9aca7fe8 | ||
|
|
d548e31c1d | ||
|
|
de6fa56a73 | ||
|
|
c67b3e32e8 | ||
|
|
70a5f0345f | ||
|
|
e162b60961 | ||
|
|
20ba312340 | ||
|
|
63751b3715 | ||
|
|
b4de48f0f6 | ||
|
|
45064e2f33 | ||
|
|
a13a6e0941 | ||
|
|
8201b075f4 | ||
|
|
c02d5a6cfc | ||
|
|
7319f900c4 | ||
|
|
1aa528d352 | ||
|
|
fcff5c626b | ||
|
|
81bba75d02 | ||
|
|
592b6e1b75 | ||
|
|
3481e2fe69 | ||
|
|
81bd4ae982 | ||
|
|
017db6bb2d | ||
|
|
33e403ab44 | ||
|
|
278d46b985 | ||
|
|
9e526ab003 | ||
|
|
a45e6a3b12 | ||
|
|
cfd8ea055e | ||
|
|
0b4bb1659a | ||
|
|
bd175ac87f | ||
|
|
b0c762aea7 | ||
|
|
13fd7cd34a | ||
|
|
306c38ca82 | ||
|
|
2c9f122d57 | ||
|
|
c3b6a70bfb | ||
|
|
d1d00c1393 | ||
|
|
a4243328cc | ||
|
|
e7ffbd5411 | ||
|
|
0804d8cc9c | ||
|
|
6b11e9f167 | ||
|
|
58c47bb24f | ||
|
|
c11e38963a | ||
|
|
f4939e4444 | ||
|
|
5528194be5 | ||
|
|
e9d794a718 | ||
|
|
0b3e23746b | ||
|
|
586f19faf5 | ||
|
|
d4ea78d619 | ||
|
|
d44dd362d3 | ||
|
|
c67eb03f6e | ||
|
|
bfc8e26431 | ||
|
|
9edc06cb09 | ||
|
|
27b0166324 | ||
|
|
515da0304c | ||
|
|
b1bfb94f1f | ||
|
|
13d838988e | ||
|
|
1d0459534d | ||
|
|
75ab57798e | ||
|
|
3d4dd893b6 | ||
|
|
62b50c05ff | ||
|
|
18c85dc44f | ||
|
|
710a9de15c | ||
|
|
a9e05ed8e3 | ||
|
|
cef3f36ab3 | ||
|
|
d14555db46 | ||
|
|
c09ab9f344 | ||
|
|
29013ebccc | ||
|
|
3c58fc80e3 | ||
|
|
307ca10733 | ||
|
|
998efdbb74 | ||
|
|
9f11dcae0e | ||
|
|
47e0c99f64 | ||
|
|
e980ae048a | ||
|
|
31e439e74d | ||
|
|
11ab7188fa | ||
|
|
33db6c909f | ||
|
|
27eb0fa546 | ||
|
|
dfdd89b5f0 | ||
|
|
a3600c8a44 | ||
|
|
ce840f56b3 | ||
|
|
614ed165e1 | ||
|
|
eabf2924a1 | ||
|
|
0e78c1280b | ||
|
|
2552a9cfa9 | ||
|
|
9a8814e2e6 | ||
|
|
07f0ea6e14 | ||
|
|
aebc3fcd89 | ||
|
|
3d60298d21 | ||
|
|
1227e216f7 | ||
|
|
dcd46a31e0 | ||
|
|
f3cff571dc | ||
|
|
1b34f5ad82 | ||
|
|
64d9657aa9 | ||
|
|
0c5c5c03ca | ||
|
|
2a873c1715 | ||
|
|
2156bab53b | ||
|
|
455d9971ba | ||
|
|
1ebfad32f5 | ||
|
|
c83803e5d6 | ||
|
|
faca1266dd | ||
|
|
47082e966c | ||
|
|
a6748335b4 | ||
|
|
c3575aca6f | ||
|
|
5d0e4a4673 | ||
|
|
509368b76d | ||
|
|
8b9adff206 | ||
|
|
b245988ffa | ||
|
|
cd25f92df4 | ||
|
|
83fb89f9ea | ||
|
|
b8a1d523e7 | ||
|
|
4c8f13bcc6 | ||
|
|
71f73a21e1 | ||
|
|
ec8d8f8734 | ||
|
|
38fd7a723a | ||
|
|
abbf1cc60c | ||
|
|
0edb47fb4f | ||
|
|
e22e66bc4e | ||
|
|
0d7452b836 | ||
|
|
ae26d626c8 | ||
|
|
1a7f877f32 | ||
|
|
dd60f089a1 | ||
|
|
3069d1c781 | ||
|
|
09a3d09013 | ||
|
|
47846709f2 | ||
|
|
6965e11fe1 | ||
|
|
f30ef75384 | ||
|
|
83925d4e56 | ||
|
|
f2c8993589 | ||
|
|
9f6495e4ae | ||
|
|
31c8f35b6b | ||
|
|
5ac829363e | ||
|
|
cbffb6492a | ||
|
|
bb02709be3 | ||
|
|
22abcd13ca | ||
|
|
80f2bb1c84 | ||
|
|
7198a83031 | ||
|
|
c02e196b4e | ||
|
|
a6416297af | ||
|
|
30c7364976 | ||
|
|
4a34b174c5 | ||
|
|
eac0163b69 | ||
|
|
6be77de7f6 | ||
|
|
ed9e171683 | ||
|
|
fc4dddfc28 | ||
|
|
d7630b9700 | ||
|
|
a3b94aff34 | ||
|
|
2b37d071b5 | ||
|
|
7255e76ef7 | ||
|
|
5f2eb2ef70 | ||
|
|
66f817e0b5 | ||
|
|
5d7efad400 | ||
|
|
c9bdfc5054 | ||
|
|
2b83be1faa | ||
|
|
51b9c6e6f0 | ||
|
|
3330d840e6 | ||
|
|
5054a9aa99 | ||
|
|
03bd1382b8 | ||
|
|
a6e8bde6f8 | ||
|
|
ebba859223 | ||
|
|
972041ac95 | ||
|
|
cd22ae59e3 | ||
|
|
f3fb1c64ac | ||
|
|
9700ea3340 | ||
|
|
44d9b49252 | ||
|
|
673c2a3752 | ||
|
|
d20111318c | ||
|
|
c6a5c13d3f | ||
|
|
3a54bdd3a2 | ||
|
|
63775a9045 | ||
|
|
04567fd328 | ||
|
|
c6a762b286 | ||
|
|
8f988af0df | ||
|
|
90b90738f1 | ||
|
|
626fa26a5b | ||
|
|
1c41dc0c32 | ||
|
|
e93c67afd5 | ||
|
|
b6ea2ed401 | ||
|
|
8f85ed9d17 | ||
|
|
ced7a06ec8 | ||
|
|
f7c3251969 | ||
|
|
b5e8d83c51 | ||
|
|
dbe639ff55 | ||
|
|
cb9036d54c | ||
|
|
48761ff473 | ||
|
|
e40e9f6eb1 | ||
|
|
600b87d601 | ||
|
|
b2be8ffc19 | ||
|
|
34df38da5a | ||
|
|
a1653d648b | ||
|
|
e7b850f3ea | ||
|
|
aae66a3de5 | ||
|
|
01c2aaad70 | ||
|
|
24683009db | ||
|
|
3ce096d352 | ||
|
|
af9ad53a7d | ||
|
|
a141fc7215 | ||
|
|
ac5fbf0ffb | ||
|
|
6a1ef52c67 | ||
|
|
8cb2a85dc9 | ||
|
|
0c0233600c | ||
|
|
45f6e4807f | ||
|
|
635a03bbd8 | ||
|
|
a35b09c4de | ||
|
|
ff94ff38ea | ||
|
|
1aa1678048 | ||
|
|
1bf9ccb74b | ||
|
|
2a7fdd0fb0 | ||
|
|
57a9afb63e | ||
|
|
7eb4450bef | ||
|
|
08a233eeed | ||
|
|
7c0814345d | ||
|
|
d89a8e127c | ||
|
|
f611cbb80b | ||
|
|
9410570e4e | ||
|
|
d682a18dd4 | ||
|
|
533b19f51f | ||
|
|
6aa24b6471 | ||
|
|
b5d008ee87 | ||
|
|
94ae8231aa | ||
|
|
23159f791a | ||
|
|
eb74188565 | ||
|
|
58948a7c0a | ||
|
|
7e75f4f3ee | ||
|
|
9c9f6f3440 | ||
|
|
d5915aec80 | ||
|
|
eab919c23b | ||
|
|
888fe5f650 | ||
|
|
9e7366d5fc | ||
|
|
317ea6565f | ||
|
|
cb354c4474 | ||
|
|
7bdeda499e | ||
|
|
88035fc25d | ||
|
|
a89c204586 | ||
|
|
7939002dba | ||
|
|
21702d6c01 | ||
|
|
24e5baa8d4 | ||
|
|
0fd4f47c84 | ||
|
|
1f2f41abaa | ||
|
|
cacad65e8c | ||
|
|
be16b73087 | ||
|
|
e836f07f4d | ||
|
|
7ca717ff1f | ||
|
|
f5e8f94336 | ||
|
|
0574d11037 | ||
|
|
1659a005c6 | ||
|
|
b801ce533d | ||
|
|
15406c8e8a | ||
|
|
12b9841fca | ||
|
|
576236daeb | ||
|
|
45f4ca0f31 | ||
|
|
25d2ac981e | ||
|
|
c9b71ee1c9 | ||
|
|
e4d9369e7d | ||
|
|
c135825579 | ||
|
|
75998f1606 | ||
|
|
1bd0bea2b4 | ||
|
|
8a6a2072aa | ||
|
|
c2aafaf349 | ||
|
|
daa9238431 | ||
|
|
de9115b90a | ||
|
|
4c308be47f | ||
|
|
d0f7b2a364 | ||
|
|
445ead109c | ||
|
|
b2fdf882b7 | ||
|
|
a9534c4dd9 | ||
|
|
ba61e43713 | ||
|
|
22c6550f7e | ||
|
|
6213a22779 | ||
|
|
d03c15cfce | ||
|
|
2d394ae74c | ||
|
|
ad73cf4097 | ||
|
|
85c9213576 | ||
|
|
89b435e72c | ||
|
|
bfd14abdbb | ||
|
|
c5c6d96384 | ||
|
|
d5f3f74ba5 | ||
|
|
b700763062 | ||
|
|
1dcd2f1ce6 | ||
|
|
093bd92bad | ||
|
|
5013fc22d8 | ||
|
|
9ba34b0faa | ||
|
|
bd1e00e894 | ||
|
|
cf12ee03fa | ||
|
|
7e7e670c0d | ||
|
|
de94b5bbaf | ||
|
|
08f9efb02b | ||
|
|
a33a913dbe | ||
|
|
3899e091d1 | ||
|
|
a053d83a60 | ||
|
|
a587e8dfdd | ||
|
|
5da3584a4d | ||
|
|
a1dd1fda75 | ||
|
|
4dc5e1b11c | ||
|
|
d840688e96 | ||
|
|
1b87f3efe9 | ||
|
|
6c4e6254dd | ||
|
|
c2e503a90a | ||
|
|
ce8f9d53f9 | ||
|
|
452f5b4169 | ||
|
|
8f4fec4247 | ||
|
|
7e508f720d | ||
|
|
02087b1878 | ||
|
|
5b734b9d83 | ||
|
|
9ff32d11c3 | ||
|
|
2cb9d7a9c5 | ||
|
|
b3460e2039 | ||
|
|
451c66ede3 | ||
|
|
db8f3622d9 | ||
|
|
f21c2582a2 | ||
|
|
0ae66896bc | ||
|
|
0573949808 | ||
|
|
2d8d57be47 | ||
|
|
d81b3139fa | ||
|
|
aa2e1b0319 | ||
|
|
998e2dbb45 | ||
|
|
8a08730be7 | ||
|
|
1dbc2bfce5 | ||
|
|
cf2880596c | ||
|
|
c26d9e9a71 | ||
|
|
26c8f7aa5a | ||
|
|
58896d179e | ||
|
|
980afcdea8 |
No files matched your search
@@ -394,7 +394,6 @@ UASYNC/LL_QUEUE/MEMORY/TIMING объединены в SYS, NORMALIZER/BBR — в
|
||||
- `route_connectivity.c/h` - Прямые UDP/TCP-пробы адресов узла для оценки достижимости и RTT при выборе пиров
|
||||
- `topo_group.c/h` - Сессии UTUN/CHAT (JOIN/ACCEPT, эпохи, READY), групповые запросы, NODEINFO/WITHDRAW и владение NCD
|
||||
- `topo_group_connect.c/h` - Подбор пиров CHAT-группы по роли: суперузлы → desktop → mobile; история/RTT внутри класса, backoff и standby burst
|
||||
- `topo_group_invite.c/h` - Получение описания канала через INVITE_INFO_REQ/RESP; добавление мембера реализовано в chat_join/chat_sync
|
||||
- `topo_node.c/h` - Модель TOPO_NODE/TOPO_GROUP_NODE, подписанные снимки узлов, сериализация и общий node_registry
|
||||
- `topo_node_sqlite.c/h` - Хранение подписанных снимков узлов, адресов, каналов и блоков мемберов в общей SQLite
|
||||
- `topo_recovery.c/h` - Восстановление потерянных маршрутов группы через READY-сессии, отмена попыток при stop
|
||||
|
||||
@@ -50,6 +50,8 @@ if test "x$with_openssl" = "xyes"; then
|
||||
AC_CHECK_LIB([crypto], [SHA256_Init],
|
||||
[AC_DEFINE([USE_OPENSSL], [1], [Define to use OpenSSL for cryptography])],
|
||||
[AC_MSG_ERROR([OpenSSL crypto library required. Use --without-openssl to use TinyCrypt.])])
|
||||
AC_CHECK_HEADERS([openssl/hpke.h], [], [AC_MSG_ERROR([DM requires OpenSSL 3.2 or newer with HPKE headers.])])
|
||||
AC_CHECK_LIB([crypto], [OSSL_HPKE_CTX_new], [], [AC_MSG_ERROR([DM requires an OpenSSL crypto library with HPKE support.])])
|
||||
LIBS="$save_LIBS"
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
# DM: доставка сообщений и файлов
|
||||
|
||||
Личные беседы принадлежат `src/dm/`. Общие CHAT-группы используются для знакомства,
|
||||
проверки ролей и маршрутизации; группа не становится идентичностью беседы.
|
||||
проверки ролей и маршрутизации. Подгруппа личных устройств — отдельная идентичность,
|
||||
не CHAT-канал. Согласованные требования: [dm_devgroup_plan.txt](dm_devgroup_plan.txt).
|
||||
|
||||
## Реализовано
|
||||
|
||||
- История и постоянная исходящая очередь сообщений в общей SQLite ядра.
|
||||
- Доставка через живой маршрут router, включая BGP-транзит.
|
||||
- При отсутствии маршрута — подписанный PUT доступному суперузлу общей группы сторон.
|
||||
- При отсутствии маршрута или ACK в течение 5 секунд — PUT суперузлу общей группы сторон.
|
||||
- Приём после проверки подписи и расшифровки, подтверждение только после commit.
|
||||
- Доставка с суперузла после возвращения получателя, независимо от отправителя.
|
||||
- Точное удаление промежуточного сообщения и сохранение квитанции для поздних повторов.
|
||||
@@ -18,6 +19,17 @@
|
||||
|
||||
## Идентичность и шифрование
|
||||
|
||||
Два режима: DEVICE закрепляет конкретные два устройства, GROUPS закрепляет
|
||||
подгруппу каждой стороны независимо, с fallback на устройство при отсутствии
|
||||
подгруппы. Смена состава/профиля не переключает существующую беседу в другой режим.
|
||||
Действие на заголовке подгруппы открывает GROUPS; на устройстве — DEVICE. Если
|
||||
своя подгруппа есть, а у выбранного устройства её нет, UI предлагает оба варианта.
|
||||
DEVICE между собственными устройствами сохраняется.
|
||||
|
||||
Сторона кодируется как `kind:1,node:LE64,ed:32,x:32`. Подгруппа имеет node=0;
|
||||
её ключи и удостоверения устройств проверяются по подписанным devgroup-записям.
|
||||
GROUPS ID — хеш домена и отсортированных полных сторон; DEVICE сохраняет прежний ID:
|
||||
|
||||
```text
|
||||
conv_hash = SHA256("utun_dm_v" || min(node_a,node_b) || max(node_a,node_b))
|
||||
conv_id = native_u64(conv_hash[0:8]) & 0x7FFFFFFFFFFFFFFF
|
||||
@@ -30,7 +42,15 @@ media_nonce = SHA256("utun_dm_media" || author || media_id || part)[..13]
|
||||
байтов хоста; native_u64 читает первые 8 байт хеша в том же порядке.
|
||||
Нулевой conv_id заменяется на 1.
|
||||
|
||||
Сообщения и медиа используют один content_key target, без ключей отдельных файлов.
|
||||
Для текста ключ выводится из X25519 закреплённых сторон. Для каждого нового файла
|
||||
создаётся собственная пара X25519 F. HPKE export-only X25519/HKDF-SHA256 выводит
|
||||
файловый AES-ключ; F_private независимо упакован HPKE AES-256-GCM для каждой стороны.
|
||||
`DM_FILE_KEYS_SIZE=224`: F_public32, file_enc32, wrap_sender80, wrap_recipient80.
|
||||
Info привязывает ключи к полным сторонам, conv, автору, UUID и назначению упаковки.
|
||||
В metadata сообщения: `DMKEY001:8`, блок ключей224, затем attachment_info.
|
||||
Обе упаковки защищены подписью сообщения; серверы не получают секретов.
|
||||
Все источники передают один и тот же ciphertext. Старые сохранённые DEVICE-файлы
|
||||
без блока ключей читаются прежним content_key; новые создаются с отдельной парой F.
|
||||
AES-256-CCM: nonce 13 байт, tag 16 байт. Максимальная порция plaintext — 65535 байт;
|
||||
порция медиа — 32768 байт. UUID медиа новый для каждой подготовки. Повтор доставки
|
||||
использует неизменяемый ciphertext. Повторная подготовка после ошибки требует нового UUID.
|
||||
@@ -42,6 +62,16 @@ AES-256-CCM: nonce 13 байт, tag 16 байт. Максимальная пор
|
||||
```
|
||||
|
||||
Автор подписывает все поля до sig. conv связывает автора с получателем.
|
||||
В GROUPS старший бит enc_len установлен: после enc_len идёт контекст
|
||||
`version:1,party_sender:73,party_recipient:73,count:LE16,targets[count]:137`.
|
||||
Target: `node:LE64,side:1,x:32,ed:32,member_sig:64`; список сортируется по node.
|
||||
Низшие 15 бит enc_len задают длину ciphertext. Нулевой ct_len остаётся допустимым
|
||||
для сохранённого DEVICE-тела. Единственный parser находится в dm_protocol.
|
||||
|
||||
Каждое сообщение фиксирует все известные источнику проверенные устройства обеих
|
||||
сторон, включая офлайн и собственные. Максимум128; превышение отклоняет всю отправку.
|
||||
Новые найденные устройства не получают старую историю автоматически. Своя реплика
|
||||
показывается исходящей, но не создаёт новую очередь рассылки.
|
||||
Для `ct=file` перед подписью добавляется descriptor `[UUID:16][plaintext_size:8][SHA256(ciphertext):32]`.
|
||||
В зашифрованном теле находится `attachment_info`; пути хранения строятся только из UUID.
|
||||
Размер data в API отправки ограничен 1024 байтами; для file сюда входит открытый
|
||||
@@ -95,13 +125,16 @@ tags, точный размер и хеш; владелец публикует
|
||||
|
||||
```text
|
||||
dm_conversations(conv_id, peer_node_id, peer_x25519, peer_ed25519, peer_name,
|
||||
group_id, last_out_seq, last_in_seq, created_at, last_ts)
|
||||
dm_messages(conv_id, dir, seq, ts, author, ct, data, sig)
|
||||
group_id, last_out_seq, last_in_seq, created_at, last_ts,
|
||||
mode, local_party, peer_party, local_key, closed)
|
||||
dm_messages(conv_id, author, seq, dir, ts, ct, data, sig, body)
|
||||
dm_outbox(conv_id, seq, body, retry_at)
|
||||
dm_pending_messages(recipient, sender, seq, msg, group_id, retry_at)
|
||||
dm_mail_receipts(recipient, sender, seq, receipt, group_id)
|
||||
dm_delivery(conv_id, author, seq, recipient, receipt, first_attempt, direct_until, retry_at, supernode)
|
||||
dm_sequence(node_id, value)
|
||||
dm_pending_messages(recipient, sender, seq, msg, retry_at)
|
||||
dm_mail_receipts(recipient, sender, seq, receipt)
|
||||
dm_history(conv_id, generation, hidden)
|
||||
dm_deleted_messages(conv_id, dir, seq, body_hash, media_id, media_receipt)
|
||||
dm_deleted_messages(conv_id, author, seq, body_hash, media_id, media_receipt)
|
||||
dm_local_files(path, conv_id, generation, busy, retry_at)
|
||||
```
|
||||
|
||||
@@ -109,14 +142,14 @@ dm_local_files(path, conv_id, generation, busy, retry_at)
|
||||
одна транзакция. При ошибке откатывается всё, сообщение не выходит в сеть.
|
||||
Приём, создание/обновление беседы и история — также одна транзакция.
|
||||
|
||||
Dedup — точный ключ (conv,dir,seq) с проверкой сохранённой подписи. last_in_seq —
|
||||
Dedup — точный ключ (conv,author,seq) с проверкой полного подписанного тела. last_in_seq —
|
||||
статистика максимального номера, не курсор подтверждения: seq=99 после seq=100
|
||||
сохраняется. Тело с другой подписью для уже сохранённого seq отклоняется.
|
||||
|
||||
Локальное удаление (`dm_history_delete`) сохраняет запись беседы, ключи и счётчики,
|
||||
скрывает её (`dm_history.hidden`) и увеличивает постоянное `generation`.
|
||||
В одной транзакции удаляются история, outbox и локальные media-записи, а SHA256
|
||||
точных удалённых тел сохраняется в `dm_deleted_messages` по `(conv_id,dir,seq)`.
|
||||
точных удалённых тел сохраняется в `dm_deleted_messages` по `(conv_id,author,seq)`.
|
||||
Файловая квитанция сохраняется только если она уже была получена; удаление само её не создаёт.
|
||||
После commit отменяются незавершённые операции; физическая уборка файлов выполняется отдельно.
|
||||
Это локальная операция: история адресата и промежуточные копии ею не отзываются.
|
||||
@@ -127,6 +160,17 @@ Dedup — точный ключ (conv,dir,seq) с проверкой сохра
|
||||
явный `dm_start` также снимает hidden без смены поколения. Отложенные отправки
|
||||
проверяют generation, чтобы старый запрос не попал в повторно открытую беседу.
|
||||
|
||||
Счётчик автора общий для его бесед: mailbox `(recipient,author,seq)` остаётся
|
||||
однозначным, когда те же устройства одновременно используют DEVICE и GROUPS.
|
||||
Каждый адресат подтверждается отдельно; outbox удаляется только после всех ACK.
|
||||
|
||||
Выход из подгруппы атомарно закрывает старые GROUPS-беседы и удаляет исходящие
|
||||
очереди. Старый групповой секрет не архивируется. Уже сохранённая история читается
|
||||
ключом беседы, упакованным для собственного устройства; скачанные файлы остаются.
|
||||
Новые приём/отправка/скачивание закрыты, незавершённые workers не публикуют результат.
|
||||
Сам выход не ротирует ключи остальных устройств; это не криптографический отзыв
|
||||
секрета у скомпрометированного устройства.
|
||||
|
||||
Загрузка беседы различает `FOUND`, `NOT_FOUND` и `ERROR`: отсутствие означает
|
||||
только успешно завершённый SELECT без строки. Ошибка SQL, повреждённые ключи
|
||||
или отказ X25519 прекращают открытие/приём/отправку/приглашение без пересоздания
|
||||
@@ -135,7 +179,7 @@ Dedup — точный ключ (conv,dir,seq) с проверкой сохра
|
||||
|
||||
Таймеры работают раз в 1с: outbox A и текстовая очередь S обрабатывают до 32 задач;
|
||||
локальные файлы endpoint, custody M и media jobs S — до 8 задач каждый.
|
||||
Обычный retry_at — через 5с; исходящий файл с квитанцией проверяется через 3600с,
|
||||
Обычный retry_at — через 5с; файлы повторяют неподтверждённые факты независимо от текста,
|
||||
неудачная уборка локального пути повторяется через 30с. Это время допуска следующей
|
||||
попытки, не гарантия доставки за указанный срок. Перед отправкой проверяется backpressure router.
|
||||
Потеря пути сохраняет durable задачу; stop отменяет таймеры, но сохраняет БД.
|
||||
@@ -158,6 +202,7 @@ SQLite принадлежит ядру и остановкой DM не закр
|
||||
```
|
||||
|
||||
Квитанцию создаёт только получатель после commit. Подпись охватывает первые 72 байта.
|
||||
GROUPS использует домен `DMACKG01`; получатель обязан быть в подписанном snapshot.
|
||||
Отправитель удаляет outbox только после проверки подписи и точного хеша тела.
|
||||
История беседы сохраняется. Подтверждение транспорта router не заменяет квитанцию.
|
||||
|
||||
@@ -167,12 +212,15 @@ SQLite принадлежит ядру и остановкой DM не закр
|
||||
PUT=1 [recipient:8][message]
|
||||
DELIVER=2 [message]
|
||||
RECEIPT=3 [receipt:136]
|
||||
STORED=4 [conv:8][seq:8]
|
||||
STORED=4 [conv:8][seq:8][recipient:8]
|
||||
MEDIA_REQUEST=5 [recipient:8][file-message]
|
||||
```
|
||||
|
||||
Все пакеты идут с подписью router; unsigned control отклоняется.
|
||||
PUT принимается только от автора, при роли supernode=yes у локального узла
|
||||
в общей группе автора и получателя. Роль берётся из подписанного adm_tags;
|
||||
в допущенной группе. Маршрут A/S и маршрут S/B выбираются независимо;
|
||||
роль S проверяется среди допущенных общих групп проверяющей стороны и S.
|
||||
Роль берётся из подписанного adm_tags;
|
||||
node_type=4 — производная сетевой классификации, для авторизации не используется.
|
||||
storage=yes само по себе не даёт права хранить сообщения.
|
||||
|
||||
@@ -185,6 +233,7 @@ dm_mail_receipts содержит только служебные квитанц
|
||||
с промежуточного узла удаляется. Ранее использовавшийся dm_mail/PULL/HELLO и
|
||||
диапазонные ACK не поддерживаются: это devel, совместимость не сохраняется.
|
||||
TTL текстовых сообщений не введён; недельный TTL из pm.txt относится к медиа.
|
||||
MEDIA_REQUEST независимо регистрирует файловое задание; PUT занимается только текстом.
|
||||
|
||||
## Доставка медиа по pm.txt
|
||||
|
||||
@@ -195,10 +244,14 @@ TTL текстовых сообщений не введён; недельный
|
||||
2. Отдельный owner dm_media: подготовка ciphertext, manifest, отправка/скачивание,
|
||||
временные файлы, TTL и лимит хранения. Каталоги dm_media/ и dm_pending/ вне
|
||||
группового обхода очистки orphan-файлов; групповые объявления HAVE_BLOCK не используются.
|
||||
3. Суперузел сначала durable регистрирует сообщение и media job, затем выбирает M.
|
||||
3. Автор сначала предлагает файл напрямую всем адресатам. Если через 5с остаются
|
||||
неподтверждённые устройства, включая свои, отдельно заказывает хранение у S.
|
||||
Автозагрузка ограничена storage_autoload и storage_unit_size; ручная команда
|
||||
снимает ограничение автозагрузки для конкретного файла. S фиксирует media job и выбирает M.
|
||||
M скачивает ciphertext у A и сообщает S подтверждённое хранение, место и expiry.
|
||||
4. Текстовая квитанция и квитанция медиа независимы. Получатель подтверждает медиа
|
||||
после проверки, публикации файла и commit. S хранит задачу удаления у M до DELETE_ACK
|
||||
4. FILE_RECEIVED подтверждает полную проверенную загрузку, TAKEN — только явное
|
||||
успешное открытие пользователем. Превью, экспорт и автозагрузка не дают TAKEN.
|
||||
S хранит задачу удаления у M до DELETE_ACK
|
||||
либо завершения подтверждённого TTL назначения; поздний STORED не возрождает доставку.
|
||||
5. TTL медиа `dm_media_ttl_sec` по умолчанию 604800 с; квота `dm_media_storage_mb`
|
||||
по умолчанию 1024 МиБ. Настройки на M. Новая custody отклоняется при
|
||||
@@ -210,7 +263,8 @@ TTL текстовых сообщений не введён; недельный
|
||||
обработку STORE без `REJECT` и регистрации custody. Назначение M сохраняется на S;
|
||||
следующий STORE приходит через существующий таймер повторов. Квота сравнивается
|
||||
только после получения строки SUM и успешного finalize. Реальный недостаток места
|
||||
вызывает `REJECT` и выбор другого M. Исправление и регрессии:
|
||||
вызывает сохранённый `REJECT` для этого assignment_id. Через 60с S пробует другой M,
|
||||
а при отсутствии нового кандидата — прежний M с новым assignment_id. Исправление и регрессии:
|
||||
[dm_audit_findings.txt](dm_audit_findings.txt), раздел6.
|
||||
|
||||
Освобождение назначения на S по EXPIRED/REJECT и сброс текущего holder выполняются
|
||||
@@ -230,26 +284,74 @@ Control `ETCP_RT_ID_DM_MEDIA=0x38`: перед body идёт один байт
|
||||
| Команда | Body |
|
||||
| --- | --- |
|
||||
| OFFER=1 | recipient:8, holder:8, expires:8, file-message |
|
||||
| STORE=2 | recipient:8, file-message |
|
||||
| STORED=3 | UUID:16, expires:8 |
|
||||
| RECEIPT=4 / DELETE=5 | файловая квитанция:152 |
|
||||
| DELETE_ACK=6 / EXPIRED=7 | UUID:16 |
|
||||
| REJECT=8 | UUID:16, reason:1 (1 — недостаток квоты) |
|
||||
| STORE=2 | assignment_id:16, recipient:8, file-message |
|
||||
| STORED=3 | assignment_id:16, UUID:16, expires:8, delete_on_open:1 |
|
||||
| RECEIPT=4 | файловая квитанция:152 |
|
||||
| DELETE=5 | assignment_id:16, файловая квитанция:152 |
|
||||
| DELETE_ACK=6 / EXPIRED=7 | assignment_id:16, UUID:16 |
|
||||
| REJECT=8 | assignment_id:16, UUID:16, reason:1 (1 — недостаток квоты) |
|
||||
| RESERVED=9 | assignment_id:16, UUID:16, expires:8, delete_on_open:1; место принято, ciphertext ещё не готов |
|
||||
| HAVE=10 / HAVE_ACK=11 | UUID:16, cipher_hash:32, probe_token:8, cipher_size:8 |
|
||||
| SOURCE_GONE=12 | UUID:16, SHA256(file-message):32, holder:8 |
|
||||
|
||||
Подпись router обязательна. Media receipt содержит `DMFILE01`, conv, seq, author,
|
||||
recipient, UUID и SHA256 полного подписанного сообщения; B подписывает первые 88 байт.
|
||||
Подпись router обязательна. Media receipt содержит домен, conv, seq, author,
|
||||
устройство, UUID и SHA256 полного подписанного сообщения; устройство подписывает первые88 байт.
|
||||
FILE_RECEIVED: `DMFILE01`/`DMFILEG1`, TAKEN: `DMTAKE01`/`DMTAKEG1` для DEVICE/GROUPS.
|
||||
TAKEN разрешён только стороне получателя. `dm_file_targets(id,node,side,received,taken)`
|
||||
хранит отдельные факты; источник уже имеет оригинал и исключён из целей файла.
|
||||
Отдельные таблицы `dm_files`, `dm_custody`, `dm_custody_done`, `dm_media_jobs`,
|
||||
`dm_media_holders` сохраняют состояния и tombstones. Все выданные grants M
|
||||
учитываются при удалении; повторный STORE не продлевает TTL.
|
||||
`dm_media_holders` сохраняют состояния и tombstones. `dm_custody_requests`
|
||||
сохраняет решение M для каждой пары (S, assignment_id), включая отказ по квоте.
|
||||
Несколько координаторов ссылаются на одну физическую копию; у копии нет единственного S.
|
||||
Повтор принятого STORE возвращает её текущий статус без продления TTL. Повтор отказа
|
||||
остаётся отказом даже после освобождения места. Конфликт assignment_id/UUID/тела отклоняется.
|
||||
S сверяет ответ с сохранёнными M и assignment_id; старый ответ не меняет новую попытку.
|
||||
DELETE передаёт подписанный факт получателя: assignment_id не разрешает произвольное удаление.
|
||||
|
||||
A предлагает собственный файл с holder=A и expires=0. S выбирает M с storage=yes
|
||||
в общей группе A/B, исключая A/B и все прежние назначения этого UUID. Назначение
|
||||
фиксируется до STORE; S/M могут быть одним узлом, local control обрабатывается синхронно.
|
||||
в допущенной общей группе S/M. Роль ищется независимо от текущего пути пакета.
|
||||
Каждая пара A/S, S/M, M/A, S/B и M/B использует свою доступную общую CHAT-группу;
|
||||
общая группа всех участников или автора и получателя не требуется.
|
||||
В pending/receipts/files/sources/custody/jobs/holders нет постоянного group_id.
|
||||
Случайный 128-битный assignment_id фиксируется до STORE и сохраняется при смене пути/рестарте.
|
||||
S/M и endpoint/M могут совмещаться. Local control обрабатывается синхронно,
|
||||
локальное копирование ciphertext — worker-ом. Endpoint-кеш и custody имеют отдельные
|
||||
пути; отзыв передач по пути не отменяет передачу другой копии того же UUID.
|
||||
Успешная передача control в router или локальный обработчик не означает durable успех.
|
||||
|
||||
EXPIRED относится к промежуточной копии. B помечает ещё не готовый файл expired,
|
||||
но продолжает попытки получить оригинал у A. После проверки исходного ciphertext,
|
||||
публикации и сохранения квитанции файл снова ready; поздний EXPIRED его не меняет.
|
||||
Политика задаётся на M в `[chatserver]`:
|
||||
|
||||
```ini
|
||||
dm_media_ttl_sec=604800
|
||||
dm_media_storage_mb=1024
|
||||
dm_media_delete_on_open=yes
|
||||
```
|
||||
|
||||
Первый STORE фиксирует expiry и политику; дубли и смена настройки не продлевают срок
|
||||
и не меняют уже принятую политику, в том числе после restart. При yes первый TAKEN
|
||||
от устройства получателя либо TTL завершает хранение. При no — TTL либо FILE_RECEIVED
|
||||
от ВСЕХ устройств получательской стороны исходного snapshot. Свои устройства автора
|
||||
не задерживают удаление. Факты открытия/скачивания нельзя подменять ACK сообщения.
|
||||
При выполненном условии M сначала устойчиво ставит terminal, отменяет выдачу этой
|
||||
копии, удаляет файл, фиксирует tombstone и лишь затем отправляет DELETE_ACK.
|
||||
Поздние STORE, STORED и worker callbacks не возрождают терминальную копию.
|
||||
|
||||
Получатель параллельно отправляет HAVE известным онлайн-устройствам обеих сторон
|
||||
и известным M. Окно ответов1с; измеряется время запрос→ответ у получателя, затем
|
||||
выбирается минимальный RTT. Один источник передаёт файл; при отказе используются
|
||||
оставшиеся ответы. При смене источника текущая реализация начинает скачивание с0.
|
||||
Проверенный ciphertext сохраняется у каждого endpoint для последующей выдачи
|
||||
авторизованным участникам. UUID сам по себе не даёт доступа. После удаления M
|
||||
доступность зависит от оставшихся копий; отсутствующий файл без источников не восстанавливается.
|
||||
|
||||
EXPIRED завершает конкретное назначение S/M. Для endpoint используется SOURCE_GONE:
|
||||
он исключает только указанный holder с точным UUID/hash и не удаляет личный файл.
|
||||
Другой источник, оригинал автора и готовая локальная копия остаются доступными.
|
||||
Отзыв группы закрывает её FILE_TRANSFER, очереди и HAVE-пробы; поздний ответ пробы
|
||||
не возобновляет отменённую попытку. Сохранённые объекты и назначения остаются,
|
||||
следующий поиск использует другой допущенный путь. Отсутствие пути не означает истечение TTL.
|
||||
Worker проверяет локальные байты независимо от группы; callback повторно проверяет
|
||||
поколение истории, состояние копии и срок перед публикацией READY.
|
||||
Готовый файл A/B остаётся в локальной истории до явного удаления беседы.
|
||||
При удалении busy-счётчик каждого пути удерживает его до callback worker/transfer;
|
||||
уборщик работает по старым поколениям, использует busy=-1 для своего захвата
|
||||
@@ -281,7 +383,11 @@ MediaMetadataRetriever в IO. Другие контейнеры на Android о
|
||||
только задания текущей беседы. Состояния файла и delivery receipt приходят в JSON
|
||||
41; `content_type`, duration, dimensions и waveform преобразуются в общие роли
|
||||
сообщения. Канальные скачивания вызываются контроллером только для каналов;
|
||||
PM-файлы доставляются автоматически через `dm_media`.
|
||||
PM-файлы скачиваются через `dm_media` по настройке либо явной команде пользователя.
|
||||
JSON различает pending/downloading/ready/expired/unavailable и возвращает счётчики
|
||||
доставки сообщения, загрузок и открытий. Qt/Android сохраняют `(conv,author,seq,generation)`
|
||||
до запуска просмотрщика; TAKEN отправляется после его успеха. Копирование в файл
|
||||
и построение превью не считаются открытием.
|
||||
|
||||
Android использует общие `ConversationComposer`/`ConversationMessages` для обеих
|
||||
бесед; Qt — `InputBar` и `MessageDelegate`. Превью Qt создаются в общем пуле
|
||||
@@ -290,6 +396,15 @@ Android использует общие `ConversationComposer`/`ConversationMess
|
||||
|
||||
## Проверки
|
||||
|
||||
Расширение от 03.11.2026: `test_dm_identity`, `test_dm_schema`, `test_dm_groups`
|
||||
проверяют удостоверения, канонический контекст, лимит адресатов, миграцию,
|
||||
рассылку A1/A2/B1/B2, собственные реплики, независимые номера авторов, две упаковки
|
||||
ключа файла, per-device факты и закрытие при выходе с rollback.
|
||||
`test_dm_media` дополнен реальным endpoint=M: ручной получатель, локальная custody,
|
||||
перезапуск с другой настройкой, FILE_RECEIVED без удаления, TAKEN с удалением только
|
||||
custody и восстановление утраченного файла автора с реплики получателя.
|
||||
Результаты окончательных сборок/проверок зафиксированы в разделе 10 плана и doc/tasks.md.
|
||||
|
||||
Аудит контрактов DM завершён 27.10.2026: все семь заголовков `src/dm/`, этот документ,
|
||||
`pm.txt` и `pm_invites.md` сверены с реализациями owners и относящимися к ним callers.
|
||||
Это проверка соответствия документации; свежая интерактивная проверка клиентов и
|
||||
@@ -326,7 +441,8 @@ SQL rollback регистрации, состояния JSON и отмена о
|
||||
В Linux дополнительно проверяются prepare/step/finalize и неожиданный SQLITE_DONE
|
||||
при подсчёте места, отсутствие REJECT/STORED и SQL-записей при ошибке; три сбоя
|
||||
в первых трёх STORE, успешный четвёртый STORE на том же M и последующая доставка
|
||||
после restart. Проверка настоящей квоты требует один REJECT до регистрации custody.
|
||||
после restart. Проверка настоящей квоты требует REJECT до регистрации custody;
|
||||
повтор того же assignment_id возвращает REJECT и после увеличения квоты.
|
||||
Дополнительно — 18 отказов освобождения EXPIRED/REJECT: prepare/bind/step/finalize
|
||||
обоих UPDATE и commit, точный rollback назначения/job, успешный повтор, дубликат
|
||||
и поздний STORED. Сетевой сценарий теряет EXPIRED, блокирует SQL-сброс, перезапускает S
|
||||
@@ -334,7 +450,16 @@ SQL rollback регистрации, состояния JSON и отмена о
|
||||
На M проверяются 11 SQL-отказов retirement: rollback сохраняет custody и не выдаёт ACK;
|
||||
после restart M/S потерянный DELETE_ACK восстанавливается по tombstone и повтору DELETE.
|
||||
После EXPIRED и возврата A проверяются точный оригинал на B, настоящая файловая
|
||||
квитанция, очистка S и невлияние позднего EXPIRED на готовый файл.
|
||||
квитанция, очистка S и невлияние позднего SOURCE_GONE на готовый файл.
|
||||
|
||||
`test_dm_group_routes`: четыре ядра и девять попарных групп, 21 проверка.
|
||||
Прямой маршрут A/B отключён; mailbox и побайтно проверенный файл проходят
|
||||
через разные группы каждой пары. Проверяются независимый поиск роли, отказ unsigned/
|
||||
отозванному PUT, повтор через другую группу, отзыв HAVE и маршрута S/M при живом ETCP,
|
||||
отсутствие пути без потери копии, два S на одном M, неизменные срок/политика,
|
||||
durable отказ по квоте, новый assignment_id на том же M и поздний отказ прежней попытки.
|
||||
`test_dm_schema` проверяет транзакционный переход схемы без потери очереди/квитанций/
|
||||
копии, rollback ошибки ALTER и неизменность выданного assignment_id при повторном init.
|
||||
|
||||
`test_dm_worker`: повторное шифрование 8 МиБ не менее 0,5 с и проверка расшифровки;
|
||||
work выполняется вне uasync, done в его потоке, heartbeat продолжает работать.
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
Личные сообщения: устройства и подгруппы
|
||||
======================================
|
||||
|
||||
Дата: 03.11.2026. Ветка реализации: feature/dm-devgroups.
|
||||
Статус: реализовано; доступ после выхода согласован в разделе 9.
|
||||
Результаты сборок и проверок — раздел 10.
|
||||
|
||||
1. Два режима беседы
|
||||
-------------------
|
||||
DEVICE — существующая личка конкретное устройство ↔ конкретное устройство,
|
||||
включая переписку между своими устройствами. Наличие подгрупп ничего не меняет.
|
||||
Сохраняются существующие идентификаторы таких бесед и поведение интерфейса.
|
||||
|
||||
GROUPS — один режим для обеих сторон: сторона представлена своей подгруппой,
|
||||
а при отсутствии подгруппы — конкретным устройством. Это не четыре режима.
|
||||
Выбранные стороны фиксируются в беседе, самопроизвольного перехода между
|
||||
режимами при создании/удалении подгруппы нет. Переименование не меняет ID.
|
||||
|
||||
Send message на подгруппе открывает GROUPS. На устройстве с подгруппой — DEVICE.
|
||||
Если устройство назначения без подгруппы, а у отправителя подгруппа есть,
|
||||
интерфейс предлагает «С подгруппами» / «Только устройства».
|
||||
Если подгрупп нет у обеих сторон — DEVICE.
|
||||
Идентичности сторон: тип + ID узла или публичный Ed25519 ID подгруппы;
|
||||
криптографический X25519 ключ связан с проверенной подписанной записью.
|
||||
ID GROUPS выводится из режима и канонически упорядоченной пары идентичностей.
|
||||
Короткий conv_id не заменяет сравнение полной пары при загрузке/приёме.
|
||||
|
||||
2. Получатели и сообщения
|
||||
------------------------
|
||||
Для каждого сообщения фиксируется список всех известных отправляющему узлу
|
||||
проверенных устройств обеих сторон, включая офлайн-устройства. Используются
|
||||
существующие node registry и проверка подписей devgroup; новый глобальный
|
||||
каталог и поиск неизвестных устройств не вводятся. Источник уже имеет копию.
|
||||
Устройства, обнаруженные позднее, участвуют в следующих сообщениях; доставка
|
||||
старой истории новым устройствам в эту задачу не входит.
|
||||
DEVICE адресуется только выбранным двум узлам.
|
||||
При превышении допустимого размера списка отправка отклоняется целиком:
|
||||
нельзя молча обрезать адресатов или показывать успешную полную доставку.
|
||||
|
||||
Ключ сообщения: (conv_id, author_node_id, seq). Устойчивый счётчик автора общий
|
||||
для всех его бесед: это сохраняет уникальность mailbox (recipient,author,seq),
|
||||
когда одна пара устройств одновременно переписывается в DEVICE и GROUPS.
|
||||
История различает авторов независимо от UI-направления. Время не определяет
|
||||
уникальность. Проверенные собственные
|
||||
реплики показываются исходящими, но не становятся новым источником рассылки.
|
||||
Повтор допускается только с тем же подписанным телом; конфликт отклоняется.
|
||||
Подпись охватывает стороны, снимок адресатов, содержимое и дескриптор вложения.
|
||||
Все изменения истории, счётчиков и очереди атомарны. ACK только после commit.
|
||||
|
||||
Для каждого адресата отдельно учитываются попытки, дедлайн прямой доставки,
|
||||
принятие промежуточным сервером и подписанный ACK получателя. Сначала доставка
|
||||
через существующий маршрут P2P; маршрутизируемый транзит ETCP допустим.
|
||||
При отсутствии маршрута или истечении времени без ACK — offline-mailbox S.
|
||||
Наличие маршрута само по себе не должно бесконечно откладывать fallback.
|
||||
Нет разрешённой общей CHAT-группы/сервера — явное ожидание с причиной в логе.
|
||||
Принятие S и транспортный ACK не означают доставку в историю адресата.
|
||||
Повторы, рестарт и параллельный путь через S не создают второе сообщение.
|
||||
|
||||
3. Шифрование
|
||||
-------------
|
||||
В GROUPS стороны используют ключ подгруппы, при её отсутствии — ключ узла.
|
||||
В DEVICE всегда ключи конкретных устройств. Текст доступен обеим сторонам.
|
||||
|
||||
Для КАЖДОГО вложения создаётся отдельная X25519 пара F_public/F_private.
|
||||
Один шифротекст хранится и передаётся всеми источниками без перешифрования.
|
||||
F_private независимо упаковывается два раза: открытым ключом стороны
|
||||
отправителя и открытым ключом стороны получателя. Обе упаковки находятся
|
||||
в подписанном сообщении. Любой член PM раскрывает доступную ему упаковку.
|
||||
Это две независимые упаковки, не последовательное двойное шифрование.
|
||||
Серверу хранения передаются только шифротекст и подписанный дескриптор.
|
||||
|
||||
Криптографическая реализация: стандартный HPKE X25519/HKDF-SHA256 для
|
||||
инкапсуляции к F_public; export-only контекст выводит ключ существующего
|
||||
потокового AES-256-CCM. HPKE AES-256-GCM независимо оборачивает F_private
|
||||
для каждой стороны. Encapsulation, параметры, UUID, размеры и SHA256
|
||||
шифротекста входят в подписанный дескриптор. Info/AAD разделяют назначения
|
||||
и связывают операции с беседой, автором, UUID и стороной; не создают зависимости
|
||||
ключа от ещё не вычисленного хеша шифротекста. Все входы вывода файлового
|
||||
ключа фиксируются до фоновой подготовки файла. Секреты очищаются после работы.
|
||||
Требование OpenSSL HPKE проверяется сборкой на всех платформах, без самодельного
|
||||
криптографического fallback. Повтор отправки использует исходные ключи/тело;
|
||||
новая подготовка — новые UUID и пару. Nonce не переиспользуется с другим текстом.
|
||||
|
||||
4. Независимые факты и автозагрузка
|
||||
----------------------------------
|
||||
MESSAGE_RECEIVED: сообщение сохранено адресатом.
|
||||
FILE_RECEIVED: файл полностью скачан, проверен и опубликован локально.
|
||||
TAKEN: получатель действительно открыл вложение по действию пользователя.
|
||||
Эти факты не заменяют друг друга; квитанции подписывает конкретное устройство.
|
||||
Автозагрузка, получение сообщения, показ карточки/миниатюры и начало скачивания
|
||||
не создают TAKEN. Открытие на устройстве стороны отправителя не удаляет custody.
|
||||
В DEVICE между своими устройствами выбранное второе устройство — получатель.
|
||||
|
||||
Автозагрузка соблюдает настройки локального устройства и лимит размера.
|
||||
Ручное скачивание разрешено при выключенной автозагрузке. Поскольку отправитель
|
||||
не знает чужие локальные настройки, отсутствие готовой копии/отказ от
|
||||
автозагрузки — отдельный результат файловой доставки, не ACK сообщения.
|
||||
Сначала передаём P2P. Если остались устройства без файла (в том числе свои)
|
||||
из-за офлайна, настроек или ошибки — заказываем хранение на M через S.
|
||||
Этот заказ существует независимо от очереди сообщений: сообщение может быть
|
||||
подтверждено всеми устройствами, а файл ещё нужен. Один blob на вложение.
|
||||
Исключение: при политике «до загрузки всеми получателями» выполнение этого
|
||||
условия завершает хранение; собственные офлайн-устройства его не продлевают.
|
||||
|
||||
5. Источники и скачивание
|
||||
------------------------
|
||||
Перед скачиванием параллельно опрашиваются все известные онлайн-устройства
|
||||
своей стороны, собеседника и назначенные серверы хранения. Запрос HAVE относится
|
||||
к точному UUID/хешу и имеет ID. Ответ подтверждает наличие проверенной копии.
|
||||
В ограниченном окне собираются ответы, выбирается минимальный измеренный RTT
|
||||
запроса к конечному источнику. Одновременно идёт одна передача данных.
|
||||
После ошибки — следующий ответивший источник; исчерпаны — новый ограниченный
|
||||
цикл проб. Поздние ответы старого запроса игнорируются; успешную передачу
|
||||
не переключаем из-за позднего меньшего RTT.
|
||||
|
||||
Каждый получивший файл узел сохраняет проверенный шифротекст и может отдавать
|
||||
его разрешённым участникам этой беседы. Проверяется право конкретного узла,
|
||||
а не знание UUID. M не получает секретов. FILE_RECEIVED — факт успешного
|
||||
сохранения в прошлом, не обещание вечного хранения локального кеша.
|
||||
Переход между источниками первоначально начинает временный файл с нуля:
|
||||
существующий file_transfer поддерживает resume внутри передачи одному пиру,
|
||||
но не подтверждает совместимость временного файла разных операций.
|
||||
|
||||
6. Политика M и удаление
|
||||
-----------------------
|
||||
Настройка сервера: dm_media_delete_on_open (по умолчанию 1).
|
||||
Срок — существующая dm_media_ttl_sec. Политика и expires_at фиксируются при
|
||||
первом принятии вложения. Дубликаты/рестарт срок не продлевают; изменение
|
||||
настройки действует для новых заданий. S получает принятую политику и срок.
|
||||
|
||||
delete_on_open=1 delete_on_open=0
|
||||
истёк срок удалить удалить
|
||||
первый TAKEN получателя удалить сам по себе не удаляет
|
||||
все получатели FILE_RECEIVED
|
||||
недостаточно удалить
|
||||
|
||||
«Все получатели» — фиксированный получательский список этого сообщения;
|
||||
в DEVICE в нём один узел. Собственные устройства отправителя не задерживают
|
||||
удаление. Список не расширяется задним числом. Сервер проверяет отдельные
|
||||
подписанные квитанции, а не доверяет неподписанному счётчику от S.
|
||||
При delete_on_open=1 автозагрузка всеми не удаляет уже принятую копию.
|
||||
При delete_on_open=0 TAKEN до загрузки остальными не запрещает нужный STORE.
|
||||
|
||||
TAKEN — факт открытия, не безусловная команда DELETE. Его подпись и принадлежность
|
||||
открывшего получательской стороне данного сообщения проверяются до применения
|
||||
политики. Поддельные, чужие, повторные квитанции не меняют условие завершения.
|
||||
Если условие завершения уже выполнено, запоздалый STORE не создаёт новую копию.
|
||||
Удаление: устойчиво записать причину завершения → запретить новые выдачи →
|
||||
отменить передачи custody → удалить файл worker-ом → сохранить tombstone →
|
||||
подтвердить физическое удаление. Поздние STORE/STORED/callback не воскрешают файл.
|
||||
Локальный endpoint-кеш и custody разделены; удаление M не удаляет личную копию.
|
||||
При недоступном M удаление происходит после получения команды или по его TTL.
|
||||
Сообщение и ключевые упаковки сохраняются независимо от времени жизни файла M.
|
||||
После удаления M доступ зависит от копий участников: офлайн — ожидание,
|
||||
утрата всех копий — файл недоступен. Не обещаем восстановление без источников.
|
||||
|
||||
7. Владение модулями и состояния
|
||||
--------------------------------
|
||||
dm_identity: типизированные стороны, их разрешение и проверенные устройства.
|
||||
dm_protocol: единый разбор/сериализация подписанного тела и квитанций;
|
||||
не размножать фиксированные смещения по core/mailbox/media/frontend.
|
||||
dm_core: беседы, история и доставка сообщений адресатам.
|
||||
dm_history: локальное удаление, поколения и защита от повторного появления.
|
||||
dm_media: подготовка, локальные файлы, поиск источников, автозагрузка/открытие.
|
||||
dm_mailbox: промежуточная доставка сообщений.
|
||||
dm_mailbox_media: устойчивый координатор хранения и подтверждений.
|
||||
file_transfer: существующий асинхронный транспорт неизменяемого файла.
|
||||
|
||||
SQLite/маршрутизация/состояния выполняются в uasync-потоке; операции с файлами
|
||||
и тяжёлая криптография — существующими media workers. Сохраняются generation,
|
||||
busy/hold и порядок quiesce/drain/destroy. Нет второй независимой DM-системы.
|
||||
Список доставки и сообщения имеют одного владельца; реплики не создают рассылку.
|
||||
Изменение формата допустимо без совместимости со старым сетевым протоколом.
|
||||
Не сбрасывать счётчики/историю существующих прямых бесед и не переподписывать
|
||||
старое сообщение с уже использованным ID. Преобразование схемы и точное
|
||||
представление старых записей требуется проверить до изменения рабочего кода.
|
||||
|
||||
Qt/Android/headless получают итоговый conv_id от ядра и явный mode;
|
||||
не вычисляют результат по двум node_id. Асинхронный ответ сопоставляется
|
||||
по request_id. Перепроверить приглашения/удаление/поколения и маршрут звонка,
|
||||
чтобы выбор конкретного устройства не потерялся при появлении GROUPS.
|
||||
|
||||
Логи dm/media отражают ID беседы/сообщения/файла, адресата, переход состояния,
|
||||
попытку, выбранный маршрут/RTT, принятый срок/политику и причину удаления/отказа.
|
||||
Ключи, plaintext и PIN в лог не выводятся. Ошибочные ветки не молчат.
|
||||
|
||||
8. Этапы реализации и проверки
|
||||
-----------------------------
|
||||
[+] Создать отдельную ветку и сохранить план; исходный рабочий diff сохранён
|
||||
в /tmp/utun2-before-dm-devgroups.patch, существующие UI-правки не откатываются.
|
||||
[+] Идентичности/проверка membership, два режима, канонический ID GROUPS,
|
||||
разрешение списка известных устройств; unit-тесты подделки/коллизии/лимита.
|
||||
[+] Подписанный формат, счётчики по автору, схема истории/доставки и её переход;
|
||||
DEVICE↔DEVICE и свои устройства, два автора с seq=1, точный/конфликтный повтор.
|
||||
[+] Рассылка, per-device ACK, fallback по дедлайну, mailbox/restart;
|
||||
A1/A2/B1/B2, офлайн собственный узел, отсутствующий общий канал, поздний ACK.
|
||||
[+] Персональная пара ключей файла и две упаковки, потоковая подготовка;
|
||||
обе стороны раскрывают один blob, третья не раскрывает, порча/чужой контекст
|
||||
отвергаются; сборки desktop/Android проверяют наличие HPKE.
|
||||
[+] Автозагрузка/ручная загрузка, FILE_RECEIVED/TAKEN, сохранение ciphertext,
|
||||
независимый заказ хранения, параллельный HAVE/RTT/failover и авторизация.
|
||||
[+] Политика M, TTL и завершение по получательскому snapshot; потеря/дубли
|
||||
квитанций, рестарт, поздние STORE/STORED, удаление во время worker/передачи,
|
||||
SQL/дисковые ошибки, совмещённый S=M и endpoint=M.
|
||||
[+] Qt/Android/headless: действия подгруппы/устройства, выбор режима при fallback,
|
||||
открытие файла, статусы отдельных фактов, request_id и возврат conv_id ядром.
|
||||
[+] Обновить нормативную документацию; выполнить целевые DM/devgroup тесты,
|
||||
Qt/Android проверки и полный ./check.sh. Известные исходные отказы из
|
||||
doc/tasks.md отделять от новых регрессий. Итог фиксировать с логами.
|
||||
|
||||
9. Обнаруженный вопрос: выход из подгруппы
|
||||
----------------------------------------
|
||||
chat_devgroup_leave → cd_commit удаляет devgroup_local, включая приватный
|
||||
X25519 ключ подгруппы (src/chat/chat_devgroup.c). Стороны GROUPS закреплены,
|
||||
а упаковка F_private адресована этому ключу. Поэтому после выхода без
|
||||
дополнительного хранения секретов нельзя расшифровать ни ещё не скачанные
|
||||
вложения, ни ранее адресованные, но ещё не полученные сообщения.
|
||||
Неявное переключение старой беседы на DEVICE неверно: это другие ключи и ID.
|
||||
|
||||
Решение пользователя: выход завершает получение старой GROUPS-беседы;
|
||||
оставляем читаемыми только уже сохранённую историю и скачанные вложения.
|
||||
Прежний групповой X25519 секрет не сохраняем. Локальные ключи конкретных бесед
|
||||
и уже сохранённых вложений защищаем собственным ключом устройства. Отправка,
|
||||
новый приём и скачивание отсутствующих вложений старой GROUPS-беседы закрываются;
|
||||
незавершённые передачи/worker отменяются и не публикуют результат после выхода.
|
||||
Закреплённая беседа не меняет режим; новую переписку открываем отдельной беседой.
|
||||
|
||||
Это также выбор доступа, а не только удобства: существующий выход не меняет
|
||||
ключи на остальных устройствах. Сохранённый групповой X25519 секрет технически
|
||||
подходит и для будущих шифротекстов этой группы; строгий криптографический
|
||||
отзыв требует отдельного протокола ротации ключей, которого сейчас нет.
|
||||
Уточнение получено до изменения протокола и реализовано.
|
||||
|
||||
10. Результаты реализации и проверки — 03.11.2026
|
||||
-------------------------------------------------
|
||||
Нормативное описание протокола/таблиц/API: doc/dm_arch.md и src/dm/*.h.
|
||||
Настройка M в [chatserver]: dm_media_delete_on_open=yes/no; default=yes.
|
||||
TTL и политика фиксируются при первом STORE, а не при последующих повторах.
|
||||
Qt/Android и CLI используют итоговый ID беседы, возвращённый ядром.
|
||||
CLI: dm_start --mode device|groups; dm_file ... download|opened.
|
||||
|
||||
Проверены:
|
||||
- make -C src -j4 и полная desktop Qt-сборка — успешно.
|
||||
- ./check.sh — 236 PASS / 238. Все DM/devgroup-наборы прошли. Два ранее
|
||||
зарегистрированных таймаута по 90 s: test_group_connect и
|
||||
test_group_connect_standby (doc/tasks.md, задача от 02.11.2026).
|
||||
Лог: /tmp/dm-devgroups-final-check2.log; подробные логи: tests/logs/.
|
||||
- test_dm_identity/schema/groups: проверенные стороны, snapshot, независимые
|
||||
авторы, собственные реплики, отказ при подделке, две упаковки одного файла,
|
||||
обе политики и закрытие при выходе/rollback. Проверки A1/A2/B1/B2 выполняются
|
||||
через реальные ядра/SQLite и обработчики приёма; сетевой mailbox проверяет
|
||||
test_dm_e2e, файловый транспорт и M — test_dm_media.
|
||||
- test_dm_media: 17 фаз, реальный транспорт, TTL, потерянные квитанции,
|
||||
SQL-отказы, рестарты S/M, endpoint=M, выключенная автозагрузка,
|
||||
явное открытие и восстановление утраченного файла отправителя с реплики.
|
||||
Проверено побайтовое содержимое и отсутствие утечек tracked allocations.
|
||||
Лог отдельного запуска: /tmp/dm-devgroups-source-recovery.log.
|
||||
- Qt ctest: 121 PASS / 122. Меню подгруппы и DM-удаление прошли.
|
||||
Единственный отказ test_gui_language: исходная незакоммиченная английская
|
||||
строка «Deleted accounts» уже переведена как «Deleted», тест ожидает исходник.
|
||||
Эта пользовательская правка сохранена. Лог: /tmp/dm-devgroups-qt-final-tests.log.
|
||||
- Android assembleDebug — успешно: /tmp/dm-devgroups-android-final-build.log.
|
||||
PendingDmOpenTest 4/4, MemberRepositoryTest 9/9,
|
||||
ConversationSubmissionsTest 4/4 — успешно. Логи:
|
||||
/tmp/dm-devgroups-android-focused.log,
|
||||
/tmp/dm-devgroups-android-submissions.log.
|
||||
Общая зависимость Android unit-test запускает также CallServiceLifecycleTest:
|
||||
его прежние 28 отказов из 58 воспроизвелись; задача уже есть в doc/tasks.md.
|
||||
Целевые PM-наборы запущены отдельно от этих зависимостей.
|
||||
|
||||
Исправленные при проверке регрессии: ранняя ошибка приглашения без conv_id,
|
||||
устаревшая SQL-инъекция test_dm_load, старое ожидание отсутствующего меню
|
||||
чужой подгруппы, уведомление UI только о первом файловом получателе,
|
||||
невозможность повторно скачать файл на исходное устройство автора.
|
||||
События открытия сбрасываются при очистке состояния UI; превью/экспорт
|
||||
и собственное открытие отправителем не создают TAKEN получателя.
|
||||
|
||||
Работа закоммичена в feature/dm-devgroups; исходные пользовательские изменения
|
||||
также закоммичены отдельными коммитами по просьбе пользователя. Push, установка на телефоны и развёртывание
|
||||
не выполнялись. Интерактивный сценарий на физических устройствах не запускался.
|
||||
|
||||
11. Панель устройств групповой беседы — 03.11.2026
|
||||
------------------------------------------------
|
||||
Панель показывает текущий проверенный состав закреплённых сторон GROUPS:
|
||||
«Мои устройства» и «Устройства собеседника». Offline-узлы остаются видимыми;
|
||||
сторона без подгруппы содержит одно устройство. Список доставки каждого уже
|
||||
отправленного сообщения остаётся неизменным при обновлении панели.
|
||||
|
||||
Общий API dm_members_snapshot возвращает CHAT_EVT_DM_MEMBERS (73):
|
||||
conv_len:u8,conv:UTF8,generation:LE64,count:LE16,(side:u8,member:wire260)*count.
|
||||
Side 0 — локальная сторона, 1 — собеседник. Используется существующий codec
|
||||
chat_member_display; имена/live-состояние берутся из общей CHAT-области.
|
||||
Известный узел без общей CHAT-области также виден, без выдуманного online.
|
||||
dm_identity_members допускает пустую подгруппу; отправка через
|
||||
dm_identity_targets по-прежнему требует хотя бы одно устройство обеих сторон.
|
||||
Закрытая или DEVICE-беседа возвращает пустой состав этим API.
|
||||
|
||||
Qt заменяет содержимое панели участников при выборе GROUPS; Android открывает
|
||||
панель кнопкой участников в заголовке PM. Пока панель видна, состав и online
|
||||
обновляются каждые 2 s. Qt сохраняет выбор/прокрутку и не перестраивает
|
||||
неизменившийся снимок. Ответ другой беседы/поколения, усечённая запись,
|
||||
повторный node_id и недопустимая сторона отклоняются без частичного обновления.
|
||||
DEVICE сохраняет прежний интерфейс. Секреты, поиск новых узлов и изменение
|
||||
членства в эту операцию чтения не входят.
|
||||
|
||||
Проверка расширения:
|
||||
- C и desktop Qt собраны; Android assembleDebug успешно.
|
||||
- test_dm_groups / test_dm_identity: состав 2+2, взгляд обеих сторон,
|
||||
fallback 2+1, offline, выход 1+2, пустой архив и устаревшее поколение;
|
||||
файловый delivery snapshot после выхода устройства остаётся прежним.
|
||||
- Qt: test_member_detail, test_dm_deletion, test_desktop_commands,
|
||||
test_channel_list — 4/4. В панели отдельно проверены разделы, self первым,
|
||||
offline, смена беседы, duplicate/stale rejection и использование всей высоты.
|
||||
Логи: /tmp/dm-members-qt-final-tests.log, /tmp/dm-members-panel-visual.log;
|
||||
визуальная проверка: /tmp/dm-members-panel.png.
|
||||
- Android: DmMembersTest 3/3, PendingDmOpenTest 4/4,
|
||||
MemberRepositoryTest 9/9. Лог сборки/тестов: /tmp/dm-members-android-tests.log.
|
||||
- ./check.sh: 236/238; только прежние test_group_connect и
|
||||
test_group_connect_standby timeout. Лог: /tmp/dm-members-full-check.log.
|
||||
Изменения закоммичены в feature/dm-devgroups; на физические устройства не устанавливались.
|
||||
@@ -146,9 +146,11 @@ JSON истории вместе с manifest ограничен `DB_SYNC_MESSAGE
|
||||
При локальном удалении канала SQLite-trigger удаляет ключи, запросы и outbox в той
|
||||
же транзакции, что `channels`; незавершённый каскад, включая `RAISE(IGNORE)`, отменяет
|
||||
удаление. Также проверяется отсутствие канала и Merkle-хешей после DELETE.
|
||||
SQL-ошибка сохраняет все записи и файлы; сетевые ресурсы к этому моменту уже могут
|
||||
быть остановлены, поэтому требуется повтор команды. Внешняя транзакция отклоняется
|
||||
до отмены ресурсов. Открытая после отказа rollback транзакция требует stop владельцем.
|
||||
SQL-ошибка сохраняет все записи, файлы и работающую группу вместе с её очередями.
|
||||
Внешняя транзакция отклоняется до отмены ресурсов. Открытая после отказа rollback
|
||||
транзакция требует stop владельцем. После commit группа помечается stopping,
|
||||
затем отменяются её операции и освобождаются runtime-ресурсы. Поздний callback
|
||||
загрузки не обновляет удалённое сообщение и не посылает событие завершения.
|
||||
Только после commit удаляются ciphertext через media_index и собственные канонические
|
||||
`decrypted/<channel>/<UUID>.file`. Посторонние имена, подкаталоги и temp этот проход
|
||||
сохраняет. I/O-ошибка не возвращает удалённый канал: она логируется, очистку можно
|
||||
@@ -189,6 +191,8 @@ SQL-отказ сохраняет команду/дату и не отправл
|
||||
|
||||
Router-сервис `ETCP_RT_ID_ATTACHMENT_KEYS=0x42`. Каждый пакет обязан иметь
|
||||
`ROUTER_FLAG_SIGNED | ROUTER_FLAG_ENCRYPTED`; промежуточные узлы ключа не видят.
|
||||
Router проверяет живую CHAT-группу и актуальный локальный допуск self/peer
|
||||
(`source=0, deleted=0, admitted=1`) до обработки данных и служебных подтверждений.
|
||||
Протокол не создаёт личную беседу и не публикует служебные сообщения в канал.
|
||||
|
||||
```text
|
||||
@@ -209,8 +213,10 @@ REQUEST=1, REQUEST_ACK=2, GRANT=3, DENY=4, RESULT_ACK=5
|
||||
отклоняется без изменений до чтения идентичности/прав. Под блокировкой проверяются
|
||||
UUID/hash контейнера, допуск участника и авторство, затем читается существующий запрос.
|
||||
Отсутствующий запрос вставляется со state=0 и проверкой одной строки; дубликат сохраняет
|
||||
решение и created_at. Только incoming=1 и state=0/2/3/4 допустимы:0 ожидает автора,
|
||||
2/4 повторяют GRANT в outbox,3 повторяет DENY. Все SQL-этапы проверяются; ACK/событие
|
||||
решение и created_at. Только incoming=1 и state=0/2/3/4/5 допустимы:0 ожидает автора,
|
||||
2/4 повторяют GRANT в outbox,3 повторяет DENY. State5 означает отмену при отзыве;
|
||||
новый допустимый REQUEST переводит его в0 с новой датой и требует нового решения автора.
|
||||
Все SQL-этапы проверяются; ACK/событие
|
||||
отправляются после commit. При отказе прежние запрос/очередь сохраняются, ACK не отправляется.
|
||||
Если после commit ACK не доставлен, клиент повторяет REQUEST; сохранённый запрос
|
||||
позволяет повторно подтвердить приём и прежнее решение. Неудачный rollback с открытой
|
||||
@@ -245,6 +251,20 @@ RESULT_ACK для GRANT/DENY и уведомление интерфейса от
|
||||
создают новых строк. Таймер обрабатывает ограниченную порцию очереди с
|
||||
backpressure и повтором через 5 секунд; restart продолжает очереди.
|
||||
|
||||
Отзыв self/peer атомарно отменяет незавершённую сетевую работу пары channel/peer:
|
||||
входящие запросы0/2 и исходящие0/1 становятся5, outbox удаляется, wanted сбрасывается
|
||||
у вложений этого автора без ключа. Проверка после UPDATE/DELETE выявляет также
|
||||
RAISE(IGNORE); SQL-отказ откатывает всю отмену, событие следует только после commit.
|
||||
Повторная проверка выполняется при изменении допуска и перед каждым проходом таймера,
|
||||
в том числе после restart. Ошибка отмены откладывает весь проход; свежий допуск
|
||||
дополнительно проверяется перед отправкой и приёмом каждой команды.
|
||||
Поздние REQUEST_ACK/GRANT/DENY/RESULT_ACK не меняют отменённый запрос.
|
||||
Завершённые решения и уже полученные ключи сохраняются, локальная расшифровка разрешена.
|
||||
Повторный допуск сам не возобновляет отменённый запрос: нужен явный want;
|
||||
backfill и новая загрузка контейнера также не восстанавливают wanted автоматически.
|
||||
Новая исходящая подготовка проверяет допуск self до worker и перед публикацией сообщения.
|
||||
Qt показывает отмену с причиной отзыва доступа к группе.
|
||||
|
||||
Для доставки нужен живой маршрут обеих сторон: офлайн-mailbox этого сервиса
|
||||
не реализован. Запрос ждёт появления автора; решение ждёт появления получателя.
|
||||
Уже выданный ключ невозможно отозвать у получателя. Защита содержимого не
|
||||
@@ -280,6 +300,9 @@ restart сервиса и rollback fault без повторного тайме
|
||||
prepare/bind/step/finalize удаления метаданных; дополнительно настоящий commit veto,
|
||||
внешняя транзакция и rollback fault с остановкой владельцем. На отказе reader видит
|
||||
прежние точные ключ/метаданные/request/state/created/outbox/retry, файлы сохранены;
|
||||
runtime-группа и DB_SYNC остаются активны. Callback загрузки после успешного удаления
|
||||
не меняет сообщение и не посылает событие. Отдельно проверены расшифровка сохранённым
|
||||
ключом после отзыва self и запрет публикации уже подготовленного worker вложения;
|
||||
в единственном CHANNEL_DELETED — канал/история/локальные записи/Merkle уже отсутствуют.
|
||||
Дополнительные проверки:13 SQL-отказов привязки авторской пары,25 наблюдения новой/существующей
|
||||
идентичности и60 операций wanted/downloaded. Сверяются все поля через независимый reader,
|
||||
@@ -303,7 +326,10 @@ restart восстанавливают прежний снимок. Внешня
|
||||
outbox, ABORT/IGNORE, commit veto, внешнюю транзакцию до создания и до отправки,
|
||||
отсутствие повторного события/запроса, rollback fault без повторного таймера.
|
||||
Readonly reader видит обе строки до события и перед REQUEST; при отсутствии маршрута,
|
||||
backpressure и ошибке send настоящий restart сохраняет очередь и исходную дату запроса.
|
||||
backpressure и ошибке send настоящий restart сохраняет очередь, исходную дату запроса
|
||||
и прежнюю личность узла. Отдельные сценарии проверяют отмену при отзыве peer/self,
|
||||
полный откат при ABORT/IGNORE/commit veto, повтор без события, отклонение внешней
|
||||
транзакции, повторный допуск без автоматического возобновления и новый явный запрос.
|
||||
|
||||
`test_attachment_decisions` проверяет116 SQL-отказов/повторов выдачи и отказа, с пустой
|
||||
и существующей очередью: чтение подписанного мембера, допуск автора, запись состояния,
|
||||
@@ -335,5 +361,9 @@ state+outbox; отдельное readonly-соединение подтверж
|
||||
commit veto, ABORT/IGNORE для key/state/outbox, внешнюю транзакцию, backpressure,
|
||||
дубликаты, конфликт ключей/решений и остановку при отказе rollback. В точке ACK/события
|
||||
readonly-соединение проверяет committed ключ/состояние/очередь и сохранность соседней записи.
|
||||
Все четыре вида позднего ответа проверяются также для отменённого state5.
|
||||
`test_chat_group_isolation` передаёт запросы и ключи двух групп по одному реальному ETCP:
|
||||
отзыв только на получателе группы B отменяет ожидающий GRANT, старая подписанная сессия
|
||||
не возобновляет запрос; GRANT и RESULT_ACK группы A проходят, общий транспорт жив.
|
||||
При startup/restart дополнительно проверяются оба дерева: stale temp удаляются,
|
||||
открытый и закрытый активные temp, конечные файлы и чужие имена сохраняются.
|
||||
@@ -95,9 +95,21 @@ DEBUG etcp_route показывает candidate/nonce, repeat, возраст и
|
||||
## Подтверждение и пауза потребителя
|
||||
|
||||
DATA проходят crypto policy и verify/decrypt до изменения reorder/rx_seq.
|
||||
Если сервис требует SIGNED, подпись обязательна также для HELLO/CHALLENGE/CONFIRM, ACK/BUSY и CLOSE.
|
||||
Пустые управляющие пакеты подписываются без шифрования; ENCRYPTED применяется к сервисному payload.
|
||||
Сервис получает `[svc:1][src:8][dst:8][rx_flags:1][group:8][payload]`, всего 26 байт заголовка.
|
||||
Его callback владеет entry, ACK продвигается после успешной передачи callback.
|
||||
|
||||
Сервис может установить `authorize(inst, group, peer)` после включения обязательного SIGNED.
|
||||
Только результат 1 разрешает доступ. На входе peer — проверенный подписью конечный отправитель;
|
||||
сосед ETCP может быть посредником. Предикат читает состояние допуска, не вызывает callbacks и не меняет router.
|
||||
Он проверяется перед открытием канала, приёмом, каждой доставкой из reorder, отправкой и ретрансмитом.
|
||||
Транзитный узел пересылает пакет без применения политики конечного сервиса.
|
||||
|
||||
После commit изменения членства `etcp_router_revalidate_group` закрывает запрещённые каналы,
|
||||
очищает их очереди и уведомляет сервис. Общий ETCP и разрешённые каналы соседних групп сохраняются.
|
||||
Байты, уже переданные нижнему транспорту, отозвать нельзя; дальнейшая отправка через router запрещена.
|
||||
|
||||
ACK подтверждает только диапазон до tx_sent, в модульном порядке uint32.
|
||||
При продвижении снимаются существующие inflight-записи и один раз учитываются исходные payload bytes.
|
||||
|
||||
@@ -137,6 +149,7 @@ resume_receive планирует продолжение. Управляющий
|
||||
|
||||
Self-доставка через etcp_route_send синхронна и не создаёт канал. Она обходит сетевые crypto policy
|
||||
и receive_ready, выдаёт rx_flags=0; режим проверяется на допустимые биты.
|
||||
Предикат authorize применяется и к self-доставке.
|
||||
Payload должен быть непустым и помещаться вместе с 26-байтным заголовком в uint16 длину.
|
||||
Нет обработчика, переполнение размера или отказ памяти возвращают -1.
|
||||
Отдельный синтетический conn каждого вызова сохраняет контекст при вложенных доставках разных ядер.
|
||||
|
||||
|
After Width: | Height: | Size: 176 KiB |
@@ -0,0 +1,62 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900">
|
||||
<rect width="1600" height="900" fill="white"/>
|
||||
<text x="800.0" y="52" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="44" font-weight="700" fill="#172B46">Ваша группа. Вы решаете, кому дать доступ.</text>
|
||||
<text x="800.0" y="124" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="27" font-weight="400" fill="#536477">Вы создаёте свою группу и полностью контролируете доступ к её информации.</text>
|
||||
<rect x="60" y="227" width="955" height="488" rx="26" fill="#F2F7FF" stroke="#B3D0F3" stroke-width="2"/>
|
||||
<text x="537.5" y="258" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="30" font-weight="700" fill="#2471CF">ВАША ГРУППА</text>
|
||||
<rect x="167" y="326" width="741" height="91" rx="16" fill="#FFFFFF" stroke="#B3D0F3" stroke-width="2"/>
|
||||
<rect x="209" y="346" width="26" height="31" rx="13" fill="#FFFFFF" stroke="#2471CF" stroke-width="3"/>
|
||||
<rect x="202" y="363" width="40" height="29" rx="6" fill="#2471CF" stroke="none" stroke-width="2"/>
|
||||
<ellipse cx="222.0" cy="375.0" rx="3.0" ry="3.0" fill="#FFFFFF"/>
|
||||
<text x="564.0" y="342" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="29" font-weight="700" fill="#2471CF">Информация вашей группы</text>
|
||||
<text x="564.0" y="382" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="21" font-weight="400" fill="#536477">сообщения • файлы • трансляции</text>
|
||||
<path d="M537,418 L537,453" stroke="#B3D0F3" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M302,453 L772,453" stroke="#B3D0F3" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M302,453 L302,488" stroke="#B3D0F3" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M772,453 L772,488" stroke="#B3D0F3" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<rect x="262" y="497" width="80" height="54" rx="5" fill="#FFFFFF" stroke="#2471CF" stroke-width="3"/>
|
||||
<path d="M253,560 L351,560" stroke="#2471CF" stroke-width="4" fill="none" stroke-linecap="round"/>
|
||||
<path d="M262,551 L253,560" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M342,551 L351,560" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<ellipse cx="302.0" cy="515.0" rx="8.0" ry="8.0" fill="#2471CF"/>
|
||||
<rect x="286" y="527" width="32" height="13" rx="6" fill="#2471CF" stroke="none" stroke-width="2"/>
|
||||
<rect x="733" y="492" width="78" height="21" rx="5" fill="#FFFFFF" stroke="#2471CF" stroke-width="3"/>
|
||||
<ellipse cx="747.5" cy="502.5" rx="3.5" ry="3.5" fill="#2471CF"/>
|
||||
<path d="M763,502 L797,502" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<rect x="733" y="518" width="78" height="21" rx="5" fill="#FFFFFF" stroke="#2471CF" stroke-width="3"/>
|
||||
<ellipse cx="747.5" cy="528.5" rx="3.5" ry="3.5" fill="#2471CF"/>
|
||||
<path d="M763,528 L797,528" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<rect x="733" y="544" width="78" height="21" rx="5" fill="#FFFFFF" stroke="#2471CF" stroke-width="3"/>
|
||||
<ellipse cx="747.5" cy="554.5" rx="3.5" ry="3.5" fill="#2471CF"/>
|
||||
<path d="M763,554 L797,554" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<text x="302.0" y="588" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="24" font-weight="700" fill="#172B46">Добавленные вами</text>
|
||||
<text x="302.0" y="619" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="25" font-weight="700" fill="#172B46">участники</text>
|
||||
<text x="772.0" y="588" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="24" font-weight="700" fill="#172B46">Добавленные вами</text>
|
||||
<text x="772.0" y="619" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="25" font-weight="700" fill="#172B46">серверы</text>
|
||||
<rect x="1060" y="227" width="480" height="488" rx="26" fill="#F5F6F8" stroke="#DDE2E8" stroke-width="2"/>
|
||||
<text x="1300.0" y="258" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="30" font-weight="700" fill="#536477">ВНЕ ГРУППЫ</text>
|
||||
<rect x="1152" y="350" width="80" height="54" rx="5" fill="#FFFFFF" stroke="#7A8797" stroke-width="3"/>
|
||||
<path d="M1143,413 L1241,413" stroke="#7A8797" stroke-width="4" fill="none" stroke-linecap="round"/>
|
||||
<path d="M1152,404 L1143,413" stroke="#7A8797" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M1232,404 L1241,413" stroke="#7A8797" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<ellipse cx="1192.0" cy="368.0" rx="8.0" ry="8.0" fill="#7A8797"/>
|
||||
<rect x="1176" y="380" width="32" height="13" rx="6" fill="#7A8797" stroke="none" stroke-width="2"/>
|
||||
<rect x="1369" y="345" width="78" height="21" rx="5" fill="#FFFFFF" stroke="#7A8797" stroke-width="3"/>
|
||||
<ellipse cx="1383.5" cy="355.5" rx="3.5" ry="3.5" fill="#7A8797"/>
|
||||
<path d="M1399,355 L1433,355" stroke="#7A8797" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<rect x="1369" y="371" width="78" height="21" rx="5" fill="#FFFFFF" stroke="#7A8797" stroke-width="3"/>
|
||||
<ellipse cx="1383.5" cy="381.5" rx="3.5" ry="3.5" fill="#7A8797"/>
|
||||
<path d="M1399,381 L1433,381" stroke="#7A8797" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<rect x="1369" y="397" width="78" height="21" rx="5" fill="#FFFFFF" stroke="#7A8797" stroke-width="3"/>
|
||||
<ellipse cx="1383.5" cy="407.5" rx="3.5" ry="3.5" fill="#7A8797"/>
|
||||
<path d="M1399,407 L1433,407" stroke="#7A8797" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<text x="1192.5" y="449" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="23" font-weight="400" fill="#536477">Другие</text>
|
||||
<text x="1192.5" y="478" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="23" font-weight="400" fill="#536477">пользователи</text>
|
||||
<text x="1412.5" y="449" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="23" font-weight="400" fill="#536477">Другие</text>
|
||||
<text x="1412.5" y="478" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="23" font-weight="400" fill="#536477">серверы</text>
|
||||
<rect x="1120" y="550" width="360" height="69" rx="15" fill="#E6EAF0" stroke="none" stroke-width="2"/>
|
||||
<text x="1300.0" y="570" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="29" font-weight="700" fill="#536477">Нет доступа</text>
|
||||
<text x="1300.0" y="649" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="23" font-weight="400" fill="#536477">пока вы их не добавите</text>
|
||||
<text x="800.0" y="770" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="33" font-weight="700" fill="#172B46">Доступ появляется только после добавления в группу.</text>
|
||||
<text x="800.0" y="826" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="27" font-weight="400" fill="#536477">Вы сами выбираете участников и серверы своей группы.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.7 KiB |
@@ -0,0 +1,63 @@
|
||||
Права приглашения в CHAT-группу
|
||||
==============================
|
||||
|
||||
В свойствах группы (desktop и Android) доступна настройка «Кто может приглашать»:
|
||||
владелец/администраторы (исходное значение) либо все действующие участники.
|
||||
Изменяет настройку суперадминистратор — обладатель приватного ключа группы.
|
||||
Обычный администратор приглашает личным ключом, но не изменяет политику группы.
|
||||
|
||||
При переходе к ограниченному режиму интерфейс показывает всех участников,
|
||||
теряющих допуск, включая зависимые цепочки. Суперадминистратор отмечает тех,
|
||||
кого сохранить. Они получают новую подпись приглашения ключом группы;
|
||||
их профиль, эпоха вступления и административные роли сохраняются.
|
||||
Неотмеченные участники теряют доступ после очередной завершённой сверки MEMBERS.
|
||||
Переподпись обычного участника не сохраняет автоматически приглашённых им людей:
|
||||
в ограниченном режиме сам он всё ещё не имеет права приглашать.
|
||||
|
||||
Предпросмотр связан с Merkle root. Если данные изменились до подтверждения,
|
||||
команда возвращает conflict: требуется новый просмотр и подтверждение.
|
||||
Изменение политики и все выбранные переподписи фиксируются одной транзакцией.
|
||||
Синхронизация может принести их разными страницами; допуск пересчитывается
|
||||
после сверки, поэтому порядок страниц не вызывает временного массового отключения.
|
||||
Локальный маркер незавершённой сверки сохраняется при перезапуске.
|
||||
|
||||
Проверка членства
|
||||
----------------
|
||||
У каждой записи проверяются подписи JOIN, профиля, owner-блока и приглашения.
|
||||
Корни имеют signed_by=0 и подпись ключом группы. Остальные допущены, если
|
||||
их пригласивший участник допущен и имеет право приглашать по ТЕКУЩЕМУ режиму.
|
||||
В ограниченном режиме это первоначальный владелец или участник с текущей
|
||||
подписанной ролью admin. Исторические права не учитываются. Циклы, отсутствующий
|
||||
пригласивший, удалённые участники и недействительные подписи допуска не дают.
|
||||
Снятие admin также может исключить зависимую цепочку при следующем пересчёте.
|
||||
|
||||
admitted — локальное производное поле; оно не входит в Merkle. Исходные записи
|
||||
сохраняются: возврат устаревшей страницы не отменяет актуальную политику.
|
||||
Возврат режима «все» может снова допустить сохранившиеся действительные цепочки.
|
||||
Это не ручное удаление участника и не отзыв уже полученных им данных/ключей.
|
||||
|
||||
JOIN требует доступного приглашающего даже при существующей старой записи.
|
||||
Он подписывает join_sig || "group_join" личным Ed25519-ключом; join_sig привязан
|
||||
к каналу, идентичности и эпохе. C ждёт REQUEST_RESULT от A с текущими attempt/node,
|
||||
а также допуска J по своей таблице, и только затем отправляет JOIN_READY.
|
||||
Новый узел удерживает транспорт до первой сверки MEMBERS. Групповой обмен BGP
|
||||
доступен только после локальной проверки собственного членства и членства пира.
|
||||
|
||||
Хранение и wire
|
||||
---------------
|
||||
group_policy: channel_id, version, all_members, signature. Версия 0 — исходное
|
||||
ограничение, без отдельной подписи. Версии >0 подписаны ключом группы над доменом
|
||||
"utun.group.policy.v1" (с NUL), channel:u64BE, version:u64BE, mode:u8.
|
||||
Большая версия побеждает; при равенстве выбирается более строгий режим.
|
||||
Политика — виртуальный лист MEMBERS с ключом 0. Страница MEMBERS содержит
|
||||
count:u16BE, policy:73 (version:8, mode:1, signature:64), затем записи участников.
|
||||
Сравнение приглашений одной эпохи предпочитает подпись группы, затем меньший
|
||||
signed_by; старое приглашение не вытесняет переподпись суперадминистратором.
|
||||
Формат протокола несовместим со старой версией: все узлы необходимо обновить.
|
||||
|
||||
CLI: tools/chatcli group_policy CHANNEL admins|all [TOKEN [NODE_ID,NODE_ID,...]]
|
||||
Без TOKEN — предпросмотр; с TOKEN — применение. ID участников передаются строками.
|
||||
Событие GUI CHAT_EVT_GROUP_POLICY_RESULT (74): request:8LE, group:8LE, JSON.
|
||||
Основные журналы: member_sync (политика/допуск), chat_sync (JOIN), bgp (сессии).
|
||||
Проверки: test_group_policy, test_chat_join, test_chat_join_e2e,
|
||||
test_chat_sync_schedule, test_member_adapter и тесты окон настроек Qt.
|
||||
@@ -0,0 +1,738 @@
|
||||
Межгрупповой доступ: план изоляции CHAT-групп
|
||||
Дата: 03.11.2026
|
||||
Статус: этап 1 реализован; доработаны CONN_MGR, MEMBER_FILES, GROUP_AVATAR,
|
||||
ADMIN_KEY, RADIO_CALL, routed JOIN, CHAT NAT_DETECTION, MULTICAST_REPORT и ATTACHMENT_KEYS из этапа 2,
|
||||
RADIO/BROADCAST/MULTICAST из этапа 3.
|
||||
Из этапа 3 также закрыты текущий допуск DB_SYNC PUSH/ACK, MESSAGES и SOUND_SETTING.
|
||||
MEMBERS переведён на отложенную проверку версий; JOIN/recovery проверены через настоящий Merkle.
|
||||
Из этапа 4 доработаны FILE_TRANSFER, прямой DM, mailbox/custody и попарная доставка личных файлов.
|
||||
CALL/аудио/видео и DEVGROUP подключены к общему допуску; отзыв отменяет звонок/приглашение без переноса группы.
|
||||
Устаревший GROUP_INVITE удалён; STREAM проверяет допуск при приёме и выходе из общей FIFO управления.
|
||||
Полная матрица ещё не закрыта.
|
||||
Граница отмены дисковых операций согласована с пользователем и реализована (раздел 5).
|
||||
Уточнение объёма: по указанию пользователя UTUN пока не дорабатывать.
|
||||
|
||||
1. Цель и исходное состояние
|
||||
|
||||
На одном ядре могут существовать несколько CHAT-групп и UTUN. Текущая задача —
|
||||
изоляция CHAT-групп: узел, допущенный только в A, не должен получать данные,
|
||||
менять состояние или запускать операции другой CHAT-группы B.
|
||||
Проверка должна работать на уже открытом общем ETCP и после отзыва членства.
|
||||
Наличие транспорта, маршрута, TOPO_GROUP_NODE или записи в общем node_registry
|
||||
не является разрешением на API группы.
|
||||
|
||||
MEDIA_DELIVERY уже использует обязательную криптографию router, проверку
|
||||
topo_group_chat_allowed и закрытие запрещённых каналов через revalidate_group.
|
||||
Для него есть негативный сетевой тест на двух группах общего ETCP.
|
||||
Полный прогон после MEDIA_DELIVERY: 239/239. После первого этапа: 240/240,
|
||||
proxy 9/9, burst и load PASS; это НЕ проверка изоляции всех остальных протоколов.
|
||||
Результаты новых отрицательных сценариев приведены в разделе 5.
|
||||
|
||||
Основные подтверждённые места для исправления/проверки:
|
||||
- radio_recv/broadcast_recv: отсутствие допуска входящего соседа воспроизведено
|
||||
сетевым тестом и исправлено; проверяются также локальная отправка и forwarding;
|
||||
- conn_mgr_router_recv_handler/cm_open: существование группы/узла вместо допуска
|
||||
воспроизведено и исправлено; подключена CHAT-политика router и адресный отзыв;
|
||||
- MEMBER_FILES/GROUP_AVATAR: добавлены обязательная crypto-политика и authorize
|
||||
router, а также отмена отозванных операций; для других сервисов аудит продолжается;
|
||||
- MULTICAST: добавлены свежий допуск соседа/source/self, фильтрация очередей и подписок;
|
||||
MULTICAST_REPORT получил scoped crypto/admission для CHAT;
|
||||
- ADMIN_KEY: добавлены равенство payload channel и router group, обязательная
|
||||
криптография и допуск self/peer. Существующая проверка соответствия ключей каналу сохранена;
|
||||
- NAT_DETECTION: CHAT-пробы проверяют актуальный допуск owner/observer/helper;
|
||||
отзыв отменяет раунд, helper-пробы и bindings; транспортный профиль сохраняется;
|
||||
- member_in_channel означает наличие любой строки. Его использование в DM,
|
||||
mailbox и MEMBERS требует разбора по назначению; массовая замена недопустима.
|
||||
|
||||
2. Решение
|
||||
|
||||
Развивать существующие механизмы, без второго универсального слоя авторизации.
|
||||
|
||||
Схема обычного запроса к CHAT-группе B:
|
||||
общий транспорт
|
||||
-> проверенная личность отправителя для данного протокола
|
||||
-> живая CHAT-группа B и активное локальное членство self и peer в B
|
||||
-> право на конкретную операцию и совпадение контекста запроса с B
|
||||
-> выполнение операции внутри B.
|
||||
Отказ на любом шаге прекращает обработку до соответствующих побочных действий.
|
||||
Членство в A не помогает пройти проверку B, даже при общем ETCP и тех же ID файлов.
|
||||
JOIN и восстановление MEMBERS имеют отдельные ограниченные входы из п. 2.4.
|
||||
|
||||
2.1. Групповые routed API
|
||||
|
||||
Использовать существующие механизмы обязательной криптографии и authorize router. Для CHAT
|
||||
базовый допуск уже реализован в topo_group_chat_allowed: живая группа и активное
|
||||
локальное членство self и peer (source=0, deleted=0, admitted=1).
|
||||
Криптографически проверяется конечный source из router, а не ETCP next hop.
|
||||
SIGNED обязателен для данных и controls; существующие требования ENCRYPTED
|
||||
сохраняются. Controls без payload подписываются без шифрования пустого тела.
|
||||
|
||||
Уточнение пользователя: при доставке A -> R -> B промежуточный R не проверяет право
|
||||
A на конечный API B и не читает состав группы ради каждого транзитного пакета.
|
||||
Подпись конечного source, актуальный допуск и предметные права проверяет B до обработки.
|
||||
R выполняет транспортную работу: проверка формата/пути, защита от петель, выбор маршрута
|
||||
и ограничение очередей. Текущий SVC_ROUTE уже отделяет транзит до crypto/authorize;
|
||||
повтор из транзитной очереди также не вызывает authorize конечного API.
|
||||
Проверки ниже относятся к локальному отправителю и конечному получателю.
|
||||
Служебные BGP JOIN/NODEINFO/WITHDRAW принимаются самим соседом и меняют его локальную
|
||||
топологию; проверка его BGP-сессии/соседа остаётся отдельной от транзитной доставки данных.
|
||||
|
||||
Политика проверяется до создания состояния и вызова receive_ready/обработчика,
|
||||
при отправке, повторе, доставке очередей и loopback. Ошибка чтения допуска означает
|
||||
отказ. Callback политики остаётся чистым: чтение состояния, без мутаций/callbacks.
|
||||
Проверки прав конкретной операции остаются в сервисах: owner файла, storage,
|
||||
supernode, подпись автора, приглашение, адресат, идентификатор текущего запроса.
|
||||
Если payload содержит канал, он обязан совпадать с авторизованным router group.
|
||||
При регистрации проверять ошибки установки обеих политик и убирать частичную
|
||||
инициализацию. Отдельный новый framework или реестр дублей SQL не требуется.
|
||||
|
||||
Не требовать READY как доказательство членства. Особенно это недопустимо для
|
||||
CONN_MGR: он помогает создать соединение. Его локальный open и входящие команды
|
||||
проверяют допуск до загрузки узла в группу, cm_ensure_entry, таймеров и NCD.
|
||||
Повторная проверка нужна при выборе посредника, retry и публикации готового пути.
|
||||
Для сервисов, работающих и с CHAT, и с UTUN, новые правила применять только к CHAT.
|
||||
Не устанавливать для них глобальное требование crypto, меняющее обмен UTUN:
|
||||
нынешний etcp_router_require_crypto задаёт требование сразу всему service ID.
|
||||
Для общих service ID потребуется выбор требований по группе; crypto-проверка
|
||||
должна предшествовать допуску по source. Существующую политику UTUN сохранять,
|
||||
попытки обратиться к несуществующей CHAT-группе отклонять.
|
||||
|
||||
2.2. Raw групповые пакеты
|
||||
|
||||
RADIO/BROADCAST/MULTICAST не проходят через router. Проверять входящий
|
||||
аутентифицированный ETCP_CONN и текущий допуск его peer именно к группе пакета.
|
||||
Для рассылки по соседям также нужна принятая живая сессия этой группы на этом conn.
|
||||
Общий проверяющий код размещать рядом с топологией групп; не копировать SQL
|
||||
в каждый протокол. Проверять до dedup, очередей, callbacks и пересылки.
|
||||
Исходящие/отложенные отправки также фильтруют адресатов по текущему допуску.
|
||||
|
||||
Такая проверка закрывает доступ постороннего соседа. Она не превращает поле src
|
||||
внутри raw RADIO/MULTICAST в сквозную подпись автора. Его нельзя использовать
|
||||
как доказательство личности для привилегированной операции. Подмену src внешним
|
||||
узлом проверять тестом вместе с отказом самому соседу. Защита от подделки автора
|
||||
уже допущенным участником требует отдельного подписанного формата и не должна
|
||||
выдаваться за результат простого фильтра членства.
|
||||
|
||||
STREAM имеет собственные сессии и проверки участников; применять к нему правило
|
||||
допуска до побочных действий, сохраняя его транспортный жизненный цикл. Проверки
|
||||
соседства RADIO нельзя автоматически навязать прямому STREAM поверх общего NCD.
|
||||
|
||||
2.3. UTUN — отложено
|
||||
|
||||
Не менять допуск UTUN, его конфигурацию, DATA/TUN, NAT и TCP/UDP/ICMP proxy.
|
||||
В общих CONN_MGR, BGP, NAT_DETECTION, multicast и router сохранить поведение UTUN;
|
||||
не распространять на него новые CHAT-проверки или обязательные crypto-флаги.
|
||||
Ранее найденные пробелы UTUN остаются отдельной отложенной задачей.
|
||||
Регрессии существующего поведения UTUN запускать, доработку доступа не выполнять.
|
||||
|
||||
2.4. Bootstrap, синхронизация и личные данные
|
||||
|
||||
Routed JOIN обслуживает A-C, уже состоящих в канале; ему применим групповой допуск
|
||||
и защита controls. Новый J входит через raw CHAT_SYNC JOIN. Исключения
|
||||
разрешают только конкретную команду добавления с проверенным invite/add_key,
|
||||
целевой идентичностью, каналом, attempt и сроком действия. Временного членства нет.
|
||||
|
||||
MEMBERS/Merkle может требоваться до admitted=1 для получения подписанного состава
|
||||
и восстановления допуска. Сохранить отдельное разрешение синхронизации MEMBERS,
|
||||
привязанное к проверенной идентичности и конкретному каналу/сценарию восстановления.
|
||||
Не выдавать через него обычный DB_SYNC сообщений и другие групповые API. Простое
|
||||
наличие строки, placeholder или tombstone нельзя молча объявить достаточным правом:
|
||||
каждый разрешённый bootstrap/recovery случай должен иметь отдельный тест.
|
||||
|
||||
Исходный план доработки MEMBERS (выполнение описано в записях от 04.11 ниже):
|
||||
- Отделить обычный допуск от первого получения состава после JOIN_READY и восстановления
|
||||
состава при неполной цепочке подписантов. Проверить каждую причину admitted=0 отдельно.
|
||||
- Сейчас JOIN_INFO_RESP сохраняет запись connection-узла, JOIN_READY завершает локальный
|
||||
attempt, а свою запись новичок получает только последующим MEMBERS. Поэтому требование
|
||||
admitted(self)=1 на всём MEMBERS создаёт взаимную блокировку при законном вступлении.
|
||||
Нужен ограниченный контекст этого обмена (peer/key, channel, attempt, deadline),
|
||||
существующий до завершения получения состава и отзываемый при cancel/stop/delete.
|
||||
- Это разрешение относится только к модели MEMBERS. Общий ETCP, любая строка peers,
|
||||
placeholder, tombstone и старый READY сами по себе не являются основанием восстановления.
|
||||
Для повторного восстановления после restart отдельно определить проверяемое основание,
|
||||
не превращая историческую подпись в бессрочный доступ удалённого участника.
|
||||
- Raw GROUP_INVITE не имел проверок invite-ключа и не использовался клиентами.
|
||||
В доработке от 04.11 endpoint, клиентский API и модуль удалены; ID0x13 зарезервирован.
|
||||
Описание канала при вступлении выдаёт только актуальный CHAT_SYNC JOIN_INFO_REQ.
|
||||
|
||||
Для DM/CALL/DEVGROUP/FILE_TRANSFER разделять право пользоваться маршрутом группы
|
||||
и право на личную беседу, вызов или объект. Сохранить проверки адресата и E2E,
|
||||
снимка устройств/эпохи и истории. Смена общей группы для маршрута не должна менять
|
||||
личность собеседника или права на вложение. Отзыв доступа к A останавливает A,
|
||||
но допускает новое согласование через B, если оба участника допущены в B.
|
||||
Ранее сохранённая локальная личная история не удаляется при отзыве маршрута.
|
||||
|
||||
2.5. Отзыв и владение
|
||||
|
||||
После commit изменения допуска использовать существующий revalidate_group.
|
||||
Проверить все пути: deleted, admitted, source, изменение group_policy, удаление
|
||||
self, остановка/удаление группы. Отмена сервиса адресуется по (group, peer, service)
|
||||
и его идентификатору операции. Не закрывать общий ETCP или чужой NCD handle.
|
||||
Убирать запрещённые router/service очереди, таймеры, ожидания, подписки и retries;
|
||||
для отложенных callbacks и завершения workers повторно проверять актуальное право
|
||||
до выдачи результата или записи. Уже переданные в транспорт/полученные данные
|
||||
нельзя отозвать задним числом; проверяется прекращение новых действий после отзыва.
|
||||
|
||||
3. Этапы реализации
|
||||
|
||||
Этап 1. Стенд и общий контракт доступа
|
||||
Расширить подход test_media_delivery_integration: реальные ядра, SQLite,
|
||||
подписанные участники, две группы и один физический ETCP. Инъекция должна идти
|
||||
ниже локального запрета отправителя, иначе тест проверит лишь клиентскую защиту.
|
||||
Зафиксировать отрицательные воспроизведения RADIO/BROADCAST и CONN_MGR в CHAT.
|
||||
Добавить осмысленные логи отказа и очистки: service, group, source, next_hop,
|
||||
команда/операция, фаза проверки, причина; без приватных ключей/содержимого файлов.
|
||||
Критерий: в старом коде наблюдаем конкретный запрещённый эффект, после исправления
|
||||
он отсутствует; нормальное действие в разрешённой группе проходит.
|
||||
|
||||
Этап 2. Routed CHAT и управление соединениями
|
||||
Подключить политику у MEMBER_FILES, GROUP_AVATAR, RADIO_CALL, ATTACHMENT_KEYS,
|
||||
ADMIN_KEY и routed JOIN. ADMIN_KEY дополнительно связывает канал с router group.
|
||||
CONN_MGR проверяет вход, local open, кандидатов и отложенные переходы состояния.
|
||||
NAT_DETECTION и MULTICAST_REPORT получают допуск конечного источника и подпись
|
||||
controls для CHAT; STREAM routed — согласованную с его проверками политику.
|
||||
Общие service ID используют требования по группе, сохраняя контракт UTUN.
|
||||
MEDIA_DELIVERY остаётся контрольным образцом и регрессией.
|
||||
Критерий: чужой/отозванный peer не создаёт сессий, файлов, запросов подключения,
|
||||
NAT-проб и отчётов; unsigned HELLO/ACK/CLOSE не воздействуют на разрешённый канал.
|
||||
|
||||
Этап 3. Raw групповые обмены и отзыв
|
||||
Общий фильтр группового соседа для RADIO/BROADCAST/MULTICAST; проверка перед
|
||||
локальной доставкой, dedup, forwarding и возобновлением отложенной отправки.
|
||||
Проверить STREAM raw и очистку его flows/relay при отзыве. Проверить BGP,
|
||||
DB_SYNC, CHAT_SYNC и Merkle по отдельным bootstrap/обычным командам.
|
||||
Критерий: подстановка чужого group/src не доставляет callback, не загрязняет
|
||||
dedup и не порождает пересылку; удаление участника прекращает текущую передачу.
|
||||
|
||||
Этап 4. Личные сервисы, bootstrap и полная матрица CHAT
|
||||
Проверить DM, DM_MAILBOX, DM_MEDIA, FILE_TRANSFER, CALL/CALL_AUDIO/CALL_VIDEO,
|
||||
CALL_VIDEO_CONTROL и DEVGROUP; обязательные controls и групповые/предметные права.
|
||||
Завершить тесты raw JOIN/invite, MEMBERS recovery и серверного add_key.
|
||||
Сопоставить каждый ID из etcp_api.h с политикой и тестом либо пометкой «UTUN отложен».
|
||||
NTP_TIME/NCD_CONTROL/SVC_ROUTE — общие транспортные службы: проверить их границы
|
||||
отдельно. Не навязывать им фиктивное CHAT-членство. Проверить отсутствие обхода
|
||||
к CHAT через неизвестный ID и явно включаемую BLE_PROBE instrumentation.
|
||||
Критерий: по каждому ID есть разрешённый сценарий, отказ либо документированный
|
||||
транспортный/bootstrap контракт; без строки «не проверено» для API CHAT-групп.
|
||||
|
||||
После каждого этапа: целевые тесты и ./check.sh; изменения фиксировать отдельными
|
||||
логически завершёнными коммитами. Этапы 2/3 не означают готовность этапа 4.
|
||||
Успешный общий набор без новых отрицательных сценариев не закрывает задачу.
|
||||
|
||||
4. Обязательная матрица регрессий
|
||||
|
||||
1) Участник только A обращается к API B через существующий ETCP.
|
||||
2) Тот же peer использует API A успешно на этом же транспорте.
|
||||
3) Подмена group/source/service и несовпадение канала payload с router header.
|
||||
4) Подписанный чужим peer пакет и unsigned/поддельные HELLO/CHALLENGE/CONFIRM,
|
||||
ACK/BUSY/CLOSE: проверка не ограничивается флагом SIGNED без верификации.
|
||||
5) Доступ отзывается только на принимающем узле после открытия сессии; отправитель
|
||||
продолжает с прежней эпохой, передаёт новые пакеты и пробует открыть канал снова.
|
||||
6) Отзыв при backpressure, reorder, retransmit, активном файле, worker и callback.
|
||||
7) Удаление self, остановка/удаление B, последующее повторное добавление участника.
|
||||
8) A остаётся рабочей; общий ETCP и NCD handle другого владельца остаются живы.
|
||||
9) Разрешённый конечный отправитель через посредника: endpoint не принимает
|
||||
next_hop за source; транзит не выполняет предметный API конечного узла.
|
||||
10) Поздний пакет/результат старой операции не относится к новой группе/операции
|
||||
при совпавшем числовом stream/transfer ID. Учесть существующие границы epochs.
|
||||
11) Для запрета проверяется побочный эффект: callback/БД/файл/сокет/таймер/NCD,
|
||||
а не только отсутствие ответа. Логи объясняют точную причину отказа.
|
||||
12) Положительные JOIN, MEMBERS recovery и DM через другую общую группу проходят.
|
||||
Локальные loopback-вызовы соблюдают ту же предметную политику.
|
||||
13) Существующие UTUN-сценарии на общих сервисах сохраняют поведение и формат;
|
||||
новых требований к его конфигурации и криптографии не появляется.
|
||||
|
||||
Задача завершена только после реализации всех этапов и этой матрицы для каждого
|
||||
относящегося к CHAT сервиса. UTUN не входит в эту гарантию и остаётся отложенным.
|
||||
Сам этот документ не подтверждает устранение пробелов.
|
||||
|
||||
5. Выполненная часть и следующая граница
|
||||
|
||||
03.11.2026: test_chat_group_isolation использует два настоящих ядра, SQLite,
|
||||
подписанные записи участников, группы 111/112 и один общий ETCP.
|
||||
Отзыв admitted происходит только в БД принимающей стороны; вредоносная инъекция
|
||||
идёт через etcp_send, минуя локальные запреты отправителя. До исправления RADIO,
|
||||
BROADCAST и подписанный CM DIRECT_REQ изменяли состояние запрещённой группы.
|
||||
Исходный результат: 3 PASS / 3 FAIL, /tmp/chat-group-isolation-before.log.
|
||||
|
||||
Реализовано:
|
||||
- etcp_router_set_chat_policy задаёт обязательную криптографию и authorize
|
||||
для CHAT. Только существующая группа с типом UTUN получает прежний обмен;
|
||||
отсутствующая группа не получает исключение. Применение зависит от типа,
|
||||
а не числового group_id. Существующие глобальные политики не ослабляются.
|
||||
- topo_group_data_allowed проверяет актуальное членство self/соседа и принятую
|
||||
живую сессию группы на конкретном ETCP; локальный producer проверяет self.
|
||||
RADIO/BROADCAST вызывают его до dedup, доставки и отправки каждому соседу.
|
||||
Между пользовательскими callbacks допуск проверяется повторно.
|
||||
- CONN_MGR требует подпись данных/controls для CHAT, проверяет local open,
|
||||
входящие команды, кандидатов, повторы и публикацию UP. Отсутствующая группа
|
||||
больше не создаётся через conn_mgr_open. При отзыве снимаются только её
|
||||
entries/NCD handles; владельцы отсоединены до TIMEOUT callback.
|
||||
- topo_group_revalidate_members очищает запрещённые router/CM операции.
|
||||
RADIO отменяет локальный burst-таймер/буфер при отзыве self и индикацию
|
||||
отозванных говорящих. Общий транспорт другого владельца сохраняется.
|
||||
|
||||
Проверено:
|
||||
- test_chat_group_isolation: 11/11, /tmp/chat-group-isolation-after.log.
|
||||
Отказ в B, успешная A на том же ETCP, local CM open, отсоединение handle до
|
||||
callback, сохранение владельца A, повторный допуск без загрязнения dedup,
|
||||
запрет локальных RADIO/BROADCAST после отзыва self.
|
||||
- test_etcp_router_unit: 45/45, /tmp/chat-group-router.log.
|
||||
Новая scoped-политика сохраняет unsigned UTUN и не допускает unsigned CHAT
|
||||
HELLO; проверены закрытие запрещённого канала и сброс политики при unbind.
|
||||
- ./check.sh: 240/240, proxy 9/9, burst и load PASS,
|
||||
/tmp/chat-group-stage1-check.log. Протокольные fixtures без CHAT-БД явно
|
||||
обозначены типом UTUN; проверки реального CHAT выполняются отдельно.
|
||||
|
||||
03.11.2026: пользователь согласовал границу отзыва MEMBER_FILES:
|
||||
- отзыв запрещает новые команды, удаляет ожидающие задания и скрывает данные
|
||||
от отозванного peer; отмена принадлежит (group, peer, operation);
|
||||
- постановка в FIFO ещё не означает начало: отмена и перевод задания в
|
||||
исполняемое состояние должны быть сериализованы mutex worker;
|
||||
- если исполнение уже началось, одна команда завершается, done приходит ровно
|
||||
один раз для безопасной очистки; дальнейшие команды/выдача данных запрещены;
|
||||
- операции файловой системы не прерывать pthread_cancel и не блокировать
|
||||
uasync ожиданием диска. Уже начатая команда может изменить файл после отзыва.
|
||||
|
||||
Этот контракт реализован: mf_worker_cancel снимает обычное ожидающее задание
|
||||
под тем же mutex, что используется при начале work, и доставляет ровно один
|
||||
done(MF_WORK_CANCELLED). Reserved cleanup не отменяется. MEMBER_FILES отзывает
|
||||
pending/server jobs/streams по группе и актуальным ролям; worker-результат
|
||||
отозванного job уничтожается. Публикация локального download отделена от
|
||||
обязательного закрытия/удаления temp: ожидающую публикацию тоже можно отменить.
|
||||
Данные и controls MEMBER_FILES требуют SIGNED; данные также ENCRYPTED.
|
||||
|
||||
Проверки после доработки:
|
||||
- test_member_files: PASS, включая отмену головы/хвоста очереди при работающем
|
||||
барьере, идемпотентность отмены, сохранение running/cleanup, отсутствие утечек.
|
||||
/tmp/chat-group-mf-unit.log.
|
||||
- test_member_files_network: PASS, реальные передачи/повторы/отмена/restart,
|
||||
/tmp/chat-group-mf-network.log.
|
||||
- test_chat_group_isolation: 23/23, /tmp/chat-group-mf-isolation.log.
|
||||
Два сценария с дисковыми барьерами: queued MKDIR не меняет дерево;
|
||||
started MKDIR завершается после отзыва без выдачи ответа. Новая подписанная
|
||||
и зашифрованная инъекция в запрещённую B не создаёт job/каталог; запрос A
|
||||
проходит по тому же ETCP. После повторного допуска учитывается штатный
|
||||
5-секундный повтор MF при смене эпохи router (первый timeout3с был ошибкой стенда).
|
||||
- GROUP_AVATAR: отменяются partial RX/TX, waiters и call_soon отозванного peer;
|
||||
новый тест отзывает доступ после первого блока и при удержанном втором.
|
||||
БД сохраняет прежнюю картинку, повторный допуск восстанавливает обмен.
|
||||
test_group_avatar и test_group_avatar_sql: PASS, /tmp/chat-group-avatar.log,
|
||||
/tmp/chat-group-avatar-sql.log.
|
||||
- ADMIN_KEY: добавлены отказы при несовпадении route/payload канала в обе
|
||||
стороны, даже с правильными ключами. Убрано повторное освобождение entry
|
||||
после отказа etcp_route_send, который сам владеет entry при любом результате.
|
||||
|
||||
Полный прогон после этой части: 240/240, proxy9/9, burst и load PASS,
|
||||
/tmp/chat-group-files-avatar-check.log. Этапы 2–4 остаются открытыми.
|
||||
|
||||
RADIO_CALL: обязательные SIGNED/ENCRYPTED и допуск router; отзыв завершает
|
||||
входящее приглашение UNAVAILABLE, останавливает запрещённые исходящие ветви
|
||||
и их retry/ACK, сохраняя допущенные устройства, если победитель ещё не выбран.
|
||||
test_radio, test_call_admission: PASS. Сетевой тест отзыва RADIO_CALL: 28/28
|
||||
вместе с прежними сценариями, /tmp/chat-group-radio-call-isolation.log.
|
||||
|
||||
Routed JOIN: SIGNED/ENCRYPTED и допуск обоих существующих участников A/C.
|
||||
Регистрация проверяет текущего connection до создания таймера и повторно при ACK;
|
||||
ключ требует действующих self/инвайтера и права приглашать. Отзыв отменяет
|
||||
регистрации, ключи и ожидания JOIN_READY. Новому J членство до сохранения записи
|
||||
не требуется. Teardown группы теперь вызывает отзыв сервисов, включая локальные
|
||||
ожидания без router-сессии; UTUN этот путь не меняет.
|
||||
test_chat_join: 34/34, test_chat_join_e2e: 3/3, test_chat_join_faults: PASS;
|
||||
логи /tmp/chat-group-join.log, /tmp/chat-group-join-e2e.log, /tmp/chat-group-join-faults.log.
|
||||
test_chat_group_isolation с teardown: 35/35. Исправлена ошибка подготовки стенда: предыдущий
|
||||
сценарий снимал BGP-сессию B, поэтому перед положительным JOIN её явно восстанавливаем.
|
||||
Полный прогон после RADIO_CALL/JOIN/teardown: 240/240, proxy9/9, burst/load PASS,
|
||||
/tmp/chat-group-radio-join-check.log.
|
||||
|
||||
NAT_DETECTION: scoped SIGNED/ENCRYPTED и допуск CHAT у router; owner, observer и helper
|
||||
проверяются перед пробами, результатами и fallback. Raw OBSERVE не создаёт binding/PONG
|
||||
для чужой группы. Отзыв третьего участника также завершает раунд CANCELLED, не меняя
|
||||
измеренный NAT-профиль или транспорт UTUN. test_nat_detection и faults: PASS,
|
||||
/tmp/chat-group-nat.log, /tmp/chat-group-nat-faults.log. Сетевой стенд: 40/40,
|
||||
/tmp/chat-group-nat-isolation.log.
|
||||
|
||||
MULTICAST: текущий допуск self/source/соседа перед входом, forwarding, отправкой FIFO
|
||||
и отложенным callback. Отзыв отцепляет запрещённые подписки, убирает доставки/отчёты
|
||||
и фильтрует адресатов в общей FIFO; handle подписки остаётся у владельца до unsubscribe.
|
||||
MULTICAST_REPORT требует SIGNED/ENCRYPTED только для CHAT. Прежний UTUN-формат сохранён.
|
||||
test_multicast: PASS, /tmp/chat-group-multicast-unrestricted.log. Сетевой стенд: 50/50,
|
||||
/tmp/chat-group-multicast-isolation.log: две очереди A/B, отзыв peer/self, signed report
|
||||
со старой эпохой, raw-инъекция и сохранение A на общем ETCP. Полный прогон после NAT/MULTICAST:
|
||||
240/240, proxy9/9, burst/load PASS, /tmp/chat-group-nat-multicast-check.log (197с).
|
||||
|
||||
ATTACHMENT_KEYS: обязательная crypto/admission router, проверки local send/request/publication
|
||||
и атомарная отмена durable запросов/outbox. State5 обозначает отмену; повторный допуск сам
|
||||
не запускает работу, новый явный запрос требует нового решения автора. Backfill и новая
|
||||
загрузка контейнера не возобновляют отменённый запрос. Поздние ACK/GRANT/DENY не меняют state5.
|
||||
Полученные ключи и локальная расшифровка сохраняются. Qt показывает причину отмены.
|
||||
Проверены rollback всей отмены при ABORT/IGNORE/commit veto, внешняя транзакция, отзыв peer/self,
|
||||
повтор без события, новый запрос/согласие. test_attachment_requests и test_attachment_results PASS;
|
||||
логи /tmp/attachment-final-requests.log и /tmp/attachment-revoke-results.log.
|
||||
Сетевой test_chat_group_isolation: 54/54, /tmp/attachment-group-isolation.log. Настоящие
|
||||
SIGNED/ENCRYPTED запросы A/B создают авторские записи; отзыв B только на получателе
|
||||
отменяет ожидающий GRANT, подписанный пакет со старой эпохой не возобновляет B;
|
||||
ключ A и его RESULT_ACK доходят по тому же живому ETCP.
|
||||
|
||||
Обнаруженный конфликт удаления канала исправлен: chat_core_delete_channel сначала
|
||||
фиксирует удаление метаданных, затем помечает группу stopping и отменяет runtime/файлы.
|
||||
При SQL-отказе сохраняются канал, ключи, запросы/outbox и работающие группа/DB_SYNC.
|
||||
Поздний callback загрузки не трогает удалённое сообщение. test_chat_attachment PASS:
|
||||
14 SQL-отказов удаления и дополнительные каскад/Merkle/commit/rollback сценарии,
|
||||
сохранённый ключ расшифровывает локальный файл после отзыва self, подготовленное
|
||||
исходящее вложение после отзыва не публикуется. Лог /tmp/attachment-worker-revoke.log.
|
||||
Тестовые peer/state приведены к новому контракту. Исправлен отдельный дефект restart-fixture:
|
||||
перезапуск теперь сохраняет прежнюю личность, а не создаёт узел без допуска в канал.
|
||||
Реальный test_encrypted_attachment A–S–B/C PASS, /tmp/attachment-delete-network.log.
|
||||
Полный повтор ./check.sh: 240/240, proxy9/9, burst/load PASS (203с),
|
||||
/tmp/attachment-isolation-full-check.log. Сборка Qt завершилась, /tmp/attachment-gui-build.log.
|
||||
test_gui_language: 22 PASS/1 FAIL на прежнем пользовательском переводе Deleted accounts → Deleted;
|
||||
эта отдельная задача уже есть в doc/tasks.md, строка не менялась текущей доработкой.
|
||||
Лог /tmp/attachment-gui-language.log; общий результат GUI-теста не считается успешным.
|
||||
STREAM, синхронизация, личные сервисы и полная матрица остаются открытыми.
|
||||
|
||||
Продолжение в feature/chat-group-isolation (ветка от a3600c8a4, включает все предыдущие коммиты).
|
||||
FILE_TRANSFER теперь требует SIGNED для DATA/controls и текущий допуск self/peer в CHAT.
|
||||
Локальный pull проверяет право до fopen: запрещённая операция не усекает destination.
|
||||
Отзыв отменяет операции, CM handles, таймеры и waiters своей группы до done(-2).
|
||||
Отложенные sender/timer/CM callbacks сами проверяют допуск до продолжения I/O.
|
||||
Предметный lookup по-прежнему проверяет право на конкретный неизменяемый объект.
|
||||
Сетевой стенд проверил A/B на одном ETCP, удержанные DATA в reorder, поздний подписанный
|
||||
пакет, отсутствие lookup запрещённого запроса, unsigned CLOSE и отзыв self перед sender callback.
|
||||
Тот же объект после отказа в B полностью скачивается через A с новым transfer ID.
|
||||
До исправления локальный pull портил destination, активная B не отменялась; исходный
|
||||
стенд57/3 FAIL, /tmp/chat-file-transfer-before.log. Проверка записи затем отделена от
|
||||
проверки callback и дополнена явным resume_receive/fflush. FILE_TRANSFER-стенд62/62,
|
||||
/tmp/chat-file-transfer-controls.log. test_dm_media:17 фаз PASS, allocations0->0,
|
||||
/tmp/chat-file-transfer-dm-media.log; это регрессия доставки, не полный аудит DM_MEDIA.
|
||||
|
||||
Прямой DM получил обязательную подпись controls и авторизацию router. Подбор общей группы
|
||||
и маршрута использует свежий допуск, не любое наличие строки участника. Личная история
|
||||
и durable outbox сохраняются; повтор через другую разрешённую группу использует прежнее
|
||||
подписанное тело и адресата. Вход через B после отзыва проверен до proactive teardown:
|
||||
прежний код записывал сообщение и выбирал отозванную B; стенд63/2 FAIL,
|
||||
/tmp/chat-dm-isolation-live-before.log. Первоначальный тест с teardown скрывал этот обход,
|
||||
поэтому проверка входа перенесена до очистки сессии. После исправления65/65,
|
||||
tests/logs/test_chat_group_isolation.log. Общий ./check.sh:240/240, proxy9/9, burst/load PASS
|
||||
(199с), /tmp/chat-personal-isolation-check.log. Новые изменения зафиксированы в отдельной ветке.
|
||||
|
||||
Следующая архитектурная граница: mailbox и custody (по чтению кода, сетевого воспроизведения ещё нет).
|
||||
- dm_mailbox_media_register сохраняет job по UUID и при повторе проверяет только body_hash;
|
||||
group_id заказа остаётся прежним, даже если повтор пришёл через другую разрешённую группу.
|
||||
- dm_custody_store при точном повторе отвечает через новую группу/source, но сохранённые
|
||||
group_id/supernode не меняются. Фоновая обработка использует старые поля.
|
||||
- dm_media_holders имеет ключ (id,node_id), без идентификатора попытки. Разные попытки
|
||||
назначения того же M не различаются; смена группы сама по себе новой попыткой не является.
|
||||
- mb_scope/dm_media_scope недостаточно проверяют admitted/source/self и смешивают допуск
|
||||
транспортной пары с требованием общей группы автора/получателя/S/M. Нужны отдельные
|
||||
предметные проверки и выбор маршрута по паре узлов согласно уточнению пользователя ниже.
|
||||
|
||||
Архитектура личной доставки (реализация и результаты проверки — после пунктов 1--7).
|
||||
Уточнение пользователя: при хранении и передаче личных сообщений/вложений конкретная
|
||||
CHAT-группа не закрепляется. Можно использовать любую доступную общую группу непосредственных
|
||||
отправителя и получателя пакета; значение имеют их личности и права на объект.
|
||||
Это заменяет прежнее предложение связывать назначение хранения с группой.
|
||||
MEMBER_FILES остаётся файловым сервисом конкретной группы; UTUN в эту доработку не входит.
|
||||
|
||||
1. Три независимые сущности.
|
||||
- Личный объект: подписанное сообщение, UUID вложения, hash полного тела, автор и стороны
|
||||
беседы. Существующие правила DEVICE/GROUPS и снимок адресатов сообщения сохраняются.
|
||||
- Назначение хранения: координатор S просит выбранное хранилище M сохранить этот объект.
|
||||
assignment_id создаётся S и сохраняется до отправки STORE; привязан к S, M и точному телу.
|
||||
Повтор, рестарт и смена маршрута сохраняют assignment_id. Другой M или новая попытка
|
||||
после окончательного отказа получают новый assignment_id. Не вводим отдельную эпоху группы.
|
||||
- Активный сетевой обмен: peer, выбранная сейчас группа, router session/transfer/probe ID.
|
||||
Только этот слой содержит обязательную привязку к группе. Ответ может прийти другой группой.
|
||||
|
||||
2. Маршрут выбирается для каждой пары отдельно.
|
||||
Автор -> S, S -> M, M -> автор, S -> получатель и M -> получатель могут использовать
|
||||
разные группы. Не требуем группу, в которой одновременно состоят все четыре узла.
|
||||
Перед каждой новой отправкой выбирается доступная общая CHAT-группа self/peer;
|
||||
приём, очередь, повтор и callback проверяют свежий topo_group_chat_allowed этой пары.
|
||||
Подпись router обязательна для DM_MAILBOX/DM_MEDIA, в том числе header-only controls.
|
||||
Роль и объектное право проверяются отдельно от выбора сетевого пути.
|
||||
|
||||
Роли supernode/storage нужны для выбора и допуска обслуживающего узла к новой работе.
|
||||
Источник этих сведений — актуальные подписанные строки участников допущенных общих групп
|
||||
проверяющей стороны и проверяемого узла; роль не обязана находиться в группе текущего пакета.
|
||||
Строка из чужой группы, source!=0, deleted или admitted!=1 полномочий не даёт.
|
||||
Смена маршрута не заменяет обслуживающий узел и не отменяет уже принятую им копию.
|
||||
Сохранённое назначение разрешает учитывать его результаты и завершать его после изменения
|
||||
роли; оно не даёт права создавать другие назначения или обращаться к групповым API.
|
||||
|
||||
PUT принимается от автора точного подписанного сообщения, адресованного указанному получателю.
|
||||
DELIVER/OFFER проверяют личность отправителя, предметные полномочия и подписанное тело.
|
||||
Выдача файла проверяет право конкретного устройства/стороны беседы либо допустимого storage;
|
||||
членство в транспортной группе и знание UUID сами по себе доступа к файлу не дают.
|
||||
HAVE, передача файла и квитанции используют те же предметные правила, что основной запрос.
|
||||
Квитанция получателя подтверждает точное тело и подписанта из снимка адресатов.
|
||||
Локальное совмещение S=M вызывает те же предметные проверки без искусственного сетевого self-сеанса.
|
||||
|
||||
3. Постоянное состояние не содержит обязательного group_id.
|
||||
Удалить group_id из контракта личных pending, receipts, sources, jobs и custody.
|
||||
Не переносить его в новый ключ и не делать из него сохранённое разрешение на доступ.
|
||||
При необходимости последний удачный маршрут можно кешировать только как необязательную
|
||||
runtime-подсказку: после рестарта всё находится по node_id, перед использованием допуск проверяется.
|
||||
Текстовый mailbox сохраняет существующий ключ сообщения и точное тело; нового assignment_id
|
||||
для текста не нужно. Повтор через другую группу не создаёт сообщение заново и не отменяет receipt.
|
||||
|
||||
На S существующий список dm_media_holders превращается в список назначений:
|
||||
assignment_id, file_id, M, состояние/ответ, подтверждённые expires и delete_on_open.
|
||||
Координатор dm_media_jobs ссылается на текущее назначение, прежние назначения сохраняются
|
||||
до окончательного разрешения. Поля статуса не дублировать без необходимости в обеих таблицах.
|
||||
На M dm_custody описывает одну физическую копию: точное тело/hash, состояние, первоначальный
|
||||
deadline и политику удаления. Поля единственного supernode/group_id из копии убираются.
|
||||
Отдельный список обращений M: (S, assignment_id), file_id/body_hash, принято или отказано.
|
||||
Принятые обращения нескольких S могут ссылаться на одну копию; ответы адресуются каждому S.
|
||||
В каждый момент на M для этой копии работает не более одной загрузки/проверки/очистки.
|
||||
|
||||
4. Идемпотентный STORE и точные ответы.
|
||||
STORE и относящиеся к нему RESERVED/STORED/REJECT/DELETE_ACK/EXPIRED несут assignment_id
|
||||
и file_id. Стороны S/M берутся из проверенной личности отправителя и локальной записи,
|
||||
тело/hash неизменно закреплены в назначении. Совпадения одного UUID или группы недостаточно.
|
||||
Повтор принятого STORE возвращает текущее состояние той же копии и прежние TTL/политику.
|
||||
Окончательный отказ, например по квоте, сохраняется для этого assignment_id до ответа;
|
||||
его повтор не превращается в принятие. Реальная новая попытка использует новый ID.
|
||||
Неавторизованный запрос не создаёт запись назначения и не меняет состояние файла.
|
||||
При конфликте ID/тела/M, SQL-ошибке или неудачном commit успешный ответ не отправляется.
|
||||
|
||||
Поздний ответ изменяет только найденное назначение. Изменение текущего назначения возможно
|
||||
только при совпадении assignment_id; ответ прежнего M не сбрасывает нового M.
|
||||
RESERVED после STORED не откатывает готовность; терминальное назначение не оживает от STORED.
|
||||
Ответ той же операции через другую разрешённую группу принимается как обычный ответ.
|
||||
Даже запоздалый ответ старой группы допустим, если её текущий допуск не отозван.
|
||||
Если допуск отозван, пакет отвергается до изменения durable состояния.
|
||||
|
||||
5. Физическая копия и факты о файле.
|
||||
Состояния копии M: RESERVED -> READY -> REMOVING -> REMOVED; удаление возможно и из RESERVED.
|
||||
Первый успешный резерв на данном M фиксирует абсолютный expires и delete_on_open.
|
||||
Повтор, другой маршрут, ещё один S и перезапуск не продлевают этот срок и не меняют политику.
|
||||
После удаления сохраняется существующий tombstone по UUID/hash; поздний STORE не возрождает копию.
|
||||
Это срок конкретной копии на M; не вводим несуществующий сейчас общий TTL для всех хранилищ.
|
||||
|
||||
RECEIPT/TAKEN — подписанные факты о файле, не о маршруте и не об assignment_id.
|
||||
DELETE переносит эти факты; сам координатор не получает права произвольно удалить общую копию.
|
||||
M проверяет факты и первоначальную политику: открытие получателем либо загрузка всеми
|
||||
адресатами стороны получателя из исходного снимка. Ещё одно обращение S не меняет этот снимок.
|
||||
Сначала commit REMOVING, затем отмена передачи и удаление на диске, затем tombstone и ACK.
|
||||
ACK адресуется конкретному обращению; остальные координаторы также получают конечный результат.
|
||||
Минимальные терминальные записи нужны для повторов после рестарта; очистка не должна допускать replay.
|
||||
|
||||
Разделить EXPIRED назначения S--M и сообщение конечному получателю о недоступности источника.
|
||||
Второе относится к file_id/body_hash и конкретному holder, не делает весь личный файл удалённым.
|
||||
Позднее исчезновение M1 не отменяет источник M2, готовую локальную копию или получение оригинала.
|
||||
|
||||
6. Отзыв группы и фоновые операции.
|
||||
Отзыв закрывает только сетевые операции этой группы, включая очереди, probes и waiters.
|
||||
Общий ETCP и другая допущенная группа продолжают работать. Сохранённое сообщение, назначение
|
||||
и готовая копия остаются. Недоступность пути не означает REJECT/EXPIRED/доставку.
|
||||
Планировщик выбирает другой допустимый маршрут для того же peer и того же assignment_id.
|
||||
Незавершённая FILE_TRANSFER получает новый transfer ID; это не новое назначение хранения.
|
||||
При отсутствии пути работа ждёт, а первоначальный срок хранения продолжает идти.
|
||||
|
||||
Worker занят локальным файлом, не выбранной группой; он не обращается к сети/SQLite.
|
||||
Уже полученные байты можно проверить локально независимо от исчезновения сетевого маршрута.
|
||||
Callback в uasync проверяет актуальность объекта, состояние удаления и deadline перед READY.
|
||||
Начатая дисковая команда может закончиться; результат не возвращается по отозванному маршруту.
|
||||
Новая сетевая работа требует нового допуска. Терминальное состояние копии не отменяется
|
||||
поздним callback; cleanup разрешён и после потери доступа. Перезапуск не запускает второй
|
||||
worker для тех же путей до завершения/отмены первого. Локальная история и готовые файлы сохраняются.
|
||||
|
||||
7. Этапы реализации и обязательные проверки.
|
||||
I. Отделить предметный допуск от маршрута в DM_MAILBOX/DM_MEDIA и выборе источников.
|
||||
Убрать постоянную привязку к группе; роли искать независимо от группы пакета.
|
||||
Проверить текстовый mailbox и прямое вложение через разные группы без общей группы всех узлов.
|
||||
Одного этого этапа недостаточно для объявления custody изолированным.
|
||||
II. Ввести assignment_id, список обращений на M и транзакционные переходы; изменить wire
|
||||
и схему вместе, без слоя обратной совместимости. Развести статус назначения и источника.
|
||||
Проверить дубли STORE, отказ/новую попытку, два S/один M, поздние ответы и SQL rollback.
|
||||
III. Довести revalidate/callback/retry/cleanup; проверить сетевой стенд и весь ./check.sh.
|
||||
Стенд: A/S/M/B соединены разными попарными группами, общей для всех нет; вторая группа
|
||||
одной пары отзывается при живом ETCP. Прежнее назначение завершается через оставшийся путь.
|
||||
Также: отзыв self, роли и третьего участника; отсутствие любого пути; поздние controls/DATA;
|
||||
старый M после выбора нового; restart между commit/отправкой; прежний TTL; worker при удалении;
|
||||
отказ в чужой группе до записи в БД/lookup/выдачи файла; сохранность MEMBER_FILES и локальной истории.
|
||||
Диагностика каждого значимого перехода: file_id, assignment_id, self/peer, выбранная группа,
|
||||
предыдущее/новое состояние, deadline и точная причина отказа/отмены. Пауза логируется при
|
||||
изменении состояния, а не на каждом тике. Ошибки БД/диска/протокола не замалчиваются.
|
||||
|
||||
Прежние результаты 65/65 и 240/240 относятся к описанным выше завершённым доработкам
|
||||
FILE_TRANSFER и прямого DM. Для реализации mailbox/custody результаты приведены ниже.
|
||||
|
||||
Реализация этапа I, 03.11.2026 (ветка feature/chat-group-isolation):
|
||||
- dm_peer_role проверяет подписанную роль среди допущенных общих групп пары, отдельно от маршрута.
|
||||
DM_MAILBOX/DM_MEDIA получили свежий допуск router и обязательные подписанные controls.
|
||||
- Mailbox и файловая доставка больше не требуют общей группы всех A/S/M/B.
|
||||
group_id удалён из pending/receipts/files/sources/custody/jobs/holders; транзакционное
|
||||
обновление схемы сохраняет тела, квитанции и остальные поля. Локальные файлы не удаляются.
|
||||
- Первоначальный test_dm_group_routes:13/13, четыре ядра, семь попарных групп.
|
||||
Последующая проверка показала, что синхронизация могла вернуть отключённый прямой A/B.
|
||||
В итоговом стенде этот путь принудительно остановлен на обеих сторонах; прежний прогон
|
||||
не считается доказательством отсутствия прямой доставки. Итоговые проверки — ниже.
|
||||
Проверены source/admitted/deleted/self при поиске роли, отрицательный подписанный PUT
|
||||
через отозванную группу, unsigned PUT, повтор в другой группе без дубля истории.
|
||||
Лог /tmp/dm-route-nogroup-network.log. Стенд полностью готовит таблицы каналов.
|
||||
- test_dm_schema проверяет rollback второго ALTER и сохранение точного pending/receipt.
|
||||
test_dm_media_cleanup проверяет ошибки SQL, рестарт, ACK другого маршрута и отказ чужому M;
|
||||
прежняя проверка отказа только из-за иной группы заменена согласно согласованному контракту.
|
||||
Логи /tmp/dm-route-schema-final.log и /tmp/dm-route-cleanup-final.log.
|
||||
- До удаления group_id из медиа-схемы: test_dm_e2e2/2 и test_dm_media17 фаз PASS, allocations0->0;
|
||||
/tmp/dm-route-mailbox-e2e.log, /tmp/dm-route-media-regression.log. Полный повтор ещё предстоит.
|
||||
|
||||
Реализация этапов II/III, 03.11.2026:
|
||||
- STORE и ответы M содержат assignment_id. S фиксирует ID до отправки; ответ проверяется
|
||||
по ID, UUID и M. Повтор/рестарт/смена группы сохраняют назначение. RESERVED не откатывает
|
||||
STORED; изменение подтверждённых expires/политики отвергается с диагностикой.
|
||||
- На M dm_custody_requests хранит (S,assignment_id,id,body_hash,rejected), отдельно от одной
|
||||
физической копии. У копии больше нет единственного S. Дубли и ещё один S не продлевают TTL.
|
||||
Квота проверяется до резерва; отказ сохраняется. S через 60с выбирает новый M либо делает
|
||||
новую попытку на прежнем M с новым ID; только завершённый отказ можно заменить.
|
||||
- DELETE передаёт подписанные факты; политика общей копии решает удаление. После durable
|
||||
удаления все зарегистрированные координаторы получают свой ответ; STORE восстанавливает
|
||||
результат из tombstone. SOURCE_GONE исключает конкретный holder, сохраняя другие копии.
|
||||
- Входящий первый DM больше не требует общего канала автора/адресата при проверенной
|
||||
mailbox-доставке. Подписи, получатель и стороны беседы проверяются независимо от пути.
|
||||
- Отзыв немедленно закрывает HAVE-пробы своей группы; FILE_TRANSFER и router закрывают
|
||||
свои операции/очереди. Durable объекты остаются. Workers проверяют байты локально,
|
||||
callback проверяет поколение, состояние удаления и срок до READY.
|
||||
- test_dm_group_routes:21/21, четыре ядра, девять попарных групп, A/B остановлен.
|
||||
Проверены текст/файл, побайтное содержимое, отзыв HAVE с новым маршрутом, отзыв S/M,
|
||||
сохранение ETCP, отсутствие пути, два S/одна копия, прежний TTL/политика после изменения
|
||||
настроек, durable квотный отказ, новый ID на прежнем M, старый REJECT при новом назначении.
|
||||
Лог /tmp/dm-assignment-network-final.log; probe revoked виден до нового скачивания.
|
||||
- test_dm_media:17 фаз PASS, allocations0->0: restart S/M, SQL rollback, lost ACK/EXPIRED,
|
||||
повторные controls, исходный файл после TTL, ручная загрузка/TAKEN, совмещение S/M/endpoint.
|
||||
Лог /tmp/dm-assignment-media-final.log. test_dm_media_cleanup и test_dm_schema PASS:
|
||||
/tmp/dm-assignment-cleanup-final.log, /tmp/dm-assignment-schema-expanded.log.
|
||||
- Полный ./check.sh:241/241, proxy9/9, burst/load PASS (203с).
|
||||
Лог /tmp/chat-personal-assignments-full-check.log; в общем прогоне повторно пройдены
|
||||
CHAT isolation65/65, личные файлы17 фаз и новый попарный стенд21/21.
|
||||
- Это не закрывает общую задачу изоляции всех CHAT-протоколов: STREAM, CALL/DEVGROUP,
|
||||
sync/BGP/bootstrap и полная матрица подмены источника остаются отдельной границей проверки.
|
||||
UTUN не изменён. MEMBER_FILES по-прежнему принадлежит конкретной группе.
|
||||
|
||||
03.11.2026: текущий допуск истории и её настроек.
|
||||
- До исправления новый сетевой сценарий дал 69 PASS / 9 FAIL: отозванный пир при старом
|
||||
READY изменял подписанную историю PUSH, отмечал доставку ACK, менял SOUND_SETTING,
|
||||
создавал MESSAGES-сессию и запускал отложенную отправку. Лог /tmp/chat-sync-isolation-before.log.
|
||||
- DB_SYNC использует общую проверку текущего допуска self/peer в живой CHAT-группе,
|
||||
точного ключа ETCP и живого соединения. READY/готовность индекса остаются дополнительными
|
||||
условиями истории. SOUND_SETTING разрешён допущенному пиру до готовности истории,
|
||||
а постороннему не создаёт даже состояние SI_PEER.
|
||||
- Модель MESSAGES проверяет эту же политику до создания сессии, входящей обработки,
|
||||
фоновой работы и выдачи подготовленного пакета в ETCP. Существующий Merkle waiter
|
||||
сохранён; диагностика различает отказ модели и потерю транспорта, указывает dataset/type.
|
||||
- test_chat_group_isolation:84/84, /tmp/chat-sync-isolation-final.log. Проверены отзыв peer/self,
|
||||
прежний READY, source=1, deleted=1, несовпадение ключа, stopping, повторный допуск,
|
||||
отложенная работа и уже подготовленный пакет; соседняя история/настройки A продолжают
|
||||
работать на том же ETCP. test_db_sync_integrity PASS, /tmp/chat-sync-integrity.log.
|
||||
- Полный ./check.sh:241/241, proxy9/9, burst/load PASS, 205с.
|
||||
Лог /tmp/chat-sync-isolation-full-check.log; в общем прогоне повторно пройдены CHAT isolation84/84,
|
||||
DB_SYNC, JOIN E2E (3/3), server JOIN, MEMBERS/feedback и транспортные регрессии.
|
||||
- MEMBERS/bootstrap пока не изменены: их исключение не переносится на историю/настройки.
|
||||
Следующая граница описана в 2.4. Транзит BGP-доставки и UTUN не изменены.
|
||||
|
||||
04.11.2026: транспортный этап отложенного приёма MEMBERS.
|
||||
- Завершён незаконченный режим одностороннего запроса в merkle_sync. Получатель ведёт
|
||||
BEGIN/READY, запрашивает различия и отправляет CHECK. Источник выдаёт DONE только
|
||||
при неизменившихся данных, включая повторную проверку перед фактической отправкой.
|
||||
Получатель вызывает commit_pull только после этого подтверждения; ошибка проверки
|
||||
не превращается в успешный результат. Отмена, обрыв и ошибка освобождают staging.
|
||||
- validate_request разрешает только локальный запрос, validate_peer — выдачу данных.
|
||||
Непрошеный ответ не создаёт сессию. Получатель не отвечает своими страницами/хешами.
|
||||
Обычный обмен, локальный запрос и встречная выдача имеют отдельные сессии/токены.
|
||||
Завершённый запрос не начинает обратную передачу и не создаёт автоматическую подписку.
|
||||
- test_merkle_protocol проверяет отказ источника, ответы без запроса, применение только
|
||||
после DONE, отказ commit_pull, изменение источника до CHECK/перед отправкой DONE,
|
||||
разделение направлений, запрет обратной выдачи, отзыв и отмену из callback.
|
||||
Лог /tmp/member-staged-protocol.log. CHAT isolation84/84 и merkle_sync28/28 прошли;
|
||||
stress прошёл 10000 вставок + 10000 обновлений с переподключениями и 1000 конкурентных
|
||||
проходов треугольника: /tmp/member-staged-merkle-stress.log.
|
||||
Полный ./check.sh:241/241, proxy9/9, burst/load PASS, 210с;
|
||||
лог /tmp/member-staged-full-check.log. Сборка ядра и выбранных тестов без предупреждений.
|
||||
- На момент транспортного этапа модель MEMBERS ещё НЕ подключена к новому режиму. Следующий этап — служебные поля
|
||||
версий в одной таблице, приём в staging, проверка состава и атомарная публикация
|
||||
вместе с допуском; затем контекст JOIN/recovery и отрицательные сетевые сценарии.
|
||||
Подтверждённый отзыв нельзя терять или заменять старым разрешающим бэкапом.
|
||||
|
||||
04.11.2026: завершён приём и проверка версий MEMBERS.
|
||||
- CURRENT/PENDING/BACKUP хранятся в одной peers_<channel>. Сетевые страницы до конца
|
||||
обмена не меняют рабочие записи, права, общие ключи узлов и Merkle-хеши. Сервисы,
|
||||
desktop GUI и Android читают только CURRENT. Старый формат таблицы несовместим;
|
||||
миграция не добавлялась согласно принятому контракту devel.
|
||||
- После окончания сверки одна транзакция объединяет подписанные блоки, проверяет
|
||||
записи и цепочки приглашений, пересчитывает права, ключи и производные индексы.
|
||||
Неверную новую версию заменяет действительный бэкап. Если обе версии неверны,
|
||||
удаляются обе версии только этого участника. Отзыв self не удаляет остальной состав.
|
||||
Независимо подписанный отзыв владельца сохраняется даже при восстановлении профиля.
|
||||
Отсутствующая зависимость или ошибка SQL/crypto откладывают решение; транзакция
|
||||
откатывается. Ошибка не публикует callbacks и не считается успешной синхронизацией.
|
||||
- Каждый одновременный обмен имеет свой sync_id. Его commit сравнивает версии с
|
||||
актуальным CURRENT, включая уже завершившиеся обмены других пиров; отмена убирает
|
||||
только собственные временные строки. После рестарта незавершённые версии убираются.
|
||||
- Источник проверяет текущий допуск self/peer и точный ключ живого ETCP перед выдачей.
|
||||
Локальный запрос восстановления не выдаёт состав в обратную сторону. После
|
||||
восстановления допуска явно запускается обычный обмен и завершается ожидающая
|
||||
сверка соседа; только после свежей проверки допуска возобновляются история/feedback.
|
||||
BGP-транзит не получил предметных проверок; UTUN не изменён.
|
||||
- test_member_versions проверяет перестановку завершения двух пиров, независимую
|
||||
отмену, fallback, удаление двух неверных версий, сохранение отзыва, пропавшего
|
||||
инвайтера, SQL rollback, рестарт и границы транзакций. test_merkle_protocol проверяет
|
||||
публикацию только после успешного commit, в том числе отмену сессии из callback.
|
||||
test_chat_group_isolation:90/90, включая отказ выдачи MEMBERS и recovery на общем ETCP.
|
||||
Целевые JOIN, group_policy, DM invite/history, RADIO, MEMBER_FILES и STREAM member
|
||||
прошли. Пять desktop GUI-тестов прошли; Android assembleDebug завершён успешно.
|
||||
- При замене повреждённой записи сохраняются локальные ник, mute, RTT и история
|
||||
подключения; прежний admitted сохраняется до пересчёта прав. Потеря этих полей
|
||||
воспроизведена отдельной проверкой и исправлена: /tmp/member-local-settings-before.log,
|
||||
/tmp/member-local-settings-after.log. Они не берутся из сетевого профиля.
|
||||
- Итоговый ./check.sh:243/243, proxy9/9, burst/load PASS, 184с;
|
||||
/tmp/member-versions-release-check.log. Длинный сценарий помощи STREAM вынесен
|
||||
в test_stream_relay, все проверки сохранены, общий лимит 90с на тест не увеличен.
|
||||
GUI:5/5 (/tmp/member-versions-release-gui-tests.log), Android assembleDebug PASS
|
||||
(/tmp/member-versions-release-android.log). Рабочая ветка: feature/chat-group-isolation.
|
||||
- Это завершение этапа MEMBERS, а не полная матрица всех протокольных ID из этапа 4.
|
||||
CALL/DEVGROUP и оставшиеся транспортные/bootstrap входы требуют отдельного аудита.
|
||||
|
||||
04.11.2026: завершены пункты 1–4 остаточного аудита (invite, CALL, DEVGROUP, STREAM).
|
||||
- Удалён неиспользуемый GROUP_INVITE/INVITE_INFO, который выдавал описание группы
|
||||
без проверки права вступления. ID 0x13 зарезервирован и не имеет обработчика.
|
||||
Вступление идёт через CHAT_SYNC JOIN. Неверный add_key даёт явный отказ без
|
||||
сохранения метаданных канала или участника; JOIN E2E3/3, CHAT isolation92/92.
|
||||
- Все четыре CALL API используют общую проверку текущего допуска и обязательные
|
||||
подпись/шифрование. Отзыв self/peer или остановка группы завершает её звонки:
|
||||
очереди и таймеры медиа освобождаются, CM закрывается; отозванным адресатам повторы не отправляются.
|
||||
Группа активного звонка фиксирована; переноса в середине разговора нет.
|
||||
Новый звонок из DM использует существующий dm_call_route, выбирающий доступную
|
||||
общую группу. Дополнительный механизм выбора маршрута не добавлялся.
|
||||
- DEVGROUP проверяет допуск обоих устройств и закреплённые X25519/Ed25519 ключи.
|
||||
Отзыв отменяет ожидание, очищает текущие и кешированные PIN; уже отправленные
|
||||
ключи отмечаются как неизвестный результат. Поздние KEYS не импортируются.
|
||||
Завершённое связывание/импортированная личность сохраняются; новое приглашение
|
||||
можно провести через другую группу. Обычные квитанции повторных PIN сохранены.
|
||||
- STREAM проверяет допуск до обработки raw/routed команды, перед raw-send и
|
||||
при выходе control из общей FIFO. Отозванная B не блокирует соседнюю A.
|
||||
Relay требует подпись/шифрование и совпадение группы payload/router.
|
||||
OVERLOAD/SELECT общий для транспорта и может перечислять разные группы;
|
||||
проверяются также группы всех существующих handles до любых изменений.
|
||||
Проверки конечных узлов не добавляют предметную авторизацию BGP-транзиту.
|
||||
- test_group_api_isolation:44/44, настоящий UDP/BGP и два канала одного транспорта,
|
||||
реальные активный звонок, видеопротиводавление, два PIN и STREAM-выдача.
|
||||
Проверены чужая группа/источник/эпоха/handle, peer/self revoke, поздние controls,
|
||||
отказ local open/send, отсутствие callback/import после отзыва, общая FIFO
|
||||
и команда разгрузки A со ссылкой на отозванный handle B; allocations0->0.
|
||||
Лог /tmp/chat-final-api-isolation.log. STREAM service/relay также PASS:
|
||||
/tmp/chat-final-stream-pressure.log, /tmp/chat-final-relay-pressure.log.
|
||||
- test_conn_mgr_phases:10 сценариев PASS. CALL-сценарий переведён на подписанную
|
||||
CHAT-группу поверх прежних транспортов: relay -> direct -> reserve -> NONE,
|
||||
реальное аудио и очистка таймеров. Самообъявленные адреса стенда остаются
|
||||
недоступными до шага direct, включая публикацию статуса звонка.
|
||||
Остальные сценарии UTUN сохранены. Лог /tmp/chat-final-cm-phases.log.
|
||||
- Это закрывает выбранные пункты 1–4. Отдельно остаются аудит прочих служебных
|
||||
BGP/NCD/NTP/bootstrap входов и итоговая матрица service ID. UTUN отложен.
|
||||
- Итоговый ./check.sh:243/243, proxy9/9, burst/load PASS, 185с;
|
||||
/tmp/chat-final-release-check.log. Desktop собран, целевые GUI-тесты9/9:
|
||||
/tmp/chat-final-gui-release-build.log, /tmp/chat-final-gui-release-tests.log.
|
||||
Android assembleDebug PASS: /tmp/chat-final-android-release-build.log.
|
||||
Известная отдельная Linux headless-сборка Android-обёртки по-прежнему не находит
|
||||
speex/speex_echo.h; это прежняя задача в doc/tasks.md, не изменение Android API.
|
||||
|
After Width: | Height: | Size: 224 KiB |
@@ -0,0 +1,99 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900">
|
||||
<rect width="1600" height="900" fill="white"/>
|
||||
<text x="800.0" y="49" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="49" font-weight="700" fill="#172B46">У каждой группы — свой круг доступа</text>
|
||||
<text x="800.0" y="119" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="29" font-weight="400" fill="#536477">Контент доступен только участникам и серверам этой группы</text>
|
||||
<rect x="420" y="185" width="760" height="58" rx="29" fill="#F0F3F7" stroke="none" stroke-width="2"/>
|
||||
<text x="800.0" y="201" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="25" font-weight="700" fill="#172B46">Вы в двух группах — с одного устройства</text>
|
||||
<rect x="60" y="279" width="710" height="439" rx="25" fill="#F2F7FF" stroke="#B3D0F3" stroke-width="2"/>
|
||||
<text x="415.0" y="306" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="30" font-weight="700" fill="#2471CF">ГРУППА А</text>
|
||||
<rect x="140" y="366" width="550" height="74" rx="16" fill="#FFFFFF" stroke="#B3D0F3" stroke-width="2"/>
|
||||
<rect x="171" y="380" width="26" height="31" rx="13" fill="#FFFFFF" stroke="#2471CF" stroke-width="3"/>
|
||||
<rect x="164" y="397" width="40" height="29" rx="6" fill="#2471CF" stroke="none" stroke-width="2"/>
|
||||
<ellipse cx="184.0" cy="409.0" rx="3.0" ry="3.0" fill="#FFFFFF"/>
|
||||
<text x="435.0" y="380" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="26" font-weight="700" fill="#2471CF">Свой контент группы А</text>
|
||||
<text x="435.0" y="412" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="19" font-weight="400" fill="#536477">сообщения • файлы • трансляции</text>
|
||||
<path d="M250,526 L349,526" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M250,526 L258,520" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M250,526 L258,532" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M349,526 L341,520" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M349,526 L341,532" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M481,526 L582,526" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M481,526 L489,520" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M481,526 L489,532" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M582,526 L574,520" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M582,526 L574,532" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<rect x="163" y="483" width="54" height="86" rx="9" fill="#FFFFFF" stroke="#172B46" stroke-width="3"/>
|
||||
<path d="M180,493 L200,493" stroke="#172B46" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<ellipse cx="190.0" cy="558.5" rx="4.0" ry="2.5" fill="#172B46"/>
|
||||
<ellipse cx="190.0" cy="519.0" rx="9.0" ry="9.0" fill="#172B46"/>
|
||||
<rect x="174" y="533" width="32" height="14" rx="7" fill="#172B46" stroke="none" stroke-width="2"/>
|
||||
<rect x="375" y="494" width="80" height="54" rx="5" fill="#FFFFFF" stroke="#2471CF" stroke-width="3"/>
|
||||
<path d="M366,557 L464,557" stroke="#2471CF" stroke-width="4" fill="none" stroke-linecap="round"/>
|
||||
<path d="M375,548 L366,557" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M455,548 L464,557" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<ellipse cx="415.0" cy="512.0" rx="8.0" ry="8.0" fill="#2471CF"/>
|
||||
<rect x="399" y="524" width="32" height="13" rx="6" fill="#2471CF" stroke="none" stroke-width="2"/>
|
||||
<rect x="601" y="489" width="78" height="21" rx="5" fill="#FFFFFF" stroke="#2471CF" stroke-width="3"/>
|
||||
<ellipse cx="615.5" cy="499.5" rx="3.5" ry="3.5" fill="#2471CF"/>
|
||||
<path d="M631,499 L665,499" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<rect x="601" y="515" width="78" height="21" rx="5" fill="#FFFFFF" stroke="#2471CF" stroke-width="3"/>
|
||||
<ellipse cx="615.5" cy="525.5" rx="3.5" ry="3.5" fill="#2471CF"/>
|
||||
<path d="M631,525 L665,525" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<rect x="601" y="541" width="78" height="21" rx="5" fill="#FFFFFF" stroke="#2471CF" stroke-width="3"/>
|
||||
<ellipse cx="615.5" cy="551.5" rx="3.5" ry="3.5" fill="#2471CF"/>
|
||||
<path d="M631,551 L665,551" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<text x="190.0" y="586" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="24" font-weight="700" fill="#172B46">Ваше</text>
|
||||
<text x="190.0" y="615" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="24" font-weight="700" fill="#172B46">устройство</text>
|
||||
<text x="415.0" y="586" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="23" font-weight="400" fill="#172B46">Устройства</text>
|
||||
<text x="415.0" y="615" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="23" font-weight="400" fill="#172B46">участников</text>
|
||||
<text x="640.0" y="586" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="23" font-weight="400" fill="#172B46">Серверы</text>
|
||||
<text x="640.0" y="615" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="23" font-weight="400" fill="#172B46">группы А</text>
|
||||
<text x="415.0" y="672" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="23" font-weight="700" fill="#2471CF">Обмен только внутри этой группы</text>
|
||||
<rect x="830" y="279" width="710" height="439" rx="25" fill="#F0FAF6" stroke="#ACDCCD" stroke-width="2"/>
|
||||
<text x="1185.0" y="306" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="30" font-weight="700" fill="#13836D">ГРУППА Б</text>
|
||||
<rect x="910" y="366" width="550" height="74" rx="16" fill="#FFFFFF" stroke="#ACDCCD" stroke-width="2"/>
|
||||
<rect x="941" y="380" width="26" height="31" rx="13" fill="#FFFFFF" stroke="#13836D" stroke-width="3"/>
|
||||
<rect x="934" y="397" width="40" height="29" rx="6" fill="#13836D" stroke="none" stroke-width="2"/>
|
||||
<ellipse cx="954.0" cy="409.0" rx="3.0" ry="3.0" fill="#FFFFFF"/>
|
||||
<text x="1205.0" y="380" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="26" font-weight="700" fill="#13836D">Свой контент группы Б</text>
|
||||
<text x="1205.0" y="412" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="19" font-weight="400" fill="#536477">сообщения • файлы • трансляции</text>
|
||||
<path d="M1020,526 L1119,526" stroke="#13836D" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M1020,526 L1028,520" stroke="#13836D" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M1020,526 L1028,532" stroke="#13836D" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M1119,526 L1111,520" stroke="#13836D" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M1119,526 L1111,532" stroke="#13836D" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M1251,526 L1352,526" stroke="#13836D" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M1251,526 L1259,520" stroke="#13836D" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M1251,526 L1259,532" stroke="#13836D" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M1352,526 L1344,520" stroke="#13836D" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M1352,526 L1344,532" stroke="#13836D" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<rect x="933" y="483" width="54" height="86" rx="9" fill="#FFFFFF" stroke="#172B46" stroke-width="3"/>
|
||||
<path d="M950,493 L970,493" stroke="#172B46" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<ellipse cx="960.0" cy="558.5" rx="4.0" ry="2.5" fill="#172B46"/>
|
||||
<ellipse cx="960.0" cy="519.0" rx="9.0" ry="9.0" fill="#172B46"/>
|
||||
<rect x="944" y="533" width="32" height="14" rx="7" fill="#172B46" stroke="none" stroke-width="2"/>
|
||||
<rect x="1145" y="494" width="80" height="54" rx="5" fill="#FFFFFF" stroke="#13836D" stroke-width="3"/>
|
||||
<path d="M1136,557 L1234,557" stroke="#13836D" stroke-width="4" fill="none" stroke-linecap="round"/>
|
||||
<path d="M1145,548 L1136,557" stroke="#13836D" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M1225,548 L1234,557" stroke="#13836D" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<ellipse cx="1185.0" cy="512.0" rx="8.0" ry="8.0" fill="#13836D"/>
|
||||
<rect x="1169" y="524" width="32" height="13" rx="6" fill="#13836D" stroke="none" stroke-width="2"/>
|
||||
<rect x="1371" y="489" width="78" height="21" rx="5" fill="#FFFFFF" stroke="#13836D" stroke-width="3"/>
|
||||
<ellipse cx="1385.5" cy="499.5" rx="3.5" ry="3.5" fill="#13836D"/>
|
||||
<path d="M1401,499 L1435,499" stroke="#13836D" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<rect x="1371" y="515" width="78" height="21" rx="5" fill="#FFFFFF" stroke="#13836D" stroke-width="3"/>
|
||||
<ellipse cx="1385.5" cy="525.5" rx="3.5" ry="3.5" fill="#13836D"/>
|
||||
<path d="M1401,525 L1435,525" stroke="#13836D" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<rect x="1371" y="541" width="78" height="21" rx="5" fill="#FFFFFF" stroke="#13836D" stroke-width="3"/>
|
||||
<ellipse cx="1385.5" cy="551.5" rx="3.5" ry="3.5" fill="#13836D"/>
|
||||
<path d="M1401,551 L1435,551" stroke="#13836D" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<text x="960.0" y="586" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="24" font-weight="700" fill="#172B46">Ваше</text>
|
||||
<text x="960.0" y="615" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="24" font-weight="700" fill="#172B46">устройство</text>
|
||||
<text x="1185.0" y="586" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="23" font-weight="400" fill="#172B46">Устройства</text>
|
||||
<text x="1185.0" y="615" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="23" font-weight="400" fill="#172B46">участников</text>
|
||||
<text x="1410.0" y="586" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="23" font-weight="400" fill="#172B46">Серверы</text>
|
||||
<text x="1410.0" y="615" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="23" font-weight="400" fill="#172B46">группы Б</text>
|
||||
<text x="1185.0" y="672" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="23" font-weight="700" fill="#13836D">Обмен только внутри этой группы</text>
|
||||
<text x="800.0" y="763" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="31" font-weight="700" fill="#172B46">Общее устройство не объединяет группы.</text>
|
||||
<text x="800.0" y="816" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="26" font-weight="400" fill="#536477">Посторонние узлы не участвуют в обмене и не имеют доступа к контенту.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
@@ -0,0 +1,129 @@
|
||||
Создай один иллюстративный слайд 16:9, 3840×2160, на русском языке.
|
||||
|
||||
Тема: «P2P: шифрование и доставка сообщений и медиа».
|
||||
|
||||
Стиль: чистая техническая инфографика, светлый фон, крупные подписи, простые пиктограммы устройств, конвертов, файлов и ключей. Смысл должен читаться по схемам и стрелкам. Не превращай слайд в текстовый документ: инструкции ниже описывают содержание, на слайд выноси только короткие подписи.
|
||||
|
||||
Цвета:
|
||||
— синий: мои устройства, сторона A;
|
||||
— зелёный: устройства собеседника, сторона B;
|
||||
— серый: серверы и недоступные устройства;
|
||||
— золотой: ключи и шифрование.
|
||||
Тонкие стрелки — запросы и подтверждения; толстые — передача контента; пунктир — резервный путь.
|
||||
|
||||
КОМПОЗИЦИЯ
|
||||
|
||||
Верхняя полоса — два режима беседы рядом.
|
||||
Ниже — три пронумерованных блока слева направо:
|
||||
1. Шифруем.
|
||||
2. Доставляем сообщение.
|
||||
3. Получаем медиа.
|
||||
Внизу — компактная полоса подтверждений и хранения.
|
||||
|
||||
ДВА РЕЖИМА
|
||||
|
||||
Слева: «Без подгрупп».
|
||||
Покажи устройство A1 ↔ устройство B1.
|
||||
Подпись: «Только два выбранных устройства».
|
||||
Ключи сторон — личные пары ключей A1 и B1.
|
||||
Маленькое примечание: «В том числе между своими устройствами».
|
||||
|
||||
Справа: «С подгруппами».
|
||||
Две явно разделённые области:
|
||||
«Мои устройства»: A1, A2.
|
||||
«Устройства собеседника»: B1, B2.
|
||||
A1 — отправитель. B2 первоначально offline.
|
||||
Каждая сторона использует свою общую пару ключей подгруппы.
|
||||
Примечание: «Нет подгруппы → сторона представлена одним устройством и его ключами».
|
||||
|
||||
1. ШИФРУЕМ
|
||||
|
||||
Раздели сообщение и файл.
|
||||
|
||||
Сообщение:
|
||||
Пары ключей сторон A и B → «Общий ключ PM: X25519 → SHA-256» → закрытый конверт.
|
||||
Приватные ключи остаются у участников.
|
||||
Подпись конверта: «E2E + подпись устройства-автора».
|
||||
Подпись автора — Ed25519; ключ подгруппы не подменяет личную подпись устройства.
|
||||
|
||||
Файл:
|
||||
«Новая пара ключей F для каждого файла» → «HPKE → ключ файла» → один зашифрованный файл.
|
||||
Сам файл шифруется AES-256-CCM.
|
||||
|
||||
От приватного ключа F проведи две независимые ветви:
|
||||
— «Упаковка для A: публичным ключом стороны A»;
|
||||
— «Упаковка для B: публичным ключом стороны B».
|
||||
|
||||
Обе упаковки помести ВНУТРИ зашифрованного сообщения вместе с описанием файла.
|
||||
Не изображай последовательное двойное шифрование ключа: это две отдельные упаковки.
|
||||
Любая сторона раскрывает свою упаковку своим приватным ключом.
|
||||
Подпись возле файла: «Один шифротекст для всех копий».
|
||||
|
||||
2. ДОСТАВЛЯЕМ СООБЩЕНИЕ
|
||||
|
||||
Используй пример A1/A2/B1/B2.
|
||||
От A1 идут прямые стрелки конверта к доступным A2 и B1.
|
||||
|
||||
Рядом покажи фиксируемый при отправке список:
|
||||
A1 — локальная копия;
|
||||
A2 — ✓ доставлено;
|
||||
B1 — ✓ доставлено;
|
||||
B2 — ожидает.
|
||||
|
||||
Подпись: «Все известные устройства обеих сторон, включая offline».
|
||||
Для режима без подгрупп адресат только B1.
|
||||
|
||||
Для недоступного B2 покажи резервный путь:
|
||||
A1 → S → B2.
|
||||
S обозначь «Сервер сообщений / mailbox».
|
||||
Подпись: «Нет прямой доставки или ACK → через S».
|
||||
При появлении B2 онлайн сервер передаёт ему тот же зашифрованный конверт.
|
||||
|
||||
От получающих устройств покажи тонкие обратные стрелки ACK:
|
||||
«Подтверждение после сохранения».
|
||||
Принятие сервером S не обозначай как доставку устройству.
|
||||
|
||||
3. ПОЛУЧАЕМ МЕДИА
|
||||
|
||||
Сначала покажи отдельное условие хранения:
|
||||
«Сначала P2P».
|
||||
«Остались устройства без файла: offline или автозагрузка выключена → хранение на M».
|
||||
Уточни, что это относится и к собственным устройствам.
|
||||
|
||||
S координирует хранение. M обозначь «Сервер хранения медиа».
|
||||
Покажи передачу на M только зашифрованной копии файла.
|
||||
|
||||
Затем покажи B2, появившийся онлайн и запросивший вложение.
|
||||
От B2 одновременно идут тонкие стрелки HAVE:
|
||||
к A1, A2, B1 и M.
|
||||
Подпись: «Параллельный запрос всем известным онлайн-источникам».
|
||||
|
||||
На ответах укажи пример RTT:
|
||||
A1 — 30 мс, A2 — 45 мс, B1 — 12 мс, M — 60 мс.
|
||||
Выдели B1 как лучший ответивший источник.
|
||||
От B1 к B2 проведи ОДНУ толстую стрелку файла:
|
||||
«Минимальный RTT → скачивание → проверка → расшифровка».
|
||||
Маленькая подпись: «Ошибка источника → следующий».
|
||||
Не изображай одновременное скачивание файла со всех источников.
|
||||
|
||||
Отметь: «Автозагрузка — по настройкам устройства».
|
||||
Проверенные копии на устройствах могут служить источниками для остальных участников.
|
||||
|
||||
НИЖНЯЯ ПОЛОСА
|
||||
|
||||
Три отдельных значка:
|
||||
«Сообщение сохранено» ≠ «Файл скачан и проверен» ≠ «Пользователь открыл файл».
|
||||
|
||||
Возле M — две настройки удаления:
|
||||
— «При открытии: первый “забран” от собеседника или TTL»;
|
||||
— «Без удаления при открытии: скачали все устройства получателя или TTL».
|
||||
|
||||
Короткое примечание:
|
||||
«Свои устройства отправителя не задерживают удаление M».
|
||||
Удаление серверной копии не удаляет уже скачанные копии участников.
|
||||
|
||||
Общая подпись:
|
||||
«Серверам не передаются открытый контент и секретные ключи».
|
||||
Небольшое пояснение: «S и M — роли; они могут находиться на одном узле».
|
||||
|
||||
Не показывай форматы пакетов, таблицы БД, размеры ключей и служебные таймеры. Сохрани ясную последовательность: кто участвует → чем шифруем → кому доставляем сообщение → откуда скачиваем файл.
|
||||
|
After Width: | Height: | Size: 1.6 MiB |
@@ -0,0 +1,37 @@
|
||||
Создай один простой иллюстративный слайд 16:9 на русском языке.
|
||||
Тип: infographic-diagram. Готовая иллюстрация для обычного пользователя, без технических знаний.
|
||||
|
||||
Главная мысль: все личные сообщения и вложения шифруются на устройстве отправителя и расшифровываются на устройстве получателя. Доставка возможна напрямую, через обычные устройства других участников группы и с хранением на сервере группы. ВСЕ узлы доставки, включая промежуточные устройства и сервер хранения, являются членами группы. Посторонние узлы в этой схеме не участвуют. Промежуточные узлы передают и хранят только зашифрованные данные и не могут прочитать содержимое личной беседы. Членство в группе само по себе не даёт ключей расшифровки личной беседы.
|
||||
|
||||
Стиль: современная спокойная инфографика, светлый фон, много свободного пространства, крупные чёткие русские подписи. Аккуратные простые иллюстрации смартфонов, ноутбука, сообщения, фотографии, документа, замков и небольшого сервера. Синий — отправитель, зелёный — получатель, золотой — защита, серый — промежуточные устройства и сервер. Без мелкого текста, таблиц, технических терминов и декоративного шума.
|
||||
|
||||
Вверху крупный заголовок, дословно:
|
||||
«Личные сообщения и вложения зашифрованы».
|
||||
Подзаголовок:
|
||||
«Шифрование и расшифровка — на ваших устройствах».
|
||||
|
||||
Основная схема слева направо. Все устройства и маршруты заключены в одну общую светлую область с тонкой рамкой. На рамке крупная и хорошо заметная подпись:
|
||||
«В доставке участвуют только узлы вашей группы».
|
||||
Все изображённые смартфоны, ноутбуки и сервер находятся ВНУТРИ этой области. Не изображай внешние узлы, облака или маршруты за её пределами.
|
||||
|
||||
СЛЕВА — большой смартфон с подписью «Ваше устройство».
|
||||
Внутри экрана видны понятные значки сообщения, фотографии и документа. У правого края экрана, внутри границы устройства, золотой закрытый замок: он обозначает шифрование перед выходом данных в сеть.
|
||||
Подпись под смартфоном: «Шифрует перед отправкой».
|
||||
|
||||
СПРАВА — большой смартфон с подписью «Устройство собеседника».
|
||||
У левого края экрана, внутри границы устройства, золотой открытый замок: расшифровка происходит здесь. Внутри экрана снова видны сообщение, фотография и документ.
|
||||
Подпись под смартфоном: «Расшифровывает при получении».
|
||||
|
||||
МЕЖДУ УСТРОЙСТВАМИ — три ясно различимых пути доставки в трёх горизонтальных рядах.
|
||||
Верхний путь: одна простая стрелка от левого смартфона к правому. На ней небольшой закрытый пакет с замком, без видимого содержимого. Короткая подпись: «Напрямую».
|
||||
Средний путь: стрелки от отправителя через два небольших обычных устройства — ноутбук и смартфон — к получателю. Это такие же пользовательские устройства, как у отправителя и собеседника; они помогают пересылать зашифрованные данные. Изобрази только закрытые пакеты с замками на этом пути, не открытое содержимое. Подпись: «Через устройства участников группы».
|
||||
Нижний путь: стрелки от отправителя через небольшой сервер к получателю. Подпись: «Через сервер группы».
|
||||
На сервере изобрази закрытый пакет с замком. Под ним короткая подпись: «Хранит только зашифрованные копии».
|
||||
Под промежуточными устройствами и сервером — одна общая ясная подпись: «Промежуточные узлы не могут прочитать содержимое».
|
||||
Стрелки направлены от отправителя к получателю. Не пересекай стрелки и подписи. Понятный текст сообщений, фото и документы видны только внутри двух больших смартфонов отправителя и получателя. В пути и на промежуточных узлах — только закрытые пакеты. Не показывай расшифровку на промежуточных устройствах или сервере.
|
||||
|
||||
Внизу одна спокойная завершающая подпись со значком ключа:
|
||||
«Ключи расшифровки — только на устройствах участников личной беседы».
|
||||
Важно не создавать впечатление, что любой участник группы может читать личную переписку. Участники группы помогают доставлять; расшифровать могут только стороны личной беседы.
|
||||
|
||||
Ограничения: не упоминай алгоритмы, пары ключей, подгруппы, ACK, RTT, TTL, протоколы, базы данных и настройки удаления. Не добавляй обещаний анонимности или абсолютной защиты. Это одна простая схема для пользователя, а не архитектурная документация. Сохрани все перечисленные короткие подписи, не добавляй пояснительных абзацев. Никаких водяных знаков.
|
||||
@@ -311,8 +311,12 @@ DONE выдачи следует только после проверки бло
|
||||
Локальное удаление канала ждёт его исходящие video/register задачи. Последний
|
||||
completion продолжает удаление; новые исходящие медиа канала в это время отклоняются,
|
||||
подготовленные результаты ожидающих отправок не публикуются как новые сообщения.
|
||||
После завершения задач отменяются загрузки/раздача и удаляются учтённые файлы, включая
|
||||
назначения неуспешных подготовок. Общие файлы других каналов сохраняются. Очистка файлов
|
||||
После завершения задач сначала фиксируется удаление канала и его записей в SQLite.
|
||||
SQL-отказ сохраняет работающую группу, запросы ключей, outbox и файлы. После commit
|
||||
группа помечается stopping до любых cleanup callbacks; затем отменяются загрузки/раздача,
|
||||
удаляются группа и DB_SYNC, очищаются учтённые файлы, включая назначения неуспешных подготовок.
|
||||
Callback медиа-загрузки для отсутствующей/stopping группы только освобождает свой контекст.
|
||||
Общие файлы других каналов сохраняются. Очистка файлов
|
||||
выполняется вне внешней транзакции: SQL-откат не восстанавливает удалённый файл.
|
||||
При ошибке очистки записи оставшихся файлов сохраняются для повторного вызова;
|
||||
удаление метаданных канала и неполная файловая очистка диагностируются отдельно.
|
||||
|
||||
@@ -5,6 +5,139 @@
|
||||
|
||||
## Текущие задачи
|
||||
|
||||
[ ] **Завершить изоляцию API CHAT-групп** — 03.11.2026. UTUN пока не трогать по указанию пользователя.
|
||||
MEDIA_DELIVERY закрыт отдельной политикой router и негативными сетевыми тестами; это не гарантия для остальных сервисов.
|
||||
Реализован этап 1, CONN_MGR из этапа 2 и RADIO/BROADCAST из этапа 3: свежий допуск CHAT,
|
||||
scoped-политика router с подписью CM controls, отзыв только операций своей группы, сохранение общего ETCP/NCD.
|
||||
test_chat_group_isolation: до исправления 3 PASS/3 FAIL; теперь 11/11. Проверены отказ в B,
|
||||
рабочая A на том же ETCP, запрет local open, detach до callback, повторный допуск и отзыв self.
|
||||
test_etcp_router_unit: 45/45; ./check.sh: 240/240, proxy 9/9, burst и load PASS.
|
||||
Логи: /tmp/chat-group-isolation-before.log, /tmp/chat-group-isolation-after.log,
|
||||
/tmp/chat-group-router.log, /tmp/chat-group-stage1-check.log.
|
||||
MEMBER_FILES/GROUP_AVATAR получили router-политики, очистку очередей и отрицательные тесты;
|
||||
расширенный test_chat_group_isolation: 23/23, включая queued/running disk job, отказ в B и успешную A.
|
||||
Контракт диска согласован: ожидающие задания отменяются, начатая команда завершается без выдачи данных
|
||||
отозванному peer. Отмена сериализована с началом work; cleanup не отменяется, публикация download отделена.
|
||||
MEMBER_FILES unit/network и GROUP_AVATAR network/SQL проходят; avatar revoke проверен при partial RX/backpressure.
|
||||
ADMIN_KEY связывает payload с router group, требует crypto/admission; убран double free при отказе отправки.
|
||||
Полный повтор: 240/240, proxy9/9, burst/load PASS, /tmp/chat-group-files-avatar-check.log.
|
||||
RADIO_CALL получил crypto/admission и адресную отмену приглашений; radio/call_admission PASS.
|
||||
Routed JOIN: отмена ключей, регистраций и JOIN_READY при отзыве прав, повторная проверка ACK.
|
||||
JOIN unit 34/34, e2e 3/3, faults PASS; сетевой стенд 35/35 с teardown-сценарием.
|
||||
Исправлен fail подготовки стенда: после отзыва B перед следующим положительным JOIN явно восстанавливается BGP-сессия.
|
||||
Полный прогон с RADIO_CALL/JOIN/teardown: 240/240, proxy9/9, burst/load PASS, /tmp/chat-group-radio-join-check.log.
|
||||
CHAT NAT_DETECTION: scoped crypto/admission, отмена owner/helper/binding, включая отзыв третьего участника.
|
||||
NAT unit/faults PASS; сетевой стенд 40/40. MULTICAST: отмена подписок/доставок и фильтрация общей FIFO,
|
||||
scoped-политика MULTICAST_REPORT; unit PASS, сетевой стенд 50/50. UTUN wire/допуск сохранены.
|
||||
Полный прогон после NAT/MULTICAST: 240/240, proxy9/9, burst/load PASS, /tmp/chat-group-nat-multicast-check.log.
|
||||
ATTACHMENT_KEYS: crypto/admission, атомарная отмена state5, новый явный запрос после readmission, Qt-статус.
|
||||
Отмена не возобновляется от backfill; поздние ответы отвергаются, полученные ключи/локальная расшифровка сохраняются.
|
||||
requests/results/decisions/chat_attachment/encrypted_attachment PASS; rollback отмены, peer/self, новые запросы
|
||||
и completion worker после отзыва проверены. Сетевой стенд54/54: ключ B не выдаётся, A работает по тому же ETCP.
|
||||
Атомарность удаления канала исправлена (задача ниже); restart-fixture сохраняет прежнюю личность узла.
|
||||
Логи: /tmp/attachment-final-requests.log, /tmp/attachment-revoke-results.log, /tmp/attachment-worker-revoke.log,
|
||||
/tmp/attachment-group-isolation.log, /tmp/attachment-delete-network.log. Общий ./check.sh:240/240,
|
||||
proxy9/9, burst/load PASS (203с), /tmp/attachment-isolation-full-check.log. Qt собран;
|
||||
test_gui_language повторил известный FAIL Deleted accounts → Deleted, отдельная задача ниже.
|
||||
Продолжение в feature/chat-group-isolation; ветка включает прежние коммиты от a3600c8a4.
|
||||
FILE_TRANSFER: допуск до fopen/lookup, подписанные controls, отмена операций/очередей и проверка callbacks.
|
||||
Прямой DM: свежий допуск входа и выбора маршрута; сохранённое тело повторяется через другую общую группу.
|
||||
Воспроизведены усечение файла до отказа, незакрытая операция и запись DM через отозванную живую сессию.
|
||||
Сетевой стенд65/65; test_dm_media17 фаз PASS, allocations0->0. Общий ./check.sh:240/240,
|
||||
proxy9/9, burst/load PASS (199с), /tmp/chat-personal-isolation-check.log.
|
||||
Личная доставка mailbox/custody доработана по уточнению пользователя: каждый участок пути
|
||||
использует доступную общую группу пары узлов; общей группы автора/получателя/S/M не требуется.
|
||||
В очередях/копиях убран group_id. Роли и права на объект проверяются отдельно от маршрута.
|
||||
assignment_id фиксируется до STORE и сохраняется при смене пути/рестарте. На M отдельный
|
||||
список запросов координаторов ссылается на одну копию с первоначальными TTL/политикой.
|
||||
Отказ по квоте устойчив; новая попытка получает новый ID. Старые ответы не меняют новое назначение.
|
||||
Отзыв закрывает сетевые операции и HAVE-пробы своей группы, сохраняя личный объект и другие пути.
|
||||
SOURCE_GONE исключает только недоступное хранилище, готовые файлы/другие источники остаются.
|
||||
test_dm_group_routes:21/21 — девять попарных групп, прямой A/B отключён; текст/файл, два S/один M,
|
||||
отзыв/смена пути, отсутствие пути, квотный отказ/новая попытка/старый REJECT. Проверка plaintext побайтно.
|
||||
test_dm_media:17 фаз PASS, allocations0->0; schema/cleanup PASS с SQL faults и рестартами.
|
||||
Логи: /tmp/dm-assignment-network-final.log, /tmp/dm-assignment-media-final.log,
|
||||
/tmp/dm-assignment-schema-expanded.log, /tmp/dm-assignment-cleanup-final.log.
|
||||
Общий ./check.sh:241/241, proxy9/9, burst/load PASS (203с), /tmp/chat-personal-assignments-full-check.log.
|
||||
В этом прогоне прежний test_chat_group_isolation:65/65, личные файлы:17 фаз, новый стенд:21/21.
|
||||
Уточнена граница BGP-доставки: права конечного API проверяет конечный получатель;
|
||||
транзитный SVC_ROUTE не проверяет состав группы/предметные права на каждом переходе.
|
||||
Проверка служебных BGP-команд, меняющих локальную топологию принимающего соседа, сохраняется.
|
||||
Закрыты текущий допуск DB_SYNC PUSH/ACK, модели MESSAGES и SOUND_SETTING: старый READY,
|
||||
placeholder/tombstone и несовпадающий ключ не дают доступа. История требует допуска self/peer;
|
||||
SOUND_SETTING допустим до готовности истории, но только от допущенного пира своей группы.
|
||||
Новый сетевой сценарий до исправления:69 PASS/9 FAIL; после доработки CHAT isolation84/84.
|
||||
Проверены обе границы отложенной выдачи Merkle, отзыв self/peer, stopping и работа соседней A.
|
||||
Логи /tmp/chat-sync-isolation-before.log, /tmp/chat-sync-isolation-final.log; db_sync_integrity PASS.
|
||||
Полный ./check.sh после исправления:241/241, proxy9/9, burst/load PASS (205с),
|
||||
/tmp/chat-sync-isolation-full-check.log; обычный JOIN и server JOIN также проходят.
|
||||
MEMBERS завершён 04.11.2026: CURRENT/PENDING/BACKUP в одной таблице, отдельный sync_id
|
||||
для каждого параллельного обмена, проверка после DONE и атомарная публикация прав.
|
||||
Неверная новая версия заменяется действительным бэкапом; обе удаляются только если обе
|
||||
неверны. SQL/crypto/dependency ошибки откладывают решение без изменения рабочей версии.
|
||||
Первичное получение после JOIN и recovery не выдают состав обратно без текущего допуска.
|
||||
На этом этапе ./check.sh:243/243, proxy9/9, burst/load PASS; подробности — в плане.
|
||||
Пункты 1–4 остаточного аудита выполнены 04.11.2026: старый GROUP_INVITE удалён;
|
||||
обычный JOIN с неверным add_key явно отказывает без сохранения канала/участника.
|
||||
CALL, CALL_AUDIO, CALL_VIDEO и CALL_VIDEO_CONTROL требуют текущего допуска self/peer.
|
||||
Отзыв завершает звонок, очищает медиа и закрывает CM; активный звонок группу не меняет.
|
||||
Новый звонок из DM использует существующий dm_call_route для выбора общей доступной группы.
|
||||
DEVGROUP отменяет ожидающие приглашения и очищает PIN, проверяет закреплённые ключи;
|
||||
ранее импортированная личность сохраняется. Новое приглашение возможно через другую группу.
|
||||
STREAM проверяет приём, отправку и общую FIFO; OVERLOAD проверяет также группы handles.
|
||||
Целевые проверки: CHAT isolation92/92, API isolation44/44 (allocations0->0), JOIN E2E3/3,
|
||||
STREAM service/relay и все 10 сценариев CONN_MGR проходят. Логи /tmp/chat-final-api-isolation.log,
|
||||
/tmp/chat-final-join-e2e.log, /tmp/chat-final-cm-phases.log. Android assembleDebug PASS.
|
||||
Итоговый ./check.sh:243/243, proxy9/9, burst/load PASS (185с), /tmp/chat-final-release-check.log.
|
||||
Desktop собран; целевые GUI-тесты9/9, /tmp/chat-final-gui-release-tests.log.
|
||||
Остался отдельный аудит прочих транспортных/bootstrap входов BGP/NCD/NTP и матрицы service ID;
|
||||
гарантия для всех протоколов пока не заявлена. UTUN и проверки транзита BGP-доставки не менялись.
|
||||
План исправления и критерии по четырём этапам: [group_isolation_plan.txt](group_isolation_plan.txt).
|
||||
Общие service ID должны сохранить поведение и crypto-требования UTUN;
|
||||
выполненная часть, результаты и оставшаяся граница — раздел 5 плана.
|
||||
|
||||
[+] **Удаление CHAT-канала: commit предшествует отзыву durable работы** — 03.11.2026.
|
||||
test_chat_attachment воспроизвёл: chat_core_delete_channel сначала уничтожает TOPO_GROUP,
|
||||
teardown отменяет attachment outbox отдельной транзакцией, затем DELETE channels получает ABORT/IGNORE.
|
||||
Канал сохраняется, но outbox уже потерян; /tmp/chat-group-attachment-core.log, channel_state column3.
|
||||
Исправлено: сначала успешный commit удаления метаданных, затем stopping и cleanup runtime/файлов.
|
||||
Cleanup callbacks проверены; поздний callback медиа-загрузки не пишет в удалённую историю и не даёт событие.
|
||||
При SQL-отказе сохранены точные записи/outbox/файлы и работающие группа/DB_SYNC. Проверки допуска сохранены.
|
||||
test_chat_attachment PASS, /tmp/attachment-worker-revoke.log; SQL faults/ABORT/IGNORE/commit veto/внешняя
|
||||
транзакция и неудачный rollback. Дополнены проверки runtime и callback после удаления.
|
||||
|
||||
[ ] **Отложено: отдельный допуск UTUN и его API** — 03.11.2026. Не выполнять в текущей задаче по указанию пользователя.
|
||||
По чтению кода: TCP/UDP/ICMP proxy и routing DATA не проверяют входящий router group;
|
||||
topo_group_peer_allowed разрешает не-CHAT группы, а allowed_keys ограничивает транспорт.
|
||||
Проверка group_id сама по себе не заменяет допуск UTUN. Сетевые воспроизведения и решение остаются отложенными.
|
||||
|
||||
[+] **Панель устройств групповой лички: свои и собеседника** — 03.11.2026.
|
||||
Общий снимок текущего состава закреплённых сторон, включая offline и fallback-устройство.
|
||||
Qt: два раздела панели мемберов; Android: кнопка участников в заголовке PM.
|
||||
Обновление раз в 2 s, защита от чужого/устаревшего поколения; список доставки не меняется.
|
||||
Поведение/API: раздел 11 [dm_devgroup_plan.txt](dm_devgroup_plan.txt).
|
||||
C/Qt/APK собраны; состав/выход/fallback и неизменность delivery snapshot проверены в test_dm_groups.
|
||||
Qt: 4/4 набора; Android: 16/16 тестов. Полный check.sh: 236/238, только прежние group_connect timeout.
|
||||
Визуально проверена Qt-панель; /tmp/dm-members-panel.png. Логи и границы — в плане.
|
||||
|
||||
[+] **Личные сообщения между подгруппами с доставкой каждому устройству** — 03.11.2026.
|
||||
Ветка feature/dm-devgroups; два режима DEVICE/GROUPS с сохранением лички между конкретными устройствами.
|
||||
Снимок адресатов, per-device ACK и mailbox fallback; отдельная пара ключей вложения и две упаковки,
|
||||
P2P/RTT-выбор источника, настройки автозагрузки и политика M: открытие либо загрузка всеми получателями/TTL.
|
||||
Согласованный план и этапы: [dm_devgroup_plan.txt](dm_devgroup_plan.txt).
|
||||
Выход из подгруппы сохраняет доступ только к уже сохранённой истории/вложениям; групповой секрет не архивируем.
|
||||
Согласованное поведение и последствия — раздел 9 плана.
|
||||
Реализовано в ядре, Qt, Android и CLI. C/Qt/APK собраны; все DM-тесты прошли.
|
||||
Полный check.sh: 236/238 (два прежних group_connect timeout), Qt: 121/122;
|
||||
целевые Android-тесты: 17/17. Точные результаты и логи — раздел 10 плана.
|
||||
|
||||
[ ] **Согласовать test_gui_language с английским названием Deleted** — 03.11.2026.
|
||||
В исходном рабочем изменении chatgui_en.ts строка Deleted accounts переведена как Deleted.
|
||||
embeddedCatalogs пока требует совпадения английского перевода с source и падает на этой строке.
|
||||
Пользовательский перевод сохранён; новые PM-переводы проходят.
|
||||
Лог: /tmp/dm-devgroups-qt-final-tests.log.
|
||||
Повтор после сборки ATTACHMENT_KEYS:22 PASS/1 FAIL на той же строке, /tmp/attachment-gui-language.log.
|
||||
|
||||
[+] **Связывание личных устройств: явное меню и два PIN** — 03.11.2026.
|
||||
Qt/Android: «Это моё устройство» только для чужого устройства без подгруппы с живым BGP; пояснение и явная кнопка «Связать».
|
||||
Входящий запрос открывается через «Завершить спаривание» на self/своей подгруппе; оба PIN проверяются до передачи ключей.
|
||||
@@ -25,6 +158,8 @@
|
||||
не найден speex/speex_echo.h. Общая C-библиотека utun в актуальной desktop-конфигурации собирается;
|
||||
Android assembleDebug также проходит. Проверить include/зависимости отдельной host-конфигурации.
|
||||
Лог: /tmp/device-link-headless-build.log.
|
||||
Повтор 04.11.2026: та же отсутствующая зависимость, /tmp/chat-final-headless-build.log;
|
||||
текущие desktop и Android APK собираются, исходники Android в этом этапе не изменялись.
|
||||
|
||||
[+] **GUI: проверить очередь задач в test_settings_save::windowGeometry** — 02.11.2026.
|
||||
Диагностика очереди подтвердила начальный CHAT_DEVGROUP_STATUS от initDevgroupUi при создании MainWindow.
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Главная страница презентации; общее правило репозитория исключает index.html.
|
||||
!index.html
|
||||
@@ -0,0 +1,62 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900">
|
||||
<rect width="1600" height="900" fill="white"/>
|
||||
<text x="800.0" y="52" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="44" font-weight="700" fill="#172B46">Ваша группа. Вы решаете, кому дать доступ.</text>
|
||||
<text x="800.0" y="124" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="27" font-weight="400" fill="#536477">Вы создаёте свою группу и полностью контролируете доступ к её информации.</text>
|
||||
<rect x="60" y="227" width="955" height="488" rx="26" fill="#F2F7FF" stroke="#B3D0F3" stroke-width="2"/>
|
||||
<text x="537.5" y="258" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="30" font-weight="700" fill="#2471CF">ВАША ГРУППА</text>
|
||||
<rect x="167" y="326" width="741" height="91" rx="16" fill="#FFFFFF" stroke="#B3D0F3" stroke-width="2"/>
|
||||
<rect x="209" y="346" width="26" height="31" rx="13" fill="#FFFFFF" stroke="#2471CF" stroke-width="3"/>
|
||||
<rect x="202" y="363" width="40" height="29" rx="6" fill="#2471CF" stroke="none" stroke-width="2"/>
|
||||
<ellipse cx="222.0" cy="375.0" rx="3.0" ry="3.0" fill="#FFFFFF"/>
|
||||
<text x="564.0" y="342" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="29" font-weight="700" fill="#2471CF">Информация вашей группы</text>
|
||||
<text x="564.0" y="382" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="21" font-weight="400" fill="#536477">сообщения • файлы • трансляции</text>
|
||||
<path d="M537,418 L537,453" stroke="#B3D0F3" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M302,453 L772,453" stroke="#B3D0F3" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M302,453 L302,488" stroke="#B3D0F3" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M772,453 L772,488" stroke="#B3D0F3" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<rect x="262" y="497" width="80" height="54" rx="5" fill="#FFFFFF" stroke="#2471CF" stroke-width="3"/>
|
||||
<path d="M253,560 L351,560" stroke="#2471CF" stroke-width="4" fill="none" stroke-linecap="round"/>
|
||||
<path d="M262,551 L253,560" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M342,551 L351,560" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<ellipse cx="302.0" cy="515.0" rx="8.0" ry="8.0" fill="#2471CF"/>
|
||||
<rect x="286" y="527" width="32" height="13" rx="6" fill="#2471CF" stroke="none" stroke-width="2"/>
|
||||
<rect x="733" y="492" width="78" height="21" rx="5" fill="#FFFFFF" stroke="#2471CF" stroke-width="3"/>
|
||||
<ellipse cx="747.5" cy="502.5" rx="3.5" ry="3.5" fill="#2471CF"/>
|
||||
<path d="M763,502 L797,502" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<rect x="733" y="518" width="78" height="21" rx="5" fill="#FFFFFF" stroke="#2471CF" stroke-width="3"/>
|
||||
<ellipse cx="747.5" cy="528.5" rx="3.5" ry="3.5" fill="#2471CF"/>
|
||||
<path d="M763,528 L797,528" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<rect x="733" y="544" width="78" height="21" rx="5" fill="#FFFFFF" stroke="#2471CF" stroke-width="3"/>
|
||||
<ellipse cx="747.5" cy="554.5" rx="3.5" ry="3.5" fill="#2471CF"/>
|
||||
<path d="M763,554 L797,554" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<text x="302.0" y="588" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="24" font-weight="700" fill="#172B46">Добавленные вами</text>
|
||||
<text x="302.0" y="619" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="25" font-weight="700" fill="#172B46">участники</text>
|
||||
<text x="772.0" y="588" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="24" font-weight="700" fill="#172B46">Добавленные вами</text>
|
||||
<text x="772.0" y="619" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="25" font-weight="700" fill="#172B46">серверы</text>
|
||||
<rect x="1060" y="227" width="480" height="488" rx="26" fill="#F5F6F8" stroke="#DDE2E8" stroke-width="2"/>
|
||||
<text x="1300.0" y="258" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="30" font-weight="700" fill="#536477">ВНЕ ГРУППЫ</text>
|
||||
<rect x="1152" y="350" width="80" height="54" rx="5" fill="#FFFFFF" stroke="#7A8797" stroke-width="3"/>
|
||||
<path d="M1143,413 L1241,413" stroke="#7A8797" stroke-width="4" fill="none" stroke-linecap="round"/>
|
||||
<path d="M1152,404 L1143,413" stroke="#7A8797" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M1232,404 L1241,413" stroke="#7A8797" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<ellipse cx="1192.0" cy="368.0" rx="8.0" ry="8.0" fill="#7A8797"/>
|
||||
<rect x="1176" y="380" width="32" height="13" rx="6" fill="#7A8797" stroke="none" stroke-width="2"/>
|
||||
<rect x="1369" y="345" width="78" height="21" rx="5" fill="#FFFFFF" stroke="#7A8797" stroke-width="3"/>
|
||||
<ellipse cx="1383.5" cy="355.5" rx="3.5" ry="3.5" fill="#7A8797"/>
|
||||
<path d="M1399,355 L1433,355" stroke="#7A8797" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<rect x="1369" y="371" width="78" height="21" rx="5" fill="#FFFFFF" stroke="#7A8797" stroke-width="3"/>
|
||||
<ellipse cx="1383.5" cy="381.5" rx="3.5" ry="3.5" fill="#7A8797"/>
|
||||
<path d="M1399,381 L1433,381" stroke="#7A8797" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<rect x="1369" y="397" width="78" height="21" rx="5" fill="#FFFFFF" stroke="#7A8797" stroke-width="3"/>
|
||||
<ellipse cx="1383.5" cy="407.5" rx="3.5" ry="3.5" fill="#7A8797"/>
|
||||
<path d="M1399,407 L1433,407" stroke="#7A8797" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<text x="1192.5" y="449" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="23" font-weight="400" fill="#536477">Другие</text>
|
||||
<text x="1192.5" y="478" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="23" font-weight="400" fill="#536477">пользователи</text>
|
||||
<text x="1412.5" y="449" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="23" font-weight="400" fill="#536477">Другие</text>
|
||||
<text x="1412.5" y="478" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="23" font-weight="400" fill="#536477">серверы</text>
|
||||
<rect x="1120" y="550" width="360" height="69" rx="15" fill="#E6EAF0" stroke="none" stroke-width="2"/>
|
||||
<text x="1300.0" y="570" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="29" font-weight="700" fill="#536477">Нет доступа</text>
|
||||
<text x="1300.0" y="649" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="23" font-weight="400" fill="#536477">пока вы их не добавите</text>
|
||||
<text x="800.0" y="770" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="33" font-weight="700" fill="#172B46">Доступ появляется только после добавления в группу.</text>
|
||||
<text x="800.0" y="826" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="27" font-weight="400" fill="#536477">Вы сами выбираете участников и серверы своей группы.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.7 KiB |
@@ -0,0 +1,99 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900">
|
||||
<rect width="1600" height="900" fill="white"/>
|
||||
<text x="800.0" y="49" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="49" font-weight="700" fill="#172B46">У каждой группы — свой круг доступа</text>
|
||||
<text x="800.0" y="119" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="29" font-weight="400" fill="#536477">Контент доступен только участникам и серверам этой группы</text>
|
||||
<rect x="420" y="185" width="760" height="58" rx="29" fill="#F0F3F7" stroke="none" stroke-width="2"/>
|
||||
<text x="800.0" y="201" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="25" font-weight="700" fill="#172B46">Вы в двух группах — с одного устройства</text>
|
||||
<rect x="60" y="279" width="710" height="439" rx="25" fill="#F2F7FF" stroke="#B3D0F3" stroke-width="2"/>
|
||||
<text x="415.0" y="306" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="30" font-weight="700" fill="#2471CF">ГРУППА А</text>
|
||||
<rect x="140" y="366" width="550" height="74" rx="16" fill="#FFFFFF" stroke="#B3D0F3" stroke-width="2"/>
|
||||
<rect x="171" y="380" width="26" height="31" rx="13" fill="#FFFFFF" stroke="#2471CF" stroke-width="3"/>
|
||||
<rect x="164" y="397" width="40" height="29" rx="6" fill="#2471CF" stroke="none" stroke-width="2"/>
|
||||
<ellipse cx="184.0" cy="409.0" rx="3.0" ry="3.0" fill="#FFFFFF"/>
|
||||
<text x="435.0" y="380" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="26" font-weight="700" fill="#2471CF">Свой контент группы А</text>
|
||||
<text x="435.0" y="412" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="19" font-weight="400" fill="#536477">сообщения • файлы • трансляции</text>
|
||||
<path d="M250,526 L349,526" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M250,526 L258,520" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M250,526 L258,532" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M349,526 L341,520" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M349,526 L341,532" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M481,526 L582,526" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M481,526 L489,520" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M481,526 L489,532" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M582,526 L574,520" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M582,526 L574,532" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<rect x="163" y="483" width="54" height="86" rx="9" fill="#FFFFFF" stroke="#172B46" stroke-width="3"/>
|
||||
<path d="M180,493 L200,493" stroke="#172B46" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<ellipse cx="190.0" cy="558.5" rx="4.0" ry="2.5" fill="#172B46"/>
|
||||
<ellipse cx="190.0" cy="519.0" rx="9.0" ry="9.0" fill="#172B46"/>
|
||||
<rect x="174" y="533" width="32" height="14" rx="7" fill="#172B46" stroke="none" stroke-width="2"/>
|
||||
<rect x="375" y="494" width="80" height="54" rx="5" fill="#FFFFFF" stroke="#2471CF" stroke-width="3"/>
|
||||
<path d="M366,557 L464,557" stroke="#2471CF" stroke-width="4" fill="none" stroke-linecap="round"/>
|
||||
<path d="M375,548 L366,557" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M455,548 L464,557" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<ellipse cx="415.0" cy="512.0" rx="8.0" ry="8.0" fill="#2471CF"/>
|
||||
<rect x="399" y="524" width="32" height="13" rx="6" fill="#2471CF" stroke="none" stroke-width="2"/>
|
||||
<rect x="601" y="489" width="78" height="21" rx="5" fill="#FFFFFF" stroke="#2471CF" stroke-width="3"/>
|
||||
<ellipse cx="615.5" cy="499.5" rx="3.5" ry="3.5" fill="#2471CF"/>
|
||||
<path d="M631,499 L665,499" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<rect x="601" y="515" width="78" height="21" rx="5" fill="#FFFFFF" stroke="#2471CF" stroke-width="3"/>
|
||||
<ellipse cx="615.5" cy="525.5" rx="3.5" ry="3.5" fill="#2471CF"/>
|
||||
<path d="M631,525 L665,525" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<rect x="601" y="541" width="78" height="21" rx="5" fill="#FFFFFF" stroke="#2471CF" stroke-width="3"/>
|
||||
<ellipse cx="615.5" cy="551.5" rx="3.5" ry="3.5" fill="#2471CF"/>
|
||||
<path d="M631,551 L665,551" stroke="#2471CF" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<text x="190.0" y="586" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="24" font-weight="700" fill="#172B46">Ваше</text>
|
||||
<text x="190.0" y="615" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="24" font-weight="700" fill="#172B46">устройство</text>
|
||||
<text x="415.0" y="586" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="23" font-weight="400" fill="#172B46">Устройства</text>
|
||||
<text x="415.0" y="615" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="23" font-weight="400" fill="#172B46">участников</text>
|
||||
<text x="640.0" y="586" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="23" font-weight="400" fill="#172B46">Серверы</text>
|
||||
<text x="640.0" y="615" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="23" font-weight="400" fill="#172B46">группы А</text>
|
||||
<text x="415.0" y="672" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="23" font-weight="700" fill="#2471CF">Обмен только внутри этой группы</text>
|
||||
<rect x="830" y="279" width="710" height="439" rx="25" fill="#F0FAF6" stroke="#ACDCCD" stroke-width="2"/>
|
||||
<text x="1185.0" y="306" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="30" font-weight="700" fill="#13836D">ГРУППА Б</text>
|
||||
<rect x="910" y="366" width="550" height="74" rx="16" fill="#FFFFFF" stroke="#ACDCCD" stroke-width="2"/>
|
||||
<rect x="941" y="380" width="26" height="31" rx="13" fill="#FFFFFF" stroke="#13836D" stroke-width="3"/>
|
||||
<rect x="934" y="397" width="40" height="29" rx="6" fill="#13836D" stroke="none" stroke-width="2"/>
|
||||
<ellipse cx="954.0" cy="409.0" rx="3.0" ry="3.0" fill="#FFFFFF"/>
|
||||
<text x="1205.0" y="380" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="26" font-weight="700" fill="#13836D">Свой контент группы Б</text>
|
||||
<text x="1205.0" y="412" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="19" font-weight="400" fill="#536477">сообщения • файлы • трансляции</text>
|
||||
<path d="M1020,526 L1119,526" stroke="#13836D" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M1020,526 L1028,520" stroke="#13836D" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M1020,526 L1028,532" stroke="#13836D" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M1119,526 L1111,520" stroke="#13836D" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M1119,526 L1111,532" stroke="#13836D" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M1251,526 L1352,526" stroke="#13836D" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M1251,526 L1259,520" stroke="#13836D" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M1251,526 L1259,532" stroke="#13836D" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M1352,526 L1344,520" stroke="#13836D" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M1352,526 L1344,532" stroke="#13836D" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<rect x="933" y="483" width="54" height="86" rx="9" fill="#FFFFFF" stroke="#172B46" stroke-width="3"/>
|
||||
<path d="M950,493 L970,493" stroke="#172B46" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<ellipse cx="960.0" cy="558.5" rx="4.0" ry="2.5" fill="#172B46"/>
|
||||
<ellipse cx="960.0" cy="519.0" rx="9.0" ry="9.0" fill="#172B46"/>
|
||||
<rect x="944" y="533" width="32" height="14" rx="7" fill="#172B46" stroke="none" stroke-width="2"/>
|
||||
<rect x="1145" y="494" width="80" height="54" rx="5" fill="#FFFFFF" stroke="#13836D" stroke-width="3"/>
|
||||
<path d="M1136,557 L1234,557" stroke="#13836D" stroke-width="4" fill="none" stroke-linecap="round"/>
|
||||
<path d="M1145,548 L1136,557" stroke="#13836D" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M1225,548 L1234,557" stroke="#13836D" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<ellipse cx="1185.0" cy="512.0" rx="8.0" ry="8.0" fill="#13836D"/>
|
||||
<rect x="1169" y="524" width="32" height="13" rx="6" fill="#13836D" stroke="none" stroke-width="2"/>
|
||||
<rect x="1371" y="489" width="78" height="21" rx="5" fill="#FFFFFF" stroke="#13836D" stroke-width="3"/>
|
||||
<ellipse cx="1385.5" cy="499.5" rx="3.5" ry="3.5" fill="#13836D"/>
|
||||
<path d="M1401,499 L1435,499" stroke="#13836D" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<rect x="1371" y="515" width="78" height="21" rx="5" fill="#FFFFFF" stroke="#13836D" stroke-width="3"/>
|
||||
<ellipse cx="1385.5" cy="525.5" rx="3.5" ry="3.5" fill="#13836D"/>
|
||||
<path d="M1401,525 L1435,525" stroke="#13836D" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<rect x="1371" y="541" width="78" height="21" rx="5" fill="#FFFFFF" stroke="#13836D" stroke-width="3"/>
|
||||
<ellipse cx="1385.5" cy="551.5" rx="3.5" ry="3.5" fill="#13836D"/>
|
||||
<path d="M1401,551 L1435,551" stroke="#13836D" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<text x="960.0" y="586" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="24" font-weight="700" fill="#172B46">Ваше</text>
|
||||
<text x="960.0" y="615" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="24" font-weight="700" fill="#172B46">устройство</text>
|
||||
<text x="1185.0" y="586" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="23" font-weight="400" fill="#172B46">Устройства</text>
|
||||
<text x="1185.0" y="615" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="23" font-weight="400" fill="#172B46">участников</text>
|
||||
<text x="1410.0" y="586" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="23" font-weight="400" fill="#172B46">Серверы</text>
|
||||
<text x="1410.0" y="615" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="23" font-weight="400" fill="#172B46">группы Б</text>
|
||||
<text x="1185.0" y="672" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="23" font-weight="700" fill="#13836D">Обмен только внутри этой группы</text>
|
||||
<text x="800.0" y="763" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="31" font-weight="700" fill="#172B46">Общее устройство не объединяет группы.</text>
|
||||
<text x="800.0" y="816" text-anchor="middle" dominant-baseline="text-before-edge" font-family="DejaVu Sans, sans-serif" font-size="26" font-weight="400" fill="#536477">Посторонние узлы не участвуют в обмене и не имеют доступа к контенту.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1,307 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#122c47">
|
||||
<meta name="description" content="VibeChat — P2P-мессенджер: своя сеть, управляемый доступ к группам, защищённая личная переписка, звонки, рация и трансляции.">
|
||||
<title>VibeChat — ваши люди, ваша сеть</title>
|
||||
<link rel="icon" href="assets/vibechat.png" type="image/png">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<script src="script.js" defer></script>
|
||||
</head>
|
||||
<body id="top">
|
||||
<a class="skip-link" href="#main">Перейти к содержимому</a>
|
||||
<svg class="icon-definitions" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<defs>
|
||||
<symbol id="icon-chat" viewBox="0 0 24 24">
|
||||
<path d="M20 11.5a8 8 0 0 1-8 8H5l-3 2v-10a9 9 0 0 1 18 0Z"/>
|
||||
<path d="M7 9h8M7 13h5"/>
|
||||
</symbol>
|
||||
<symbol id="icon-file" viewBox="0 0 24 24">
|
||||
<path d="M14 2H5v20h14V7l-5-5Zm0 0v5h5M8 12h8M8 16h5"/>
|
||||
</symbol>
|
||||
<symbol id="icon-video" viewBox="0 0 24 24">
|
||||
<rect x="2" y="5" width="13" height="14" rx="3"/>
|
||||
<path d="m15 9 7-4v14l-7-4Z"/>
|
||||
</symbol>
|
||||
<symbol id="icon-radio" viewBox="0 0 24 24">
|
||||
<rect x="9" y="2" width="6" height="12" rx="3"/>
|
||||
<path d="M5 10v1a7 7 0 0 0 14 0v-1M12 18v4M8 22h8"/>
|
||||
</symbol>
|
||||
<symbol id="icon-live" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="2"/>
|
||||
<path d="M7.8 7.8a6 6 0 0 0 0 8.4m8.4 0a6 6 0 0 0 0-8.4M5 5a10 10 0 0 0 0 14m14 0a10 10 0 0 0 0-14"/>
|
||||
</symbol>
|
||||
<symbol id="icon-delivery" viewBox="0 0 24 24">
|
||||
<path d="M3 11a9 9 0 1 1 2 7M3 4v7h7M12 6v6l4 2"/>
|
||||
</symbol>
|
||||
<symbol id="icon-arrow" viewBox="0 0 24 24">
|
||||
<path d="M5 12h14m-6-6 6 6-6 6"/>
|
||||
</symbol>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<header class="header">
|
||||
<div class="container header-inner">
|
||||
<a class="brand" href="#top" aria-label="VibeChat — в начало">
|
||||
<img src="assets/vibechat.png" alt="" width="42" height="42">
|
||||
<span>VibeChat<span class="brand-dot">.</span></span>
|
||||
</a>
|
||||
<nav aria-label="Основная навигация">
|
||||
<a href="#features">Возможности</a>
|
||||
<a href="#network">Как устроено</a>
|
||||
<a href="#platforms">Платформы</a>
|
||||
<a href="#opensource">Открытый код <span aria-hidden="true">↗</span></a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<section class="hero container" aria-labelledby="hero-title">
|
||||
<div class="hero-copy">
|
||||
<p class="eyebrow"><span class="little-dot"></span> Децентрализованный мессенджер</p>
|
||||
<h1 id="hero-title">Ваши люди.<br><span>Ваша сеть.</span></h1>
|
||||
<p class="hero-description">VibeChat объединяет переписку, звонки и прямые эфиры
|
||||
в сети устройств участников. Может работать без серверов.</p>
|
||||
<a class="button button-dark" href="#network">Познакомиться с VibeChat
|
||||
<svg class="icon" aria-hidden="true"><use href="#icon-arrow"/></svg>
|
||||
</a>
|
||||
<p class="hero-note">На компьютере и Android. На общем ядре uTun.</p>
|
||||
</div>
|
||||
|
||||
<figure class="network-art">
|
||||
<div class="art-heading"><span class="little-dot"></span> Связь внутри вашего круга <span class="art-label">P2P</span></div>
|
||||
<svg class="network-drawing" viewBox="0 0 500 390" role="img" aria-labelledby="network-art-title network-art-description">
|
||||
<title id="network-art-title">Сеть участников VibeChat</title>
|
||||
<desc id="network-art-description">Телефоны и компьютеры участников образуют сеть.
|
||||
Показан необязательный сервер группы: он упрощает связь и временно хранит данные.</desc>
|
||||
<circle class="orbit" cx="250" cy="195" r="150"/>
|
||||
<circle class="orbit orbit-inner" cx="250" cy="195" r="104"/>
|
||||
<g class="network-lines">
|
||||
<path d="m115 95 270 20-40 180-225 5-5-205 230 200M120 300l265-185M115 95l135 95 135-75M250 190l95 105M120 300l130-110"/>
|
||||
</g>
|
||||
<g class="signal-dots" fill="#d4f58b">
|
||||
<circle cx="183" cy="143" r="4"/><circle cx="317" cy="153" r="4"/>
|
||||
<circle cx="183" cy="246" r="4"/><circle cx="366" cy="207" r="4"/>
|
||||
</g>
|
||||
<g class="node node-you" transform="translate(115 95)">
|
||||
<circle r="38"/>
|
||||
<rect x="-10" y="-19" width="20" height="34" rx="4"/>
|
||||
<path d="M-3 10h6"/>
|
||||
<text y="66">Вы</text>
|
||||
</g>
|
||||
<g class="node" transform="translate(385 115)">
|
||||
<circle r="32"/>
|
||||
<rect x="-18" y="-14" width="36" height="24" rx="3"/>
|
||||
<path d="M-24 17h48"/>
|
||||
<text y="59">Участник</text>
|
||||
</g>
|
||||
<g class="node" transform="translate(250 190)">
|
||||
<circle r="32"/>
|
||||
<rect x="-10" y="-18" width="20" height="34" rx="4"/>
|
||||
<path d="M-3 11h6"/>
|
||||
<text y="60">Участник</text>
|
||||
</g>
|
||||
<g class="node" transform="translate(345 295)">
|
||||
<circle r="32"/>
|
||||
<rect x="-18" y="-14" width="36" height="24" rx="3"/>
|
||||
<path d="M-24 17h48"/>
|
||||
<text y="59">Участник</text>
|
||||
</g>
|
||||
<g class="node node-server" transform="translate(120 300)">
|
||||
<circle r="32"/>
|
||||
<rect x="-17" y="-16" width="34" height="12" rx="3"/>
|
||||
<rect x="-17" y="4" width="34" height="12" rx="3"/>
|
||||
<path d="M-10-10h3M-10 10h3M3-10h8M3 10h8"/>
|
||||
<text y="59">Сервер группы</text>
|
||||
</g>
|
||||
</svg>
|
||||
<figcaption><span>Сервер группы — по желанию</span><span aria-hidden="true">↗</span></figcaption>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<div class="principles container" aria-label="Три главных принципа">
|
||||
<div><span class="principle-number">01 /</span><p><strong>Своя сеть</strong><span>Работает и без серверов</span></p></div>
|
||||
<div><span class="principle-number">02 /</span><p><strong>Свой круг</strong><span>Управляемый доступ участников</span></p></div>
|
||||
<div><span class="principle-number">03 /</span><p><strong>Личное остаётся личным</strong><span>Сквозное шифрование в беседах</span></p></div>
|
||||
</div>
|
||||
|
||||
<section class="features container section" id="features" aria-labelledby="features-title">
|
||||
<div class="section-heading">
|
||||
<div><p class="eyebrow">01 — Возможности</p><h2 id="features-title">Больше способов<br>быть на связи.</h2></div>
|
||||
<p class="section-intro">От короткого сообщения до общего эфира —<br class="desktop-break"> в одном приложении.</p>
|
||||
</div>
|
||||
<div class="feature-grid">
|
||||
<article class="feature">
|
||||
<svg class="icon feature-icon" aria-hidden="true"><use href="#icon-chat"/></svg>
|
||||
<h3>Личные и групповые чаты</h3>
|
||||
<p>Переписка один на один и общение в группе. Ответы, реакции и опросы помогают оставаться в контексте.</p>
|
||||
</article>
|
||||
<article class="feature">
|
||||
<svg class="icon feature-icon" aria-hidden="true"><use href="#icon-file"/></svg>
|
||||
<h3>Файлы и голосовые</h3>
|
||||
<p>Делитесь документами, фотографиями и видео. Записывайте голосовые, когда удобнее сказать, чем написать.</p>
|
||||
</article>
|
||||
<article class="feature">
|
||||
<svg class="icon feature-icon" aria-hidden="true"><use href="#icon-video"/></svg>
|
||||
<h3>Аудио- и видеозвонки</h3>
|
||||
<p>Разговаривайте с собеседником голосом или с камерой. Приложение подбирает доступный путь соединения.</p>
|
||||
</article>
|
||||
<article class="feature">
|
||||
<svg class="icon feature-icon" aria-hidden="true"><use href="#icon-radio"/></svg>
|
||||
<h3>Групповая рация</h3>
|
||||
<p>Нажмите и говорите участникам канала. Можно включить автоматическую передачу при обнаружении речи.</p>
|
||||
</article>
|
||||
<article class="feature">
|
||||
<svg class="icon feature-icon" aria-hidden="true"><use href="#icon-live"/></svg>
|
||||
<h3>Прямые трансляции</h3>
|
||||
<p>Ведите эфир с камеры, делитесь экраном с компьютера. Участники помогают передавать поток другим зрителям.</p>
|
||||
</article>
|
||||
<article class="feature">
|
||||
<svg class="icon feature-icon" aria-hidden="true"><use href="#icon-delivery"/></svg>
|
||||
<h3>Доставка после возвращения</h3>
|
||||
<p>Суперузел общей группы может сохранить зашифрованное личное сообщение и передать его, когда получатель вернётся в сеть.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="architecture section" id="network" aria-labelledby="architecture-title">
|
||||
<div class="container">
|
||||
<div class="section-heading">
|
||||
<div><p class="eyebrow">02 — Как всё устроено</p><h2 id="architecture-title">Своя сеть.<br>Понятные границы.</h2></div>
|
||||
<p class="section-intro">Кто участвует в обмене, кто получает доступ<br class="desktop-break"> и кто может прочитать личное.</p>
|
||||
</div>
|
||||
|
||||
<div class="diagram-tabs" role="tablist" aria-label="Принципы работы VibeChat" hidden>
|
||||
<button id="tab-access" type="button" role="tab" aria-selected="true" aria-controls="panel-access">
|
||||
<span>01</span> Вы выбираете участников
|
||||
</button>
|
||||
<button id="tab-groups" type="button" role="tab" aria-selected="false" aria-controls="panel-groups" tabindex="-1">
|
||||
<span>02</span> Группы независимы
|
||||
</button>
|
||||
<button id="tab-private" type="button" role="tab" aria-selected="false" aria-controls="panel-private" tabindex="-1">
|
||||
<span>03</span> Личное зашифровано
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<article class="diagram-panel" id="panel-access" aria-labelledby="diagram-access-title">
|
||||
<div class="diagram-copy">
|
||||
<h3 id="diagram-access-title">Ваша группа — ваш круг доверия.</h3>
|
||||
<p>Создайте группу и пригласите людей. Сервер необязателен: ваш сервер или сервер вашего круга
|
||||
упрощает связь и обеспечивает временное хранение данных для доставки участникам.
|
||||
Доступ и правила приглашения остаются под контролем группы.</p>
|
||||
</div>
|
||||
<figure>
|
||||
<a class="diagram-link" href="assets/group_access_slide.svg" target="_blank" rel="noopener"
|
||||
aria-label="Увеличить схему доступа к группе" data-enlarge>
|
||||
<img src="assets/group_access_slide.svg" width="1600" height="900" loading="lazy"
|
||||
alt="К информации группы имеют доступ добавленные участники и серверы. Посторонние пользователи и серверы доступа не имеют.">
|
||||
<span class="zoom-label" aria-hidden="true">Открыть схему <span>↗</span></span>
|
||||
</a>
|
||||
<figcaption>Групповой контент доступен участникам и серверам этой группы.</figcaption>
|
||||
</figure>
|
||||
</article>
|
||||
|
||||
<article class="diagram-panel" id="panel-groups" aria-labelledby="diagram-groups-title">
|
||||
<div class="diagram-copy">
|
||||
<h3 id="diagram-groups-title">Одно устройство. Разные сообщества.</h3>
|
||||
<p>Можно состоять в нескольких группах. У каждой — собственные участники, серверы и контент.
|
||||
Ваше участие в обеих группах не объединяет их доступ.</p>
|
||||
</div>
|
||||
<figure>
|
||||
<a class="diagram-link" href="assets/group_slide_simple.svg" target="_blank" rel="noopener"
|
||||
aria-label="Увеличить схему независимых групп" data-enlarge>
|
||||
<img src="assets/group_slide_simple.svg" width="1600" height="900" loading="lazy"
|
||||
alt="Группы А и Б обмениваются сообщениями, файлами и трансляциями каждая внутри своего круга, даже при общем участнике.">
|
||||
<span class="zoom-label" aria-hidden="true">Открыть схему <span>↗</span></span>
|
||||
</a>
|
||||
<figcaption>Общие участники не дают одной группе доступ к содержимому другой.</figcaption>
|
||||
</figure>
|
||||
</article>
|
||||
|
||||
<article class="diagram-panel" id="panel-private" aria-labelledby="diagram-private-title">
|
||||
<div class="diagram-copy">
|
||||
<h3 id="diagram-private-title">Доставить сообщение ≠ прочитать его.</h3>
|
||||
<p>Личные сообщения и вложения шифруются на устройстве отправителя и расшифровываются у собеседника.
|
||||
Промежуточные узлы передают зашифрованные данные.</p>
|
||||
</div>
|
||||
<figure>
|
||||
<a class="diagram-link" href="assets/p2p_slide_simple.png" target="_blank" rel="noopener"
|
||||
aria-label="Увеличить схему шифрования личной переписки" data-enlarge>
|
||||
<img src="assets/p2p_slide_simple.png" width="1672" height="941" loading="lazy"
|
||||
alt="Личные сообщения доставляются напрямую, через участников или сервер группы. Ключи расшифровки есть только у сторон личной беседы.">
|
||||
<span class="zoom-label" aria-hidden="true">Открыть схему <span>↗</span></span>
|
||||
</a>
|
||||
<figcaption>Сквозное шифрование защищает содержимое личной беседы, в том числе при хранении на сервере группы.</figcaption>
|
||||
</figure>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="platforms container section" id="platforms" aria-labelledby="platforms-title">
|
||||
<div class="platform-copy">
|
||||
<p class="eyebrow">03 — Один проект, разные устройства</p>
|
||||
<h2 id="platforms-title">На большом экране.<br>И под рукой.</h2>
|
||||
<p>Десктопный клиент и приложение для Android используют общее сетевое ядро uTun.
|
||||
Чат работает как самостоятельный сервис.</p>
|
||||
<ul class="platform-list" aria-label="Платформы"><li>Windows</li><li>Linux</li><li>Android</li></ul>
|
||||
</div>
|
||||
<aside class="project-note" aria-labelledby="project-title">
|
||||
<span class="project-badge"><span class="little-dot"></span> В активной разработке</span>
|
||||
<h3 id="project-title">Общение, которое<br>строится вокруг вас.</h3>
|
||||
<p>Соберите свой круг: люди и устройства, которым вы даёте доступ к группе.
|
||||
При желании добавьте свой сервер или сервер вашего круга.</p>
|
||||
<a href="#network">Посмотреть, как это работает <span aria-hidden="true">↗</span></a>
|
||||
</aside>
|
||||
</section>
|
||||
<section class="opensource section" id="opensource" aria-labelledby="opensource-title">
|
||||
<div class="container">
|
||||
<div class="section-heading">
|
||||
<div><p class="eyebrow">04 — Open source</p><h2 id="opensource-title">Открытый код.<br>Развиваем вместе.</h2></div>
|
||||
<p class="section-intro">Присоединяйтесь к разработке VibeChat:<br class="desktop-break">
|
||||
изучайте, проверяйте и улучшайте проект.</p>
|
||||
</div>
|
||||
<div class="feature-grid contribution-grid">
|
||||
<article class="feature">
|
||||
<span class="contribution-number" aria-hidden="true">01 /</span>
|
||||
<h3>Улучшайте вместе с нами</h3>
|
||||
<p>Предлагайте идеи, исправляйте ошибки, добавляйте возможности и помогайте с документацией.
|
||||
Ваш вклад может сделать VibeChat удобнее для всех.</p>
|
||||
</article>
|
||||
<article class="feature">
|
||||
<span class="contribution-number" aria-hidden="true">02 /</span>
|
||||
<h3>Проверяйте безопасность</h3>
|
||||
<p>Изучайте исходники самостоятельно или с помощью нейросети: разбирайте шифрование,
|
||||
доступ к данным и сетевые механизмы. Проверяйте находки и помогайте устранять уязвимости.</p>
|
||||
</article>
|
||||
<article class="feature">
|
||||
<span class="contribution-number" aria-hidden="true">03 /</span>
|
||||
<h3>Собирайте из исходников</h3>
|
||||
<p>Вы можете сами собрать приложение, запустить его на своих устройствах
|
||||
и доработать под собственные задачи. Изучайте код, который используете.</p>
|
||||
</article>
|
||||
</div>
|
||||
<div class="source-link">
|
||||
<p>Начните с исходников проекта.</p>
|
||||
<a class="button button-dark" href="http://git.syqiq.tech/jeka/utun2" target="_blank" rel="noopener noreferrer">
|
||||
Открыть репозиторий <span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="footer container">
|
||||
<a class="brand" href="#top"><img src="assets/vibechat.png" alt="" width="32" height="32"><span>VibeChat<span class="brand-dot">.</span></span></a>
|
||||
<p>Ваши люди. Ваша сеть.</p>
|
||||
<a class="back-top" href="#top">Наверх <span aria-hidden="true">↑</span></a>
|
||||
</footer>
|
||||
|
||||
<dialog class="image-dialog" aria-label="Увеличенная схема">
|
||||
<div class="dialog-toolbar"><p>VibeChat / Как устроена сеть</p><button type="button" class="dialog-close" autofocus>Закрыть <span aria-hidden="true">×</span></button></div>
|
||||
<div class="dialog-image-wrap"><img class="dialog-image" alt=""></div>
|
||||
<p class="dialog-hint">На узком экране схему можно прокручивать по горизонтали.</p>
|
||||
</dialog>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,74 @@
|
||||
/* Без JavaScript все схемы остаются видимыми, ссылки открывают исходные иллюстрации. */
|
||||
"use strict";
|
||||
|
||||
const tabs = Array.from(document.querySelectorAll('[role="tab"]'));
|
||||
const panels = tabs.map((tab) => document.getElementById(tab.getAttribute("aria-controls")));
|
||||
const tabList = document.querySelector(".diagram-tabs");
|
||||
|
||||
// Выбор схемы синхронизирует видимость, фокус и состояние доступности.
|
||||
function selectDiagram(index) {
|
||||
tabs.forEach((tab, position) => {
|
||||
const selected = position === index;
|
||||
tab.setAttribute("aria-selected", String(selected));
|
||||
tab.tabIndex = selected ? 0 : -1;
|
||||
panels[position].hidden = !selected;
|
||||
});
|
||||
console.debug("[VibeChat] Выбрана схема:", panels[index].id);
|
||||
}
|
||||
|
||||
tabs.forEach((tab, index) => {
|
||||
panels[index].setAttribute("role", "tabpanel");
|
||||
panels[index].setAttribute("aria-labelledby", tab.id);
|
||||
panels[index].tabIndex = 0;
|
||||
tab.addEventListener("click", () => selectDiagram(index));
|
||||
tab.addEventListener("keydown", (event) => {
|
||||
let next = index;
|
||||
if (event.key === "ArrowRight") next = (index + 1) % tabs.length;
|
||||
else if (event.key === "ArrowLeft") next = (index + tabs.length - 1) % tabs.length;
|
||||
else if (event.key === "Home") next = 0;
|
||||
else if (event.key === "End") next = tabs.length - 1;
|
||||
else return;
|
||||
event.preventDefault();
|
||||
selectDiagram(next);
|
||||
tabs[next].focus();
|
||||
});
|
||||
});
|
||||
selectDiagram(0);
|
||||
tabList.hidden = false;
|
||||
document.getElementById("network").classList.add("tabs-ready");
|
||||
|
||||
const dialog = document.querySelector(".image-dialog");
|
||||
const dialogImage = dialog.querySelector(".dialog-image");
|
||||
|
||||
if (typeof dialog.showModal === "function") {
|
||||
document.querySelectorAll("[data-enlarge]").forEach((link) => {
|
||||
link.addEventListener("click", (event) => {
|
||||
if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
|
||||
event.preventDefault();
|
||||
const source = link.querySelector("img");
|
||||
dialogImage.src = source.src;
|
||||
dialogImage.alt = source.alt;
|
||||
dialog.showModal();
|
||||
dialog.querySelector(".dialog-image-wrap").scrollLeft = 0;
|
||||
console.debug("[VibeChat] Открыта увеличенная схема:", link.getAttribute("href"));
|
||||
});
|
||||
});
|
||||
dialog.querySelector(".dialog-close").addEventListener("click", () => dialog.close());
|
||||
dialog.addEventListener("click", (event) => {
|
||||
const bounds = dialog.getBoundingClientRect();
|
||||
const outside = event.clientX < bounds.left || event.clientX > bounds.right
|
||||
|| event.clientY < bounds.top || event.clientY > bounds.bottom;
|
||||
if (event.target === dialog && outside) dialog.close();
|
||||
});
|
||||
dialog.addEventListener("close", () => console.debug("[VibeChat] Увеличенная схема закрыта"));
|
||||
} else {
|
||||
console.warn("[VibeChat] Просмотр в диалоге недоступен; схемы открываются по обычным ссылкам");
|
||||
}
|
||||
|
||||
document.querySelectorAll("img").forEach((image) => {
|
||||
image.addEventListener("error", () => console.error("[VibeChat] Не удалось загрузить иллюстрацию:", image.src));
|
||||
if (image.getAttribute("src") && image.complete && !image.naturalWidth) {
|
||||
console.error("[VibeChat] Иллюстрация недоступна:", image.src);
|
||||
}
|
||||
});
|
||||
console.info("[VibeChat] Презентация готова; схем:", tabs.length);
|
||||
@@ -0,0 +1,241 @@
|
||||
/* Автономная презентация: системные шрифты, локальные иллюстрации, без внешних зависимостей. */
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--ink: #122c47;
|
||||
--muted: #556679;
|
||||
--blue: #256cc6;
|
||||
--line: #dce4eb;
|
||||
--paper: #f8fafc;
|
||||
--lime: #d4f58b;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; scroll-padding-top: 100px; }
|
||||
body { margin: 0; background: var(--paper); color: var(--ink); font-family: "Segoe UI", Arial, sans-serif; -webkit-font-smoothing: antialiased; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button { font: inherit; cursor: pointer; }
|
||||
button, a { -webkit-tap-highlight-color: transparent; }
|
||||
button:focus-visible, a:focus-visible, [tabindex]:focus-visible { outline: 3px solid var(--blue); outline-offset: 5px; }
|
||||
img { display: block; max-width: 100%; height: auto; }
|
||||
figure, p, h1, h2, h3 { margin: 0; }
|
||||
[hidden] { display: none !important; }
|
||||
::selection { color: var(--ink); background: var(--lime); }
|
||||
.container { width: min(1200px, calc(100% - 96px)); margin-inline: auto; }
|
||||
.icon-definitions { position: absolute; width: 0; height: 0; overflow: hidden; }
|
||||
.icon { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.skip-link { position: fixed; left: 20px; top: -80px; z-index: 10; padding: 14px 22px; background: white; }
|
||||
.skip-link:focus { top: 12px; }
|
||||
.header { position: sticky; top: 0; z-index: 5; background: #f8fafcf5; border-bottom: 1px solid var(--line); }
|
||||
.header-inner { min-height: 88px; display: flex; align-items: center; justify-content: space-between; gap: 28px; }
|
||||
.brand { display: inline-flex; align-items: center; gap: 10px; font-size: 26px; font-weight: 750; letter-spacing: -1px; }
|
||||
.brand img { flex-shrink: 0; }
|
||||
.brand-dot { color: var(--blue); }
|
||||
nav { display: flex; align-items: center; gap: 35px; font-size: 14px; font-weight: 600; }
|
||||
nav a { padding-block: 14px; }
|
||||
nav a:hover, .back-top:hover { color: var(--blue); }
|
||||
nav a:last-child { padding: 12px 18px; border: 1px solid #cbd6e1; border-radius: 6px; }
|
||||
nav a span { padding-left: 12px; }
|
||||
.hero { display: grid; grid-template-columns: 1.08fr 1fr; align-items: center; gap: 52px; padding-block: 76px 62px; }
|
||||
.eyebrow { display: flex; align-items: center; gap: 10px; font-size: 11px; line-height: 1.7; font-weight: 700; text-transform: uppercase; letter-spacing: 1.7px; }
|
||||
.little-dot { display: inline-block; flex-shrink: 0; width: 7px; height: 7px; border-radius: 50%; background: var(--blue); }
|
||||
h1 { margin-block: 24px; font-size: clamp(48px, 5.5vw, 76px); line-height: 1.06; font-weight: 750; letter-spacing: -3.7px; }
|
||||
h1 span { color: var(--blue); }
|
||||
.hero-description { max-width: 500px; color: var(--muted); font-size: 18px; line-height: 1.7; }
|
||||
.button { display: inline-flex; align-items: center; justify-content: center; gap: 22px; min-height: 54px; padding: 15px 22px; border-radius: 6px; font-size: 14px; font-weight: 600; }
|
||||
.button-dark { margin-top: 30px; background: var(--ink); color: white; transition: background .2s, transform .2s; }
|
||||
.button-dark:hover { background: #21496e; transform: translateY(-2px); }
|
||||
.button:focus-visible { outline-offset: 5px; }
|
||||
.hero-note { margin-top: 18px; color: var(--muted); font-size: 12px; line-height: 1.6; }
|
||||
.network-art { overflow: hidden; padding: 26px 26px 0; border-radius: 18px; background: var(--ink); color: #e4edf7; }
|
||||
.art-heading { display: flex; align-items: center; gap: 9px; font-size: 12px; }
|
||||
.art-heading .little-dot { background: var(--lime); }
|
||||
.art-label { margin-left: auto; padding: 4px 7px; border: 1px solid #496077; border-radius: 4px; color: var(--lime); font-size: 10px; letter-spacing: 1px; }
|
||||
.network-drawing { display: block; width: 100%; height: auto; overflow: visible; margin-block: 7px; }
|
||||
.orbit { fill: none; stroke: #2c4761; stroke-dasharray: 3 8; }
|
||||
.orbit-inner { stroke: #243e59; stroke-dasharray: none; }
|
||||
.network-lines { fill: none; stroke: #517494; stroke-width: 1.3; }
|
||||
.node circle { fill: #1d3c59; stroke: #7d99b3; stroke-width: 1.3; }
|
||||
.node rect, .node path { fill: none; stroke: #e4edf7; stroke-width: 2; stroke-linecap: round; }
|
||||
.node text { fill: #d6e3f0; stroke: none; text-anchor: middle; font-size: 13px; }
|
||||
.node-you circle { fill: var(--lime); stroke: var(--lime); }
|
||||
.node-you rect, .node-you path { stroke: var(--ink); }
|
||||
.node-you text { fill: var(--lime); font-size: 14px; font-weight: 700; }
|
||||
.node-server circle { fill: #284e70; }
|
||||
.network-art figcaption { display: flex; justify-content: space-between; gap: 16px; padding: 19px 0; border-top: 1px solid #3a5269; font-size: 12px; color: #b6c9db; }
|
||||
.network-art figcaption > span:last-child { color: var(--lime); }
|
||||
.principles { display: grid; grid-template-columns: repeat(3, 1fr); padding-block: 30px; border-block: 1px solid var(--line); }
|
||||
.principles > div { display: flex; align-items: baseline; gap: 20px; padding-inline: 30px; border-left: 1px solid var(--line); }
|
||||
.principles > div:first-child { padding-left: 0; border-left: 0; }
|
||||
.principles > div:last-child { padding-right: 0; }
|
||||
.principle-number { font-size: 11px; font-weight: 600; color: var(--blue); }
|
||||
.principles strong { display: block; font-size: 16px; font-weight: 650; }
|
||||
.principles p > span { display: block; margin-top: 8px; color: var(--muted); font-size: 12px; line-height: 1.5; }
|
||||
.section { padding-block: 90px; }
|
||||
.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 40px; margin-bottom: 46px; }
|
||||
.section-heading .eyebrow, .platforms .eyebrow { color: var(--blue); }
|
||||
h2 { margin-top: 18px; font-size: clamp(32px, 3.3vw, 45px); line-height: 1.12; font-weight: 650; letter-spacing: -1.6px; }
|
||||
.section-intro { padding-bottom: 3px; font-size: 15px; line-height: 1.8; color: var(--muted); }
|
||||
.feature-grid { display: grid; grid-template-columns: repeat(3, 1fr); column-gap: 44px; }
|
||||
.feature { padding-block: 30px 32px; border-top: 1px solid var(--line); }
|
||||
.feature-icon { width: 28px; height: 28px; color: var(--blue); margin-bottom: 22px; }
|
||||
.feature h3 { margin-bottom: 12px; font-size: 18px; font-weight: 650; letter-spacing: -.35px; }
|
||||
.feature p { color: var(--muted); font-size: 14px; line-height: 1.8; }
|
||||
.architecture { background: #eaf0f6; border-block: 1px solid var(--line); }
|
||||
.diagram-tabs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-bottom: 24px; padding: 6px; background: #dce5ee; border-radius: 9px; }
|
||||
.diagram-tabs button { display: flex; align-items: center; justify-content: center; gap: 12px; padding: 16px 12px; border: 0; border-radius: 5px; background: transparent; color: #445d76; font-size: 14px; font-weight: 600; }
|
||||
.diagram-tabs button span { font-size: 10px; opacity: .75; }
|
||||
.diagram-tabs button:hover { background: #edf3f8; }
|
||||
.diagram-tabs button[aria-selected="true"] { background: var(--ink); color: white; }
|
||||
.diagram-tabs button[aria-selected="true"] span { color: var(--lime); opacity: 1; }
|
||||
.diagram-panel { padding: 30px; border: 1px solid #d4dfe9; border-radius: 12px; background: white; }
|
||||
.diagram-panel + .diagram-panel { margin-top: 24px; }
|
||||
.tabs-ready .diagram-panel { margin-top: 0; }
|
||||
.diagram-copy { display: grid; grid-template-columns: 1fr 1.35fr; align-items: center; gap: 40px; margin-bottom: 24px; }
|
||||
.diagram-copy h3 { font-size: 22px; line-height: 1.35; letter-spacing: -.5px; }
|
||||
.diagram-copy p { color: var(--muted); font-size: 14px; line-height: 1.75; }
|
||||
.diagram-link { position: relative; display: block; border: 1px solid #e6ebf0; border-radius: 6px; overflow: hidden; cursor: zoom-in; }
|
||||
.diagram-link img { width: 100%; }
|
||||
.zoom-label { position: absolute; right: 10px; bottom: 10px; display: flex; gap: 16px; padding: 9px 12px; border: 1px solid #cbd6e1; border-radius: 5px; background: #fff; font-size: 11px; color: var(--muted); }
|
||||
.diagram-link:hover .zoom-label { color: var(--blue); border-color: var(--blue); }
|
||||
.diagram-panel figcaption { margin-top: 16px; color: var(--muted); font-size: 12px; line-height: 1.6; }
|
||||
.platforms { display: grid; grid-template-columns: 1.1fr 1fr; align-items: center; gap: 90px; }
|
||||
.platform-copy > p:not(.eyebrow) { margin-top: 24px; color: var(--muted); font-size: 15px; line-height: 1.8; }
|
||||
.platform-list { display: flex; flex-wrap: wrap; gap: 12px; list-style: none; padding: 0; margin: 24px 0 0; }
|
||||
.platform-list li { padding: 10px 17px; border: 1px solid #ccd8e3; border-radius: 5px; font-size: 13px; font-weight: 600; }
|
||||
.project-note { padding: 38px; border-radius: 12px; background: var(--ink); color: white; }
|
||||
.project-badge { display: inline-flex; align-items: center; gap: 8px; font-size: 11px; color: var(--lime); }
|
||||
.project-badge .little-dot { background: currentColor; width: 6px; height: 6px; }
|
||||
.project-note h3 { margin-block: 26px 18px; font-size: 28px; font-weight: 550; line-height: 1.25; letter-spacing: -.6px; }
|
||||
.project-note p { color: #becfdf; font-size: 14px; line-height: 1.8; }
|
||||
.project-note > a { display: flex; justify-content: space-between; gap: 16px; padding-top: 23px; margin-top: 28px; border-top: 1px solid #3a5269; color: var(--lime); font-size: 13px; line-height: 1.5; }
|
||||
.project-note > a:hover { color: white; }
|
||||
.opensource { background: #eef4e9; border-top: 1px solid #d7e1d0; }
|
||||
.opensource .feature { border-color: #d0dcc8; }
|
||||
.contribution-number { display: block; margin-bottom: 22px; color: var(--blue); font-size: 12px; font-weight: 600; }
|
||||
.source-link { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin-top: 16px; }
|
||||
.source-link p { color: var(--muted); font-size: 14px; }
|
||||
.source-link .button { margin-top: 0; }
|
||||
.footer { display: flex; align-items: center; justify-content: space-between; gap: 24px; min-height: 112px; border-top: 1px solid var(--line); }
|
||||
.footer .brand { font-size: 21px; }
|
||||
.footer p, .back-top { font-size: 12px; color: var(--muted); }
|
||||
.back-top span { padding-left: 12px; }
|
||||
.image-dialog { width: min(1700px, calc(100% - 40px)); max-width: none; max-height: calc(100dvh - 40px); padding: 20px; border: 0; border-radius: 12px; color: var(--ink); }
|
||||
.image-dialog::backdrop { background: #071728d9; }
|
||||
.dialog-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 18px; font-size: 13px; }
|
||||
.dialog-close { display: flex; align-items: center; gap: 18px; min-height: 44px; padding: 8px 16px; background: var(--ink); color: white; border: 0; border-radius: 5px; }
|
||||
.dialog-close span { font-size: 23px; }
|
||||
.dialog-image-wrap { overflow: auto; }
|
||||
.dialog-image { width: 100%; min-width: 900px; }
|
||||
.dialog-hint { margin-top: 12px; color: var(--muted); font-size: 12px; line-height: 1.5; }
|
||||
body:has(.image-dialog[open]) { overflow: hidden; }
|
||||
|
||||
@media (min-width: 1600px) {
|
||||
.hero { padding-block: 90px 76px; }
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.container { width: calc(100% - 64px); }
|
||||
.hero { gap: 28px; padding-block: 56px; }
|
||||
h1 { letter-spacing: -2.8px; }
|
||||
.hero-description { font-size: 16px; }
|
||||
.network-art { padding: 20px 20px 0; }
|
||||
.principles > div { padding-inline: 20px; gap: 12px; }
|
||||
.principles strong { font-size: 14px; }
|
||||
.feature-grid { column-gap: 28px; }
|
||||
.diagram-tabs button { gap: 8px; font-size: 12px; }
|
||||
.platforms { gap: 45px; }
|
||||
.project-note { padding: 30px; }
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
html { scroll-padding-top: 96px; }
|
||||
.container { width: calc(100% - 40px); }
|
||||
.header-inner { min-height: 76px; gap: 18px; }
|
||||
.brand { gap: 6px; font-size: 23px; }
|
||||
.brand img { width: 36px; }
|
||||
nav { gap: 16px; font-size: 12px; }
|
||||
nav a:last-child { padding: 10px; }
|
||||
nav a span { display: none; }
|
||||
.hero { grid-template-columns: 1fr; gap: 38px; padding-block: 44px; }
|
||||
h1 { font-size: clamp(54px, 10vw, 72px); }
|
||||
.hero-description { max-width: 560px; font-size: 17px; }
|
||||
.network-art { width: 100%; max-width: 560px; justify-self: center; }
|
||||
.principles { gap: 14px; }
|
||||
.principles > div { display: block; padding-inline: 14px; }
|
||||
.principle-number { display: block; margin-bottom: 10px; }
|
||||
.principles strong { line-height: 1.4; }
|
||||
.section { padding-block: 64px; }
|
||||
.section-heading { align-items: start; flex-direction: column; gap: 22px; margin-bottom: 32px; }
|
||||
.feature-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.contribution-grid { grid-template-columns: 1fr; }
|
||||
.diagram-tabs { gap: 4px; }
|
||||
.diagram-tabs button { flex-direction: column; gap: 8px; padding: 12px 8px; line-height: 1.5; }
|
||||
.diagram-panel { padding: 22px; }
|
||||
.diagram-copy { grid-template-columns: 1fr; gap: 14px; }
|
||||
.diagram-copy h3 { font-size: 21px; }
|
||||
.platforms { grid-template-columns: 1fr; gap: 32px; }
|
||||
.platform-copy { max-width: 550px; }
|
||||
.project-note h3 br { display: none; }
|
||||
}
|
||||
@media (max-width: 650px) {
|
||||
html { scroll-padding-top: 160px; }
|
||||
.header-inner { flex-wrap: wrap; gap: 0; padding-block: 8px; }
|
||||
nav { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); width: 100%; gap: 0 16px; margin-top: 3px; }
|
||||
nav a, nav a:last-child { padding: 9px 0; border: 0; }
|
||||
.source-link { align-items: flex-start; flex-direction: column; gap: 18px; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.header .brand { font-size: 21px; }
|
||||
.header .brand img { width: 30px; }
|
||||
nav { gap: 0 8px; }
|
||||
.eyebrow { font-size: 9px; letter-spacing: 1.35px; }
|
||||
h1 { font-size: clamp(45px, 12.7vw, 61px); letter-spacing: -2.5px; margin-block: 20px; }
|
||||
.hero { padding-top: 34px; }
|
||||
.hero-description { font-size: 16px; }
|
||||
.button { width: 100%; gap: 12px; font-size: 13px; }
|
||||
.hero-note { font-size: 11px; }
|
||||
.network-art { padding: 18px 15px 0; border-radius: 12px; }
|
||||
.art-heading, .network-art figcaption { font-size: 10px; }
|
||||
.network-art figcaption { padding-block: 15px; }
|
||||
.principles { grid-template-columns: 1fr; padding-block: 6px; gap: 0; }
|
||||
.principles > div, .principles > div:first-child { display: flex; align-items: baseline; gap: 18px; padding: 18px 0; border: 0; }
|
||||
.principles > div + div { border-top: 1px solid var(--line); }
|
||||
.principle-number { margin: 0; }
|
||||
.principles strong { font-size: 15px; }
|
||||
.principles p > span { margin-top: 4px; }
|
||||
.section { padding-block: 52px; }
|
||||
h2 { font-size: 32px; letter-spacing: -1px; }
|
||||
.section-intro { font-size: 14px; }
|
||||
.desktop-break { display: none; }
|
||||
.feature-grid { grid-template-columns: 1fr; }
|
||||
.feature { padding-block: 25px; }
|
||||
.feature-icon { float: left; width: 25px; height: 25px; margin: 0 15px 0 0; }
|
||||
.feature h3 { min-height: 25px; font-size: 17px; padding-top: 2px; }
|
||||
.feature p { clear: both; }
|
||||
.diagram-tabs { padding: 4px; }
|
||||
.diagram-tabs button { padding: 10px 5px; font-size: 11px; }
|
||||
.diagram-panel { padding: 16px; }
|
||||
.diagram-copy h3 { font-size: 20px; }
|
||||
.diagram-copy p { font-size: 13px; }
|
||||
.zoom-label { position: static; justify-content: center; border: 0; border-top: 1px solid var(--line); border-radius: 0; }
|
||||
.project-note { padding: 26px; }
|
||||
.project-note h3 { font-size: 25px; }
|
||||
.footer { flex-wrap: wrap; gap: 16px; padding-block: 26px; }
|
||||
.footer p { order: 3; width: 100%; }
|
||||
.image-dialog { width: calc(100% - 16px); padding: 12px; }
|
||||
.dialog-toolbar { font-size: 11px; gap: 12px; }
|
||||
.dialog-close { gap: 8px; padding-inline: 12px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
*, *::before, *::after { transition: none !important; }
|
||||
}
|
||||
@media print {
|
||||
body { background: white; }
|
||||
.header { position: static; background: white; }
|
||||
.container { width: 100%; }
|
||||
nav, .button, .diagram-tabs, .zoom-label, .back-top, .image-dialog { display: none !important; }
|
||||
.hero, .section { padding-block: 24px; }
|
||||
.network-art, .project-note { print-color-adjust: exact; }
|
||||
.diagram-panel[hidden] { display: block !important; }
|
||||
.diagram-panel { margin-block: 20px !important; break-inside: avoid; }
|
||||
.architecture { background: white; }
|
||||
.feature { break-inside: avoid; }
|
||||
}
|
||||
@@ -63,7 +63,6 @@ utun_CORE_SOURCES = \
|
||||
routing_layer/conn_mgr_monitor.c \
|
||||
routing_layer/topo_recovery.c \
|
||||
routing_layer/topo_group_connect.c \
|
||||
routing_layer/topo_group_invite.c \
|
||||
chat/db_sync.c \
|
||||
routing_layer/routing.c \
|
||||
tun_if.c \
|
||||
@@ -189,6 +188,7 @@ utun_CORE_SOURCES = \
|
||||
chat/chat_devgroup_invite.c \
|
||||
chat/chat_devgroup.h \
|
||||
chat/group_avatar.c \
|
||||
chat/group_policy.c \
|
||||
chat/chat_status.c \
|
||||
chat/chat_sync.c \
|
||||
chat/chat_admin.c \
|
||||
@@ -204,6 +204,12 @@ utun_CORE_SOURCES = \
|
||||
chat/chat_headless_control.c \
|
||||
chat/chat_whisper.h \
|
||||
dm/dm_core.c \
|
||||
dm/dm_identity.c \
|
||||
dm/dm_identity.h \
|
||||
dm/dm_protocol.c \
|
||||
dm/dm_protocol.h \
|
||||
dm/dm_schema.c \
|
||||
dm/dm_schema.h \
|
||||
dm/dm_history.c \
|
||||
dm/dm_history.h \
|
||||
dm/dm_crypto.c \
|
||||
@@ -211,6 +217,8 @@ utun_CORE_SOURCES = \
|
||||
dm/dm_media.c \
|
||||
dm/dm_media.h \
|
||||
dm/dm_media_priv.h \
|
||||
dm/dm_media_receipt.c \
|
||||
dm/dm_media_receipt.h \
|
||||
dm/dm_mailbox_media.c \
|
||||
dm/dm_mailbox_media.h \
|
||||
call/call.c \
|
||||
@@ -265,7 +273,6 @@ libutun_a_SOURCES = \
|
||||
routing_layer/conn_mgr_monitor.c \
|
||||
routing_layer/topo_recovery.c \
|
||||
routing_layer/topo_group_connect.c \
|
||||
routing_layer/topo_group_invite.c \
|
||||
chat/db_sync.c \
|
||||
routing_layer/routing.c \
|
||||
tun_if.c \
|
||||
@@ -389,6 +396,7 @@ libutun_a_SOURCES = \
|
||||
chat/chat_devgroup_invite.c \
|
||||
chat/chat_devgroup.h \
|
||||
chat/group_avatar.c \
|
||||
chat/group_policy.c \
|
||||
chat/chat_status.c \
|
||||
chat/chat_sync.c \
|
||||
chat/chat_admin.c \
|
||||
@@ -405,6 +413,12 @@ libutun_a_SOURCES = \
|
||||
chat/chat_whisper.c \
|
||||
chat/chat_whisper.h \
|
||||
dm/dm_core.c \
|
||||
dm/dm_identity.c \
|
||||
dm/dm_identity.h \
|
||||
dm/dm_protocol.c \
|
||||
dm/dm_protocol.h \
|
||||
dm/dm_schema.c \
|
||||
dm/dm_schema.h \
|
||||
dm/dm_history.c \
|
||||
dm/dm_history.h \
|
||||
dm/dm_crypto.c \
|
||||
@@ -412,6 +426,8 @@ libutun_a_SOURCES = \
|
||||
dm/dm_media.c \
|
||||
dm/dm_media.h \
|
||||
dm/dm_media_priv.h \
|
||||
dm/dm_media_receipt.c \
|
||||
dm/dm_media_receipt.h \
|
||||
dm/dm_mailbox_media.c \
|
||||
dm/dm_mailbox_media.h \
|
||||
call/call.c \
|
||||
|
||||
@@ -81,6 +81,7 @@ void broadcast_destroy(struct TOPO_GROUP* group) {
|
||||
|
||||
int broadcast_send(struct TOPO_GROUP* group, const uint8_t* data, uint16_t data_len) {
|
||||
if (!group || !group->broadcast || !data || data_len > BROADCAST_MAX_DATA) return -1;
|
||||
if (!topo_group_data_allowed(group, NULL)) return -1;
|
||||
struct broadcast_ctx* ctx = group->broadcast;
|
||||
|
||||
uint8_t uuid[BROADCAST_UUID_SIZE];
|
||||
@@ -108,7 +109,7 @@ int broadcast_send(struct TOPO_GROUP* group, const uint8_t* data, uint16_t data_
|
||||
struct ll_entry* se = group->senders_list->head;
|
||||
while (se) {
|
||||
struct TOPO_GROUP_CONN_ITEM* item = (struct TOPO_GROUP_CONN_ITEM*)se->data;
|
||||
if (item && item->conn) {
|
||||
if (item && item->conn && topo_group_data_allowed(group, item->conn)) {
|
||||
uint16_t total = 1 + 8 + BROADCAST_UUID_SIZE + 2 + data_len;
|
||||
uint8_t* pkt = u_malloc(total);
|
||||
if (!pkt) { se = se->next; continue; }
|
||||
@@ -153,9 +154,13 @@ void broadcast_remove_cbk(struct TOPO_GROUP* group, broadcast_recv_fn fn, void*
|
||||
}
|
||||
}
|
||||
|
||||
static void broadcast_fire_cbks(struct broadcast_ctx* ctx, const uint8_t* uuid, const uint8_t* data, uint16_t data_len) {
|
||||
static void broadcast_fire_cbks(struct broadcast_ctx* ctx, struct ETCP_CONN* from_conn,
|
||||
const uint8_t* uuid, const uint8_t* data, uint16_t data_len) {
|
||||
struct broadcast_cbk_entry* c = ctx->cbks;
|
||||
while (c) { c->fn(uuid, data, data_len, c->arg); c = c->next; }
|
||||
while (c) {
|
||||
if (!topo_group_data_allowed(ctx->group, from_conn)) return;
|
||||
c->fn(uuid, data, data_len, c->arg); c = c->next;
|
||||
}
|
||||
}
|
||||
|
||||
static void broadcast_cleanup_timer_cb(void* arg) {
|
||||
@@ -189,6 +194,7 @@ static void broadcast_cleanup_timer_cb(void* arg) {
|
||||
|
||||
void broadcast_recv(struct TOPO_GROUP* group, struct ETCP_CONN* from_conn, const uint8_t* payload, size_t payload_len) {
|
||||
if (!group || !group->broadcast || !payload || payload_len < BROADCAST_UUID_SIZE + 2) return;
|
||||
if (!topo_group_data_allowed(group, from_conn)) return;
|
||||
struct broadcast_ctx* ctx = group->broadcast;
|
||||
|
||||
const uint8_t* uuid = payload;
|
||||
@@ -217,7 +223,7 @@ void broadcast_recv(struct TOPO_GROUP* group, struct ETCP_CONN* from_conn, const
|
||||
struct ll_entry* se = group->senders_list->head;
|
||||
while (se) {
|
||||
struct TOPO_GROUP_CONN_ITEM* item = (struct TOPO_GROUP_CONN_ITEM*)se->data;
|
||||
if (item && item->conn && item->conn != from_conn) {
|
||||
if (item && item->conn && item->conn != from_conn && topo_group_data_allowed(group, item->conn)) {
|
||||
uint16_t total = 1 + 8 + BROADCAST_UUID_SIZE + 2 + data_len;
|
||||
uint8_t* pkt = u_malloc(total);
|
||||
if (!pkt) { se = se->next; continue; }
|
||||
@@ -236,7 +242,7 @@ void broadcast_recv(struct TOPO_GROUP* group, struct ETCP_CONN* from_conn, const
|
||||
}
|
||||
}
|
||||
|
||||
broadcast_fire_cbks(ctx, uuid, data, data_len);
|
||||
broadcast_fire_cbks(ctx, from_conn, uuid, data, data_len);
|
||||
}
|
||||
|
||||
static void broadcast_recv_dispatcher(struct ETCP_CONN* from_conn, struct ll_entry* entry) {
|
||||
|
||||
@@ -39,7 +39,7 @@ int broadcast_init(struct TOPO_GROUP* group);
|
||||
// Отменяет таймер/standby-ожидание, освобождает кеш и регистрации, но не arg подписчиков; NULL/нет контекста допустимы.
|
||||
void broadcast_destroy(struct TOPO_GROUP* group);
|
||||
// data не NULL даже при data_len=0; data_len <= BROADCAST_MAX_DATA, группа должна быть инициализирована.
|
||||
// 0 — данные сохранены в кеше и рассылка предпринята; -1 — аргументы/отсутствие контекста/OOM записи кеша.
|
||||
// 0 — данные сохранены в кеше и рассылка предпринята; -1 — аргументы/контекст/отсутствие CHAT-допуска/OOM кеша.
|
||||
// Ошибки выделения/отправки отдельным соседям не меняют 0; собственные recv-callbacks не вызываются.
|
||||
int broadcast_send(struct TOPO_GROUP* group, const uint8_t* data, uint16_t data_len);
|
||||
// Добавляет fn(arg) в начало; дубликаты разрешены и вызываются отдельно. При NULL/нет контекста/OOM не добавляет.
|
||||
|
||||
@@ -161,7 +161,7 @@ static int call_send_invite(struct call_ctx* ctx, struct call_session* s) {
|
||||
int call_resolve_targets(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer, int devices,
|
||||
uint64_t* out, unsigned capacity) {
|
||||
struct TOPO_GROUP* g = inst && inst->topo_groups ? topo_groups_find(inst->topo_groups, group) : NULL;
|
||||
if (!g || g->group_type != TOPO_GROUP_TYPE_CHAT || !peer || !out || !capacity) {
|
||||
if (!g || !topo_group_chat_allowed(inst, group, peer) || !peer || !out || !capacity) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CALL, "call target invalid group=%llx peer=%llx", (unsigned long long)group, (unsigned long long)peer);
|
||||
return -1;
|
||||
}
|
||||
@@ -424,6 +424,11 @@ static void call_target_tick(void* arg) {
|
||||
for (unsigned i = 0; i < s->target_count; ++i) {
|
||||
struct call_target* t = &s->targets[i];
|
||||
if (!t->end_subcmd || t->ack) continue;
|
||||
if (!topo_group_chat_allowed(s->ctx->inst, s->group_id, t->peer)) {
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "call target retry cancelled after revoke id=%llx peer=%llx",
|
||||
(unsigned long long)s->call_id, (unsigned long long)t->peer);
|
||||
t->ack = 1; continue;
|
||||
}
|
||||
if (now - t->ended_tb >= CALL_END_TIMEOUT_TB) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CALL, "call target end timeout id=%llx peer=%llx",
|
||||
(unsigned long long)s->call_id, (unsigned long long)t->peer);
|
||||
@@ -516,6 +521,30 @@ static void call_end(struct call_ctx* ctx, struct call_session* s, uint8_t reaso
|
||||
call_session_remove_and_free(ctx, s);
|
||||
}
|
||||
|
||||
void call_revalidate_members(struct UTUN_INSTANCE* inst, uint64_t group) {
|
||||
struct call_ctx* ctx = call_of(inst);
|
||||
struct ll_entry* e = ctx && ctx->sessions ? ctx->sessions->head : NULL;
|
||||
while (e) {
|
||||
struct call_session* s = (struct call_session*)e->data;
|
||||
e = e->next;
|
||||
if (s->group_id != group) continue;
|
||||
int allowed = topo_group_chat_allowed(inst, group, s->peer_node_id);
|
||||
for (unsigned i = 0; i < s->target_count; ++i) {
|
||||
struct call_target* t = &s->targets[i];
|
||||
if (topo_group_chat_allowed(inst, group, t->peer)) continue;
|
||||
t->ack = 1;
|
||||
if (!s->selected && t->state != CALL_ST_ENDED) allowed = 0;
|
||||
}
|
||||
if (allowed) continue;
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "call access revoked id=%llx group=%llx peer=%llx state=%s; stopping media and retries",
|
||||
(unsigned long long)s->call_id, (unsigned long long)group,
|
||||
(unsigned long long)s->peer_node_id, call_state_name(s->state));
|
||||
if (s->cm_handle) { conn_mgr_close(s->cm_handle); s->cm_handle = NULL; }
|
||||
s->end_subcmd = 0;
|
||||
call_end(ctx, s, CALL_REASON_CONNECT_FAIL, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─────────────────── conn_mgr ─────────────────── */
|
||||
|
||||
static void call_traffic_timeout_cb(void* arg);
|
||||
@@ -1114,8 +1143,10 @@ int call_init(struct UTUN_INSTANCE* inst) {
|
||||
|
||||
ctx->initialized = 1;
|
||||
if (etcp_router_require_crypto(inst, ETCP_RT_ID_CALL, ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT)
|
||||
|| etcp_router_set_authorize(inst, ETCP_RT_ID_CALL, topo_group_chat_allowed)
|
||||
|| etcp_router_bind(inst, ETCP_RT_ID_CALL_AUDIO, call_etcp_recv)
|
||||
|| etcp_router_require_crypto(inst, ETCP_RT_ID_CALL_AUDIO, ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT)
|
||||
|| etcp_router_set_authorize(inst, ETCP_RT_ID_CALL_AUDIO, topo_group_chat_allowed)
|
||||
|| call_video_transport_bind(inst)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call media channel bind failed");
|
||||
call_destroy(inst); return -1;
|
||||
@@ -1165,8 +1196,9 @@ int call_busy(struct UTUN_INSTANCE* inst) {
|
||||
/* Только uasync: проверка доступности перед созданием сессии; очереди групп не межпоточные. */
|
||||
static int call_is_online(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t peer_node_id) {
|
||||
if (!inst || !ch_id || !ch_id[0] || !inst->topo_groups) return 0;
|
||||
if (peer_node_id == inst->node_id) return 1;
|
||||
uint64_t group_id = strtoull(ch_id, NULL, 10);
|
||||
if (!topo_group_chat_allowed(inst, group_id, peer_node_id)) return 0;
|
||||
if (peer_node_id == inst->node_id) return 1;
|
||||
struct TOPO_GROUP* g = topo_groups_find(inst->topo_groups, group_id);
|
||||
if (!g) return 0;
|
||||
return topo_node_find_by_id(g, peer_node_id) != NULL;
|
||||
@@ -1381,14 +1413,15 @@ static void call_start_target(struct UTUN_INSTANCE* inst, const char* ch_id, uin
|
||||
|
||||
uint64_t group_id = 0;
|
||||
char route_ch[64];
|
||||
if (devices && !strncmp(ch_id, "dm:", 3)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CALL, "device group call requires CHAT channel");
|
||||
call_event_id(ctx, CHAT_EVT_CALL_ERROR, out_call_id, 1); return;
|
||||
}
|
||||
if (!strncmp(ch_id, "dm:", 3)) {
|
||||
uint64_t peer = 0;
|
||||
struct dm_party parties[2];
|
||||
if (dm_conversation_access(inst, ch_id + 3, parties, NULL)) {
|
||||
call_event_id(ctx, CHAT_EVT_CALL_ERROR, out_call_id, 1); return;
|
||||
}
|
||||
devices = parties[1].kind == DM_PARTY_GROUP;
|
||||
int available = dm_call_route(inst, ch_id + 3, &peer, &group_id);
|
||||
if (!available || peer != peer_node_id) {
|
||||
if (!available || (!devices && peer != peer_node_id)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CALL, "PM call rejected conv=%s requested_peer=%llu actual_peer=%llu available=%d",
|
||||
ch_id + 3, (unsigned long long)peer_node_id, (unsigned long long)peer, available);
|
||||
uint8_t evt[9];
|
||||
@@ -1396,6 +1429,7 @@ static void call_start_target(struct UTUN_INSTANCE* inst, const char* ch_id, uin
|
||||
chat_event_post(inst, CHAT_EVT_CALL_ERROR, evt, sizeof(evt));
|
||||
return;
|
||||
}
|
||||
peer_node_id = peer;
|
||||
snprintf(route_ch, sizeof(route_ch), "%llu", (unsigned long long)group_id);
|
||||
ch_id = route_ch;
|
||||
} else group_id = strtoull(ch_id, NULL, 10);
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
// CHAT-группы или из DM (group_id — группа маршрутизации, как в dm_core).
|
||||
// Сигналинг, аудио и видео идут через etcp_router с обязательными ROUTE_CRYPTO_SIGN|ENCRYPT; соединение
|
||||
// оптимизируется через параллельные пробы conn_mgr (DIRECT/REVERSE/INDIRECT).
|
||||
// Оба узла должны оставаться допущенными участниками группы доставки. При отзыве доступа
|
||||
// или остановке группы звонок завершается, очереди медиа очищаются. Группа активного звонка
|
||||
// не меняется; новый звонок из DM заново выбирает доступную общую группу через dm_call_route.
|
||||
//
|
||||
// Все функции, кроме явно отмеченных, — в uasync-потоке. GUI/headless вызывают через trampoline
|
||||
// (gui_bridge_post_uasync_fn / uasync_post).
|
||||
@@ -34,6 +37,10 @@ int call_init(struct UTUN_INSTANCE* inst);
|
||||
* Регистрация call_audio_ops у ядра сохраняется, DSP и устройства frontend здесь не освобождаются. */
|
||||
void call_destroy(struct UTUN_INSTANCE* inst);
|
||||
|
||||
/* uasync: после изменения участников/остановки CHAT-группы завершить затронутые звонки
|
||||
* с CONNECT_FAIL, отменить их медиа/CM/повторы отозванным пирам. Остальные группы не затрагивает. */
|
||||
void call_revalidate_members(struct UTUN_INSTANCE* inst, uint64_t group);
|
||||
|
||||
// ── сигналинг (uasync-поток) ──
|
||||
|
||||
/* Caller: ch_id — группа либо "dm:<conv_id>" (BGP-группа выбирается при запуске).
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "../utun_instance.h"
|
||||
#include "../routing_layer/etcp_router.h"
|
||||
#include "../routing_layer/route_crypto.h"
|
||||
#include "../routing_layer/topo_group.h"
|
||||
#include "../transport_layer/etcp.h"
|
||||
#include "../../lib/mem.h"
|
||||
#include "../../lib/u_async.h"
|
||||
@@ -568,7 +569,9 @@ int call_video_transport_bind(struct UTUN_INSTANCE *inst) {
|
||||
etcp_router_unbind(inst, ETCP_RT_ID_CALL_VIDEO_CONTROL); return -1;
|
||||
}
|
||||
if (etcp_router_require_crypto(inst, ETCP_RT_ID_CALL_VIDEO_CONTROL, ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT)
|
||||
|| etcp_router_require_crypto(inst, ETCP_RT_ID_CALL_VIDEO, ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT)) {
|
||||
|| etcp_router_set_authorize(inst, ETCP_RT_ID_CALL_VIDEO_CONTROL, topo_group_chat_allowed)
|
||||
|| etcp_router_require_crypto(inst, ETCP_RT_ID_CALL_VIDEO, ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT)
|
||||
|| etcp_router_set_authorize(inst, ETCP_RT_ID_CALL_VIDEO, topo_group_chat_allowed)) {
|
||||
etcp_router_unbind(inst, ETCP_RT_ID_CALL_VIDEO); etcp_router_unbind(inst, ETCP_RT_ID_CALL_VIDEO_CONTROL); return -1;
|
||||
}
|
||||
etcp_router_set_receive_ready(inst, ETCP_RT_ID_CALL_VIDEO, ready); return 0;
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
#include "../utun_instance.h"
|
||||
#include "../routing_layer/topo_node_sqlite.h"
|
||||
#include "../routing_layer/topo_group.h"
|
||||
#include "../routing_layer/etcp_router.h"
|
||||
#include "../routing_layer/route_crypto.h"
|
||||
#include "../transport_layer/etcp_api.h"
|
||||
@@ -45,7 +46,10 @@ static int ed25519_pub_from_priv(const uint8_t priv[32], uint8_t pub[32]) {
|
||||
*/
|
||||
static void admin_key_recv_cb(struct ETCP_CONN* conn, struct ll_entry* entry) {
|
||||
if (!entry) return;
|
||||
if (!conn || !entry->dgram || entry->len < ROUTER_SVC_PAYLOAD_OFF + 8 + 32 + 32) {
|
||||
if (conn && entry->dgram && entry->len == ROUTER_SVC_PAYLOAD_OFF) {
|
||||
queue_dgram_free(entry); queue_entry_free(entry); return; // У сервиса нет собственного состояния соединения.
|
||||
}
|
||||
if (!conn || !entry->dgram || entry->len != ROUTER_SVC_PAYLOAD_OFF + 8 + 32 + 32) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "%s: recv — invalid delivery len=%u", CA_ID, entry->len);
|
||||
queue_dgram_free(entry); queue_entry_free(entry); return;
|
||||
}
|
||||
@@ -70,6 +74,12 @@ static void admin_key_recv_cb(struct ETCP_CONN* conn, struct ll_entry* entry) {
|
||||
|
||||
const uint8_t* payload = d + ROUTER_SVC_PAYLOAD_OFF;
|
||||
uint64_t group_id; memcpy(&group_id, payload, 8);
|
||||
uint64_t authorized_group; memcpy(&authorized_group, d + ROUTER_SVC_GROUP_OFF, 8);
|
||||
if (group_id != authorized_group) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "%s: channel mismatch route=%llu payload=%llu peer=%016llx", CA_ID,
|
||||
(unsigned long long)authorized_group, (unsigned long long)group_id, (unsigned long long)from_node);
|
||||
queue_dgram_free(entry); queue_entry_free(entry); return;
|
||||
}
|
||||
const uint8_t* ed_priv = payload + 8;
|
||||
const uint8_t* x_priv = payload + 8 + 32;
|
||||
|
||||
@@ -106,6 +116,12 @@ static void admin_key_recv_cb(struct ETCP_CONN* conn, struct ll_entry* entry) {
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: received admin key ch=%s from 0x%016llx — imported, now co-owner",
|
||||
CA_ID, ch_id, (unsigned long long)from_node);
|
||||
|
||||
/* Другие узлы проверяют право приглашать по подписанной роли, а не по наличию нашего приватного ключа. */
|
||||
struct chat_member_tags* tags = chat_member_tags_load(inst, ch_id, inst->node_id);
|
||||
if (!tags || chat_member_tags_set(tags, "admin", "yes") || chat_member_tags_commit(inst, tags))
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: co-owner key imported, publishing admin role failed ch=%s", CA_ID, ch_id);
|
||||
chat_member_tags_free(tags);
|
||||
|
||||
uint8_t evt[72];
|
||||
uint8_t cl = (uint8_t)strlen(ch_id);
|
||||
evt[0] = cl; memcpy(evt + 1, ch_id, cl);
|
||||
@@ -123,6 +139,11 @@ int chat_admin_init(struct UTUN_INSTANCE* inst) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: etcp_router_bind failed", CA_ID);
|
||||
return -1;
|
||||
}
|
||||
if (etcp_router_require_crypto(inst, ETCP_RT_ID_ADMIN_KEY, ROUTER_FLAG_SIGNED | ROUTER_FLAG_ENCRYPTED) ||
|
||||
etcp_router_set_authorize(inst, ETCP_RT_ID_ADMIN_KEY, topo_group_chat_allowed)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: router access policy initialization failed", CA_ID);
|
||||
etcp_router_unbind(inst, ETCP_RT_ID_ADMIN_KEY); return -1;
|
||||
}
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: initialized", CA_ID);
|
||||
return 0;
|
||||
}
|
||||
@@ -147,18 +168,17 @@ void chat_core_transfer_admin(struct UTUN_INSTANCE* inst, const char* ch_id, uin
|
||||
return;
|
||||
}
|
||||
|
||||
uint64_t group_id = strtoull(ch_id, NULL, 10);
|
||||
if (!group_id || !topo_group_chat_allowed(inst, group_id, target_node_id)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "%s: transfer denied ch=%s target=%016llx", CA_ID,
|
||||
ch_id, (unsigned long long)target_node_id); return;
|
||||
}
|
||||
uint8_t x_priv[32], ed_priv[32];
|
||||
if (topo_node_sqlite_channel_get_privs(cc->db, ch_id, x_priv, ed_priv) != 0) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: transfer — no channel privkey ch=%s (not owner?)", CA_ID, ch_id);
|
||||
return;
|
||||
}
|
||||
|
||||
uint64_t group_id = strtoull(ch_id, NULL, 10);
|
||||
if (group_id == 0) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: transfer — invalid ch_id=%s", CA_ID, ch_id);
|
||||
return;
|
||||
}
|
||||
|
||||
uint8_t payload[8 + 32 + 32];
|
||||
memcpy(payload, &group_id, 8);
|
||||
memcpy(payload + 8, ed_priv, 32);
|
||||
@@ -177,7 +197,6 @@ void chat_core_transfer_admin(struct UTUN_INSTANCE* inst, const char* ch_id, uin
|
||||
if (rc != 0) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: transfer — etcp_route_send FAILED rc=%d ch=%s → 0x%016llx",
|
||||
CA_ID, rc, ch_id, (unsigned long long)target_node_id);
|
||||
queue_dgram_free(entry); queue_entry_free(entry);
|
||||
return;
|
||||
}
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: admin key sent ch=%s → 0x%016llx (encrypted+signed)",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include "../utun_instance.h"
|
||||
#include "../transport_layer/etcp.h"
|
||||
#include "../routing_layer/etcp_router.h"
|
||||
#include "../routing_layer/topo_group.h"
|
||||
#include "../routing_layer/topo_node_sqlite.h"
|
||||
#include "../media_delivery/media_index.h"
|
||||
#include "../media_async/media_async.h"
|
||||
@@ -148,6 +149,88 @@ static void ak_file_work(void* arg) {
|
||||
|
||||
static void ak_rollback(struct UTUN_INSTANCE* inst, const char* operation, const char* channel);
|
||||
|
||||
/* Отменить работу пары одним commit, сохранив полученные ключи и завершённые решения. */
|
||||
static int ak_cancel_peer(struct UTUN_INSTANCE* inst, const char* channel, uint64_t peer) {
|
||||
sqlite3* db = CC(inst)->db; sqlite3_stmt* st = NULL;
|
||||
const char* stage = "begin revoke";
|
||||
int rc = sqlite3_exec(db, "BEGIN IMMEDIATE /* attachment revoke */", NULL, NULL, NULL);
|
||||
if (rc != SQLITE_OK) goto fail;
|
||||
const char* queries[] = {
|
||||
"UPDATE attachment_keys SET wanted=0 WHERE channel=?1 AND author=?2 AND key IS NULL AND wanted<>0",
|
||||
"UPDATE attachment_key_requests SET state=5 WHERE channel=?1 AND peer=?2"
|
||||
" AND ((incoming=1 AND state IN(0,2)) OR (incoming=0 AND state IN(0,1)))",
|
||||
"DELETE FROM attachment_key_outbox WHERE channel=?1 AND peer=?2",
|
||||
"SELECT 1 FROM attachment_keys WHERE channel=?1 AND author=?2 AND key IS NULL AND wanted<>0"
|
||||
" UNION ALL SELECT 1 FROM attachment_key_requests WHERE channel=?1 AND peer=?2"
|
||||
" AND ((incoming=1 AND state IN(0,2)) OR (incoming=0 AND state IN(0,1)))"
|
||||
" UNION ALL SELECT 1 FROM attachment_key_outbox WHERE channel=?1 AND peer=?2 LIMIT 1"
|
||||
};
|
||||
int changed[3] = {0};
|
||||
for (unsigned i = 0; i < 4; i++) {
|
||||
stage = queries[i]; rc = sqlite3_prepare_v2(db, queries[i], -1, &st, NULL);
|
||||
if (rc != SQLITE_OK) goto fail;
|
||||
rc = sqlite3_bind_text(st, 1, channel, -1, SQLITE_STATIC);
|
||||
if (rc != SQLITE_OK) goto fail;
|
||||
rc = sqlite3_bind_int64(st, 2, (sqlite3_int64)peer);
|
||||
if (rc != SQLITE_OK) goto fail;
|
||||
rc = sqlite3_step(st);
|
||||
if (rc != SQLITE_DONE) goto fail; /* Последний SELECT обязан быть пуст: silent IGNORE также откатывает отмену. */
|
||||
if (i < 3) changed[i] = sqlite3_changes(db);
|
||||
rc = sqlite3_finalize(st); st = NULL;
|
||||
if (rc != SQLITE_OK) goto fail;
|
||||
}
|
||||
stage = "commit revoke"; rc = sqlite3_exec(db, "COMMIT /* attachment revoke */", NULL, NULL, NULL);
|
||||
if (rc != SQLITE_OK) goto fail;
|
||||
if (changed[0] || changed[1] || changed[2]) {
|
||||
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "attachment keys: revoked channel=%s peer=%016llx wanted=%d requests=%d outbox=%d",
|
||||
channel, (unsigned long long)peer, changed[0], changed[1], changed[2]);
|
||||
ak_changed(inst, channel);
|
||||
}
|
||||
return 0;
|
||||
fail:
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: revoke channel=%s peer=%016llx stage=%s rc=%d db=%s",
|
||||
channel, (unsigned long long)peer, stage, rc, sqlite3_errmsg(db));
|
||||
int final_rc = sqlite3_finalize(st);
|
||||
if (final_rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: revoke cleanup finalize rc=%d", final_rc);
|
||||
ak_rollback(inst, "revoke", channel); return -1;
|
||||
}
|
||||
|
||||
/* Снимок пар закрывается до изменений БД; повторный проход безопасен после SQL-отказа или перезапуска. */
|
||||
int chat_attachment_revalidate_members(struct UTUN_INSTANCE* inst, uint64_t group) {
|
||||
if (!inst || !CC(inst) || !CC(inst)->attachment_initialized || CC(inst)->attachment_closing) return 0;
|
||||
sqlite3* db = CC(inst)->db;
|
||||
if (!sqlite3_get_autocommit(db)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: revoke postponed inside external transaction group=%016llx",
|
||||
(unsigned long long)group); return -1;
|
||||
}
|
||||
char filter[64] = "1", sql[768];
|
||||
if (group) snprintf(filter, sizeof(filter), "channel='%llu'", (unsigned long long)group);
|
||||
snprintf(sql, sizeof(sql), "SELECT channel,peer FROM ("
|
||||
"SELECT channel,peer FROM attachment_key_requests WHERE (incoming=1 AND state IN(0,2)) OR (incoming=0 AND state IN(0,1))"
|
||||
" UNION SELECT channel,peer FROM attachment_key_outbox"
|
||||
" UNION SELECT channel,author AS peer FROM attachment_keys WHERE wanted<>0 AND key IS NULL) WHERE %s", filter);
|
||||
char** pairs = NULL; int rows = 0, columns = 0;
|
||||
int rc = sqlite3_get_table(db, sql, &pairs, &rows, &columns, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: revoke scan failed group=%016llx rc=%d db=%s",
|
||||
(unsigned long long)group, rc, sqlite3_errmsg(db)); sqlite3_free_table(pairs); return -1;
|
||||
}
|
||||
int result = 0;
|
||||
for (int i = 0; i < rows; i++) {
|
||||
const char* channel = pairs[(i + 1) * columns];
|
||||
const char* identity = pairs[(i + 1) * columns + 1];
|
||||
if (!ak_channel(channel) || !identity) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: invalid revoke identity row=%d", i); result = -1; break;
|
||||
}
|
||||
uint64_t peer = (uint64_t)strtoll(identity, NULL, 10);
|
||||
if (!topo_group_chat_allowed(inst, strtoull(channel, NULL, 10), peer) && ak_cancel_peer(inst, channel, peer)) {
|
||||
result = -1; break;
|
||||
}
|
||||
if (CC(inst)->attachment_closing) { result = -1; break; }
|
||||
}
|
||||
sqlite3_free_table(pairs); return result;
|
||||
}
|
||||
|
||||
/* 1 — строка существует, 0 — канал/вложение удалены, -1 — SQL-отказ, который нельзя считать удалением. */
|
||||
static int ak_present(struct ak_file* job) {
|
||||
sqlite3* db = CC(job->inst)->db; sqlite3_stmt* st = NULL;
|
||||
@@ -238,6 +321,9 @@ static void ak_finish_file(struct ak_file* job) {
|
||||
struct UTUN_INSTANCE* inst = job->inst;
|
||||
int valid = CC(inst)->attachment_closing ? 0 : ak_store_file_result(job);
|
||||
if (valid < 0) return;
|
||||
if (valid && job->encrypt && !topo_group_chat_allowed(inst, strtoull(job->channel, NULL, 10), inst->node_id)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: publication revoked channel=%s", job->channel); valid = 0;
|
||||
}
|
||||
int success = !job->completion_error && !job->error && valid;
|
||||
if (success && job->encrypt) {
|
||||
char name[38], hex[33]; ak_hex(job->id, hex); snprintf(name, sizeof(name), "%s.uenc", hex);
|
||||
@@ -355,6 +441,9 @@ int chat_attachment_send(struct UTUN_INSTANCE* inst, const char* channel, const
|
||||
!source || !source[0] || strlen(source) >= 1024 || !reply || encrypted_file_validate_type(type) || attachment_validate(info)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: invalid send arguments/state"); return -1;
|
||||
}
|
||||
if (!topo_group_chat_allowed(inst, strtoull(channel, NULL, 10), inst->node_id)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: preparation denied channel=%s", channel); return -1;
|
||||
}
|
||||
if (CC(inst)->attachment_file_count >= MEDIA_ASYNC_MAX_TASKS) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: file task limit reached count=%u", CC(inst)->attachment_file_count); return -1;
|
||||
}
|
||||
@@ -559,6 +648,9 @@ fail:
|
||||
/* Неподписанные и незашифрованные ответы сервис не принимает. */
|
||||
static int ak_send(struct UTUN_INSTANCE* inst, const char* channel, uint64_t peer, const uint8_t* body, size_t length) {
|
||||
uint64_t group = strtoull(channel, NULL, 10);
|
||||
if (!topo_group_chat_allowed(inst, group, peer)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: send denied channel=%s peer=%016llx", channel, (unsigned long long)peer); return -1;
|
||||
}
|
||||
if (!etcp_router_has_route(inst, group, peer)) return 1;
|
||||
struct ETCP_ROUTER_CONN* conn = etcp_router_conn_get(inst, group, peer, ETCP_RT_ID_ATTACHMENT_KEYS);
|
||||
if (!conn) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: router channel allocation failed"); return -1; }
|
||||
@@ -605,6 +697,10 @@ fail:
|
||||
/* Разрешение проверяет полное подписанное членство и immutable историю. */
|
||||
static int ak_authorized(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message[32], uint64_t peer) {
|
||||
uint8_t key[32];
|
||||
if (!topo_group_chat_allowed(inst, strtoull(channel, NULL, 10), peer)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: author access denied channel=%s peer=%016llx",
|
||||
channel, (unsigned long long)peer); return 0;
|
||||
}
|
||||
if (member_sync_verified_ed25519(inst, channel, peer, key)) return 0;
|
||||
char sql[256]; snprintf(sql, sizeof(sql), "SELECT 1 FROM msg_%s m JOIN attachment_keys a ON a.message_id=m.message_id"
|
||||
" AND a.channel=? WHERE m.message_id=? AND m.node_id=? AND a.author=? AND length(a.key)=32", channel);
|
||||
@@ -672,15 +768,16 @@ static int ak_accept_request(struct UTUN_INSTANCE* inst, const char* channel, ui
|
||||
int state = exists ? sqlite3_column_int(st, 1) : 0;
|
||||
stage = "read finalize"; rc = sqlite3_finalize(st); st = NULL;
|
||||
if (rc != SQLITE_OK) goto fail;
|
||||
if (incoming != 1 || (state != 0 && state != 2 && state != 3 && state != 4)) {
|
||||
if (incoming != 1 || (state != 0 && state != 2 && state != 3 && state != 4 && state != CHAT_ATTACHMENT_REQUEST_CANCELLED)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: request conflict channel=%s peer=%llu incoming=%d state=%d",
|
||||
channel, (unsigned long long)peer, incoming, state);
|
||||
stage = "request state"; rc = SQLITE_CONSTRAINT; goto fail;
|
||||
}
|
||||
if (!exists) {
|
||||
if (!exists || state == CHAT_ATTACHMENT_REQUEST_CANCELLED) {
|
||||
stage = "insert prepare";
|
||||
rc = sqlite3_prepare_v2(db, "INSERT INTO attachment_key_requests(channel,message_id,peer,incoming,state,created_at)"
|
||||
" VALUES(?,?,?,1,0,unixepoch())", -1, &st, NULL);
|
||||
" VALUES(?,?,?,1,0,unixepoch()) ON CONFLICT(channel,message_id,peer) DO UPDATE SET state=0,created_at=unixepoch()"
|
||||
" WHERE incoming=1 AND state=5", -1, &st, NULL);
|
||||
if (rc != SQLITE_OK) goto fail;
|
||||
stage = "insert bind channel"; rc = sqlite3_bind_text(st, 1, channel, -1, SQLITE_STATIC);
|
||||
if (rc != SQLITE_OK) goto fail;
|
||||
@@ -698,6 +795,7 @@ static int ak_accept_request(struct UTUN_INSTANCE* inst, const char* channel, ui
|
||||
changed, channel, (unsigned long long)peer);
|
||||
stage = "insert row count"; rc = SQLITE_CONSTRAINT; goto fail;
|
||||
}
|
||||
state = 0;
|
||||
}
|
||||
int command = state == 0 ? 0 : state == 3 ? AK_DENY : AK_GRANT;
|
||||
stage = "outbox";
|
||||
@@ -843,11 +941,17 @@ static void ak_receive(struct ETCP_CONN* conn, struct ll_entry* entry) {
|
||||
uint8_t* d = entry->dgram;
|
||||
uint64_t peer = 0, group = 0, target = 0;
|
||||
char channel[32];
|
||||
if (entry->len == ROUTER_SVC_HDR_SIZE) {
|
||||
memcpy(&group, d + ROUTER_SVC_GROUP_OFF, 8);
|
||||
queue_dgram_free(entry); queue_entry_free(entry);
|
||||
chat_attachment_revalidate_members(inst, group); return;
|
||||
}
|
||||
if (!CC(inst) || CC(inst)->attachment_closing || entry->len < ROUTER_SVC_PAYLOAD_OFF + AK_BODY) goto invalid;
|
||||
memcpy(&peer, d + ROUTER_SVC_SRC_OFF, 8); memcpy(&group, d + ROUTER_SVC_GROUP_OFF, 8); memcpy(&target, d + ROUTER_SVC_DST_OFF, 8);
|
||||
if (target != inst->node_id || !peer || peer == inst->node_id ||
|
||||
(d[ROUTER_SVC_FLAGS_OFF] & (ROUTER_FLAG_SIGNED | ROUTER_FLAG_ENCRYPTED)) != (ROUTER_FLAG_SIGNED | ROUTER_FLAG_ENCRYPTED)) goto invalid;
|
||||
snprintf(channel, sizeof(channel), "%llu", (unsigned long long)group);
|
||||
if (!topo_group_chat_allowed(inst, group, peer)) goto invalid;
|
||||
uint8_t* body = d + ROUTER_SVC_PAYLOAD_OFF; size_t bytes = entry->len - ROUTER_SVC_PAYLOAD_OFF;
|
||||
uint8_t cmd = body[4], expected[AK_WIRE];
|
||||
if (!ak_channel(channel) || memcmp(body, "AK01", 4) || cmd < AK_REQUEST || cmd > AK_RESULT_ACK ||
|
||||
@@ -1005,9 +1109,11 @@ int chat_attachment_downloaded(struct UTUN_INSTANCE* inst, const char* channel,
|
||||
const char* stage = "begin downloaded";
|
||||
int rc = sqlite3_exec(db, "BEGIN IMMEDIATE /* attachment downloaded */", NULL, NULL, NULL);
|
||||
if (rc != SQLITE_OK) goto fail;
|
||||
char sql[512]; snprintf(sql, sizeof(sql), "SELECT downloaded,wanted,error,NOT EXISTS(SELECT 1 FROM peers_%s"
|
||||
" WHERE node_id=? AND source=0 AND deleted=0 AND length(adm_tags_sig)=64 AND json_extract(adm_tags,'$.storage')='yes')"
|
||||
" FROM attachment_keys WHERE channel=? AND message_id=?", channel);
|
||||
char sql[768]; snprintf(sql, sizeof(sql), "SELECT downloaded,wanted,error,NOT EXISTS(SELECT 1 FROM peers_%s"
|
||||
" WHERE node_id=? AND record_state=0 AND source=0 AND deleted=0 AND admitted=1"
|
||||
" AND length(adm_tags_sig)=64 AND json_extract(adm_tags,'$.storage')='yes')"
|
||||
" AND NOT EXISTS(SELECT 1 FROM attachment_key_requests r WHERE r.channel=a.channel AND r.message_id=a.message_id"
|
||||
" AND r.incoming=0 AND r.state=5) FROM attachment_keys a WHERE channel=? AND message_id=?", channel);
|
||||
stage = "prepare download state"; rc = sqlite3_prepare_v2(db, sql, -1, &st, NULL);
|
||||
if (rc != SQLITE_OK) goto fail;
|
||||
stage = "bind node"; rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)inst->node_id);
|
||||
@@ -1143,6 +1249,10 @@ static void ak_pump(struct UTUN_INSTANCE* inst) {
|
||||
/* Создать исходящий запрос вместе с очередью; перечитать условия под собственной блокировкой записи. */
|
||||
static int ak_request_file(struct ak_file* job) {
|
||||
struct UTUN_INSTANCE* inst = job->inst;
|
||||
if (!topo_group_chat_allowed(inst, strtoull(job->channel, NULL, 10), job->author)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: request denied channel=%s author=%016llx",
|
||||
job->channel, (unsigned long long)job->author); return -1;
|
||||
}
|
||||
sqlite3* db = CC(inst)->db; sqlite3_stmt* st = NULL;
|
||||
if (!sqlite3_get_autocommit(db)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: outgoing request rejected inside external transaction channel=%s", job->channel);
|
||||
@@ -1154,7 +1264,7 @@ static int ak_request_file(struct ak_file* job) {
|
||||
stage = "prepare eligibility";
|
||||
rc = sqlite3_prepare_v2(db, "SELECT 1 FROM attachment_keys a WHERE channel=? AND message_id=? AND author=?"
|
||||
" AND downloaded=1 AND wanted=1 AND ready=0 AND decrypting=0 AND error=0 AND key IS NULL"
|
||||
" AND NOT EXISTS(SELECT 1 FROM attachment_key_requests r WHERE r.channel=a.channel AND r.message_id=a.message_id)", -1, &st, NULL);
|
||||
" AND NOT EXISTS(SELECT 1 FROM attachment_key_requests r WHERE r.channel=a.channel AND r.message_id=a.message_id AND r.state<>5)", -1, &st, NULL);
|
||||
if (rc != SQLITE_OK) goto fail;
|
||||
stage = "bind channel"; rc = sqlite3_bind_text(st, 1, job->channel, -1, SQLITE_STATIC);
|
||||
if (rc != SQLITE_OK) goto fail;
|
||||
@@ -1168,7 +1278,8 @@ static int ak_request_file(struct ak_file* job) {
|
||||
if (rc != SQLITE_OK) goto fail;
|
||||
stage = "prepare request";
|
||||
rc = sqlite3_prepare_v2(db, "INSERT INTO attachment_key_requests(channel,message_id,peer,incoming,state,created_at)"
|
||||
" VALUES(?,?,?,0,0,unixepoch())", -1, &st, NULL);
|
||||
" VALUES(?,?,?,0,0,unixepoch()) ON CONFLICT(channel,message_id,peer) DO UPDATE SET state=0,created_at=unixepoch()"
|
||||
" WHERE incoming=0 AND state=5", -1, &st, NULL);
|
||||
if (rc != SQLITE_OK) goto fail;
|
||||
stage = "bind channel"; rc = sqlite3_bind_text(st, 1, job->channel, -1, SQLITE_STATIC);
|
||||
if (rc != SQLITE_OK) goto fail;
|
||||
@@ -1253,6 +1364,7 @@ static void ak_tick(void* arg) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: pending files/outbox postponed inside external transaction");
|
||||
goto schedule;
|
||||
}
|
||||
if (chat_attachment_revalidate_members(inst, 0)) goto schedule;
|
||||
for (struct ak_file* job = CC(inst)->attachment_files; job;) {
|
||||
struct ak_file* next = job->next;
|
||||
if (job->completed) ak_finish_file(job);
|
||||
@@ -1263,7 +1375,8 @@ static void ak_tick(void* arg) {
|
||||
if (CC(inst)->attachment_file_count >= MEDIA_ASYNC_MAX_TASKS) break;
|
||||
sqlite3_stmt* st = ak_prepare(inst, "SELECT channel,message_id,crypto_id,author,key,cipher_hash,cipher_path,plain_path FROM attachment_keys a"
|
||||
" WHERE downloaded=1 AND wanted=1 AND ready=0 AND decrypting=0 AND error=0 AND"
|
||||
" (length(key)=32 OR NOT EXISTS(SELECT 1 FROM attachment_key_requests r WHERE r.channel=a.channel AND r.message_id=a.message_id)) LIMIT 1");
|
||||
" (length(key)=32 OR NOT EXISTS(SELECT 1 FROM attachment_key_requests r"
|
||||
" WHERE r.channel=a.channel AND r.message_id=a.message_id AND r.state<>5)) LIMIT 1");
|
||||
if (!st) break;
|
||||
int rc = sqlite3_step(st);
|
||||
if (rc != SQLITE_ROW) {
|
||||
@@ -1434,9 +1547,16 @@ int chat_attachment_init(struct UTUN_INSTANCE* inst) {
|
||||
CC(inst)->attachment_closing = 0;
|
||||
CC(inst)->attachment_initialized = 1;
|
||||
ak_recover_preparations(inst);
|
||||
if (etcp_router_bind(inst, ETCP_RT_ID_ATTACHMENT_KEYS, ak_receive)) return -1;
|
||||
if (etcp_router_bind(inst, ETCP_RT_ID_ATTACHMENT_KEYS, ak_receive) ||
|
||||
etcp_router_require_crypto(inst, ETCP_RT_ID_ATTACHMENT_KEYS, ROUTER_FLAG_SIGNED | ROUTER_FLAG_ENCRYPTED) ||
|
||||
etcp_router_set_authorize(inst, ETCP_RT_ID_ATTACHMENT_KEYS, topo_group_chat_allowed)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: router policy initialization failed");
|
||||
chat_attachment_quiesce(inst); return -1;
|
||||
}
|
||||
CC(inst)->attachment_timer = uasync_set_timeout(inst->ua, 10000, inst, ak_tick, "attachment keys");
|
||||
if (!CC(inst)->attachment_timer) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: initial timer allocation failed"); return -1; }
|
||||
if (!CC(inst)->attachment_timer) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: initial timer allocation failed"); chat_attachment_quiesce(inst); return -1;
|
||||
}
|
||||
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "attachment keys: initialized persistent requests and separate ciphertext/plaintext storage"); return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
* повтор с тем же результатом допустим, конфликтующий ключ/решение отклоняется. Отказ SQL сохраняет прежнее состояние.
|
||||
* Приём REQUEST владеет собственной транзакцией проверки идентичности/прав и сохранения запроса/повтора ответа.
|
||||
* Внешняя транзакция отклоняется без изменений; ACK/событие — после commit. Дубликат сохраняет решение и дату:
|
||||
* state=0 ждёт автора, 2/4 повторяют GRANT, 3 повторяет DENY; чужое направление и другие состояния отклоняются.
|
||||
* state=0 ждёт автора, 2/4 повторяют GRANT, 3 повторяет DENY; 5 — отменён при отзыве, новый REQUEST снова требует решения.
|
||||
* Отзыв self/peer прекращает outbox и ожидающие запросы; полученные ключи и локальная расшифровка сохраняются.
|
||||
* После readmission исходящий запрос запускается новым want; отменённый запрос не принимает поздний результат.
|
||||
* Исходящий REQUEST сохраняется собственной транзакцией request+outbox после повторной проверки состояния.
|
||||
* Уведомление/отправка следуют после commit; внешняя транзакция откладывает весь проход файлов и очереди.
|
||||
* Перед сетевой попыткой отдельно фиксируется retry_at; отказ SQL не отправляет команду и сохраняет очередь/дату.
|
||||
@@ -26,6 +28,8 @@ extern "C" {
|
||||
#endif
|
||||
struct UTUN_INSTANCE;
|
||||
|
||||
#define CHAT_ATTACHMENT_REQUEST_CANCELLED 5 /* Сохранённый запрос отменён; ключ/решение по завершённому запросу не затрагивается. */
|
||||
|
||||
// ── Жизненный цикл модуля ──
|
||||
|
||||
// Подключить таблицы, router-сервис и обработку сохранённых задач; 0/-1. Внешняя транзакция запрещена.
|
||||
@@ -37,6 +41,10 @@ void chat_attachment_quiesce(struct UTUN_INSTANCE* inst);
|
||||
// После workers убрать незавершённые подготовки; открытая транзакция откладывает recovery, сохраняя файлы.
|
||||
void chat_attachment_destroy(struct UTUN_INSTANCE* inst);
|
||||
|
||||
/* После commit допуска: атомарно отменить запрещённую сетевую работу, сохранить локальные ключи/историю.
|
||||
* group=0 проверяет все сохранённые пары (tick/restart); иначе только указанную группу. 0/-1; SQL-отказ допускает повтор. */
|
||||
int chat_attachment_revalidate_members(struct UTUN_INSTANCE* inst, uint64_t group);
|
||||
|
||||
// ── Отправка и учёт вложений ──
|
||||
|
||||
/* Асинхронно отправить подготовленный файл; channel — десятичный ID, reply — 32 байта ID ответа либо нули.
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "chat_core_priv.h"
|
||||
#include "chat_attachment.h"
|
||||
#include "group_avatar.h"
|
||||
#include "group_policy.h"
|
||||
#include "chat_event.h"
|
||||
#include "chat_feedback.h"
|
||||
#include "chat_setting.h"
|
||||
@@ -130,7 +131,7 @@ int chat_core_create_channel(struct UTUN_INSTANCE* inst, const struct chat_chann
|
||||
bytes = member_sync_build_join_msg(req->x25519_pubkey, req->ed25519_pubkey, inst->node_id,
|
||||
inst->my_keys.public_key, timestamp, body, sizeof(body));
|
||||
if (bytes < 0 || sc_ed25519_sign(inst->my_ed25519_privkey, body, (size_t)bytes, join) != SC_OK ||
|
||||
member_sync_sign_pubkey(req->ed25519_privkey, inst->my_keys.public_key, root) < 0) {
|
||||
member_sync_sign_join(req->ed25519_privkey, join, root) < 0) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel create: root JOIN/invitation signing failed ch=%s", req->channel_id); return -1;
|
||||
}
|
||||
if (chat_core_name_userinfo(inst->name, profile, sizeof(profile))) return -1;
|
||||
@@ -168,6 +169,10 @@ int chat_core_create_channel(struct UTUN_INSTANCE* inst, const struct chat_chann
|
||||
stage = "commit"; rc = sqlite3_exec(cc->db, "COMMIT /* channel creation */", NULL, NULL, NULL);
|
||||
if (rc != SQLITE_OK) goto error;
|
||||
|
||||
if (group_policy_refresh(inst, req->channel_id)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel created but admission refresh failed ch=%s", req->channel_id); return -1;
|
||||
}
|
||||
|
||||
int ready = !chat_core_ensure_channel_ready(inst, req->channel_id);
|
||||
if (!ready) DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "channel create: committed ch=%s, runtime activation deferred; retry preparation", req->channel_id);
|
||||
member_sync_record_committed(inst, req->channel_id, inst->node_id, (unsigned)changes);
|
||||
@@ -367,44 +372,47 @@ void chat_core_delete_channel(struct UTUN_INSTANCE* inst, const char* ch_id) {
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: delete_channel BEGIN ch=%s", CC_ID, ch_id);
|
||||
uint64_t gid = strtoull(ch_id, NULL, 10);
|
||||
|
||||
/* 1. отмена медиа-загрузок/релэев/суперузлов канала (держат conn_mgr handle группы) */
|
||||
media_delivery_cancel_channel(inst, gid);
|
||||
member_files_cancel_group(inst, gid);
|
||||
group_avatar_cancel_group(inst, gid);
|
||||
|
||||
/* 2. снять merkle-сессии этого namespace */
|
||||
member_sync_cancel_channel(inst, ch_id);
|
||||
|
||||
/* 3. удалить группу (conn_mgr → закрытие соединений, connect-цикл, broadcast, recovery) */
|
||||
if (inst->topo_groups)
|
||||
topo_groups_remove_group(inst->topo_groups, gid);
|
||||
|
||||
/* 4. удалить db_sync инстанс (освобождает память, отменяет таймеры) */
|
||||
{
|
||||
struct DB_SYNC_INSTANCE* si = si_find(inst, ch_id);
|
||||
if (si) db_sync_instance_remove(si);
|
||||
}
|
||||
|
||||
/* 5. немедленно убрать канал из кеша chat_sync */
|
||||
chat_sync_on_channel_deleted(inst, ch_id);
|
||||
|
||||
/* 6. удалить таблицы, метаданные и attachment-записи одной SQL-транзакцией до файловой очистки */
|
||||
/* До commit не трогаем работающую группу и durable очереди: SQL-отказ сохраняет канал целиком. */
|
||||
if (topo_node_sqlite_channel_delete(cc->db, ch_id)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel metadata deletion failed ch=%s; files preserved, retry required", ch_id);
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel metadata deletion failed ch=%s; group, requests and files preserved", ch_id);
|
||||
if (!sqlite3_get_autocommit(cc->db)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel delete left transaction open ch=%s; requesting owner stop", ch_id);
|
||||
chat_attachment_quiesce(inst); utun_instance_stop(inst);
|
||||
}
|
||||
return;
|
||||
}
|
||||
struct TOPO_GROUP* group = topo_groups_find(inst->topo_groups, gid);
|
||||
if (group) group->stopping = 1; /* Cleanup callbacks уже не могут начать новую работу или читать удалённые peers. */
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "channel deletion committed ch=%s; cancelling runtime work", ch_id);
|
||||
|
||||
/* 7. удалить файлы только после commit; I/O-отказ не восстанавливает удалённый канал */
|
||||
/* Отмена медиа-загрузок/релэев/суперузлов канала (держат conn_mgr handle группы). */
|
||||
media_delivery_cancel_channel(inst, gid);
|
||||
member_files_cancel_group(inst, gid);
|
||||
group_avatar_cancel_group(inst, gid);
|
||||
|
||||
/* Снять merkle-сессии этого namespace. */
|
||||
member_sync_cancel_channel(inst, ch_id);
|
||||
|
||||
/* Удалить группу (conn_mgr, connect-цикл, broadcast, recovery). */
|
||||
if (inst->topo_groups)
|
||||
topo_groups_remove_group(inst->topo_groups, gid);
|
||||
|
||||
/* Удалить db_sync инстанс (освобождает память, отменяет таймеры). */
|
||||
{
|
||||
struct DB_SYNC_INSTANCE* si = si_find(inst, ch_id);
|
||||
if (si) db_sync_instance_remove(si);
|
||||
}
|
||||
|
||||
/* Убрать канал из кеша chat_sync. */
|
||||
chat_sync_on_channel_deleted(inst, ch_id);
|
||||
|
||||
/* Удалить файлы только после commit; I/O-отказ не восстанавливает удалённый канал. */
|
||||
int media_error = chat_attachment_delete_channel(inst, ch_id);
|
||||
char media_base[MEDIA_BASE_MAX];
|
||||
if (media_index_get_base(cc->db, media_base) || media_index_delete_channel(cc->db, ch_id, media_base)) media_error = 1;
|
||||
if (media_error) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "channel=%s removed with incomplete media cleanup; retry required", ch_id);
|
||||
|
||||
/* 8. уведомить GUI */
|
||||
/* Уведомить GUI после очистки runtime. */
|
||||
{
|
||||
uint8_t evt[65]; uint8_t cl = (uint8_t)strlen(ch_id);
|
||||
evt[0] = cl; memcpy(evt + 1, ch_id, cl);
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
#include "chat_core_priv.h"
|
||||
#include "group_policy.h"
|
||||
#include "chat_devgroup.h"
|
||||
#include "chat_whisper.h"
|
||||
#include "chat_attachment.h"
|
||||
@@ -433,8 +434,8 @@ int chat_core_get_channels_json(struct UTUN_INSTANCE* inst, char* buf, size_t bu
|
||||
sqlite3_int64 messages, count = 0, online = 0;
|
||||
stage = "history count";
|
||||
if (json_message_count(cc->db, channel, &messages)) { rc = SQLITE_ERROR; goto error; }
|
||||
char table[80], sql[160]; peers_table_name(channel, table, sizeof(table));
|
||||
snprintf(sql, sizeof(sql), "SELECT node_id FROM \"%s\" WHERE deleted=0", table);
|
||||
char table[80], sql[192]; peers_table_name(channel, table, sizeof(table));
|
||||
snprintf(sql, sizeof(sql), "SELECT node_id FROM \"%s\" WHERE record_state=0 AND deleted=0 AND admitted=1", table);
|
||||
stage = "members"; rc = sqlite3_prepare_v2(cc->db, sql, -1, &peers, NULL); if (rc != SQLITE_OK) goto error;
|
||||
while ((rc = sqlite3_step(peers)) == SQLITE_ROW) {
|
||||
count++; online += chat_core_member_online(inst, channel, (uint64_t)sqlite3_column_int64(peers, 0));
|
||||
@@ -508,7 +509,7 @@ int chat_core_get_members_json(struct UTUN_INSTANCE* inst, const char* ch_id, ch
|
||||
if (!chat_channel_id(ch_id)) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "members JSON: invalid channel"); return -1; }
|
||||
char table[80], sql[256]; peers_table_name(ch_id, table, sizeof(table));
|
||||
snprintf(sql, sizeof(sql), "SELECT p.node_id,n.name,n.x25519_pubkey,n.ed25519_pubkey,p.adm_tags,p.deleted"
|
||||
" FROM \"%s\" p LEFT JOIN nodes n ON p.node_id=n.node_id ORDER BY p.node_id", table);
|
||||
" FROM \"%s\" p LEFT JOIN nodes n ON p.node_id=n.node_id WHERE p.record_state=0 ORDER BY p.node_id", table);
|
||||
sqlite3_stmt *st = NULL, *addresses = NULL; struct strbuf json = strbuf_new(); const char* stage = "prepare";
|
||||
int rc = sqlite3_prepare_v2(cc->db, sql, -1, &st, NULL); if (rc != SQLITE_OK) goto error;
|
||||
stage = "JSON"; rc = SQLITE_NOMEM; if (strbuf_addf(&json, "[") < 0) goto error;
|
||||
@@ -674,7 +675,8 @@ struct chat_member_tags* chat_member_tags_load(struct UTUN_INSTANCE* inst, const
|
||||
|
||||
char peers_tbl[80]; peers_table_name(ch_id, peers_tbl, sizeof(peers_tbl));
|
||||
sqlite3_stmt* st = NULL;
|
||||
char sql[256]; snprintf(sql, sizeof(sql), "SELECT adm_tags,adm_tags_sig,x25519_pubkey,ed25519_pubkey,source FROM \"%s\" WHERE node_id=?", peers_tbl);
|
||||
char sql[256]; snprintf(sql, sizeof(sql), "SELECT adm_tags,adm_tags_sig,x25519_pubkey,ed25519_pubkey,source"
|
||||
" FROM \"%s\" WHERE node_id=? AND record_state=0", peers_tbl);
|
||||
const char* stage = "prepare"; int rc = sqlite3_prepare_v2(cc->db, sql, -1, &st, NULL);
|
||||
if (rc == SQLITE_OK) { stage = "bind"; rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)node_id); }
|
||||
if (rc == SQLITE_OK) { stage = "step"; rc = sqlite3_step(st); }
|
||||
@@ -831,7 +833,7 @@ static int tags_build_json(struct UTUN_INSTANCE* inst, struct chat_member_tags*
|
||||
/* Выполнить UPDATE внутри транзакции владельца; пустое имя снимает локальный псевдоним. */
|
||||
static int member_write_nickname(sqlite3* db, const char* channel, uint64_t node, const char* nickname) {
|
||||
char table[80], sql[160]; peers_table_name(channel, table, sizeof(table));
|
||||
snprintf(sql, sizeof(sql), "UPDATE \"%s\" SET local_nick=? WHERE node_id=?", table);
|
||||
snprintf(sql, sizeof(sql), "UPDATE \"%s\" SET local_nick=? WHERE node_id=? AND record_state=0", table);
|
||||
sqlite3_stmt* st = NULL; const char* stage = "prepare";
|
||||
int rc = sqlite3_prepare_v2(db, sql, -1, &st, NULL);
|
||||
if (rc == SQLITE_OK) { stage = "bind nickname"; rc = sqlite3_bind_text(st, 1, nickname[0] ? nickname : NULL, -1, SQLITE_STATIC); }
|
||||
@@ -892,6 +894,7 @@ write_nickname:
|
||||
*t = *current; u_free(current); strbuf_free(&json);
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "tags: committed ch=%s node=%016llx ver=%d keys=%d", channel,
|
||||
(unsigned long long)node, t->version, t->key_count);
|
||||
if (group_policy_refresh(inst, channel)) DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "tags committed, admission refresh failed ch=%s", channel);
|
||||
member_sync_record_committed(inst, channel, node, (unsigned)changes); return 0;
|
||||
done:
|
||||
chat_member_tags_free(current); strbuf_free(&json);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "../routing_layer/topo_node_sqlite.h"
|
||||
#include "../transport_layer/secure_channel.h"
|
||||
#include "../ntp_time.h"
|
||||
#include "../dm/dm_core.h"
|
||||
#include "../../lib/mem.h"
|
||||
#include "../../lib/strbuf.h"
|
||||
#include <openssl/crypto.h>
|
||||
@@ -56,7 +57,9 @@ static int cd_commit(struct UTUN_INSTANCE* inst, const struct devgroup_record* r
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup: invalid commit context/record"); return -1;
|
||||
}
|
||||
struct TOPO_NODE next = *current; next.devgroup = *r; next.ver = (current->ver % 255) + 1;
|
||||
int leaving = current->devgroup.json[0] && !r->json[0];
|
||||
if (cd_sql(db, "BEGIN IMMEDIATE")) return -1;
|
||||
if (leaving && (dm_group_leave_prepare(inst, current->devgroup.ed) || dm_media_close_prepare(inst))) goto rollback;
|
||||
sqlite3_stmt* st = NULL; int rc;
|
||||
if (r->json[0]) {
|
||||
uint8_t wire[DEVGROUP_WIRE_MAX]; int n = devgroup_encode(r, wire, sizeof(wire));
|
||||
@@ -84,6 +87,7 @@ static int cd_commit(struct UTUN_INSTANCE* inst, const struct devgroup_record* r
|
||||
}
|
||||
if (cd_sql(db, "COMMIT")) goto rollback;
|
||||
*current = next;
|
||||
if (leaving) dm_media_close_committed(inst);
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CHAT, "devgroup: committed node=%016llx member=%d timestamp=%llu",
|
||||
(unsigned long long)inst->node_id, !!r->json[0], (unsigned long long)next.timestamp);
|
||||
cd_publish(inst); return 0;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
/* chat_devgroup — управление общей группой устройств в потоке uasync.
|
||||
* Публичная принадлежность живёт в ядре; чат управляет приглашениями и локальными изменениями.
|
||||
* Приглашение привязано к двум устройствам и одной доступной CHAT-группе доставки.
|
||||
* Отзыв доступа отменяет ожидание и очищает PIN; уже отправленные ключи отозвать нельзя.
|
||||
* После отмены можно создать новое приглашение через другую общую группу.
|
||||
* Все результаты операций 0/-1, события после commit. Аргументы заимствованы до возврата. */
|
||||
#ifndef CHAT_DEVGROUP_H
|
||||
#define CHAT_DEVGROUP_H
|
||||
@@ -29,6 +32,9 @@ void chat_devgroup_trampoline(void* arg);
|
||||
int chat_devgroup_init(struct UTUN_INSTANCE* inst);
|
||||
/* После локального выхода отменить ожидания; уже переданные ключи имеют неизвестный результат. */
|
||||
void chat_devgroup_cancel_pending(struct UTUN_INSTANCE* inst);
|
||||
/* uasync: после изменения участников/остановки CHAT-группы отменить её недопустимые приглашения.
|
||||
* Проверяет допуск обоих узлов и зафиксированные ключи пира. Другие группы не затрагивает. */
|
||||
void chat_devgroup_revalidate_members(struct UTUN_INSTANCE* inst, uint64_t group);
|
||||
void chat_devgroup_destroy(struct UTUN_INSTANCE* inst);
|
||||
/* Пригласить известный узел через CHAT-группу; out_id[33] hex, out_pin[5]. 0/-1, PIN только для локального UI. */
|
||||
int chat_devgroup_invite(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer, char out_id[33], char out_pin[5]);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "member_sync.h"
|
||||
#include "../utun_instance.h"
|
||||
#include "../routing_layer/route_crypto.h"
|
||||
#include "../routing_layer/topo_group.h"
|
||||
#include "../transport_layer/etcp.h"
|
||||
#include "../../lib/mem.h"
|
||||
#include "../../lib/u_async.h"
|
||||
@@ -60,7 +61,8 @@ static int dg_id(const char* text, uint8_t out[16]) {
|
||||
static int dg_peer(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer, uint8_t x[32], uint8_t ed[32]) {
|
||||
char channel[32]; snprintf(channel, sizeof(channel),"%llu", (unsigned long long)group);
|
||||
struct TOPO_NODE* node = topo_node_registry_find(inst->topo_groups, peer);
|
||||
if (!node || peer == inst->node_id || member_sync_verified_ed25519(inst, channel, peer, ed) || memcmp(ed, node->ed25519_public_key, 32)) {
|
||||
if (!topo_group_chat_allowed(inst, group, peer) || !node || peer == inst->node_id ||
|
||||
member_sync_verified_ed25519(inst, channel, peer, ed) || memcmp(ed, node->ed25519_public_key, 32)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: peer identity unavailable group=%llu peer=%016llx", (unsigned long long)group, (unsigned long long)peer);
|
||||
return -1;
|
||||
}
|
||||
@@ -100,6 +102,21 @@ static int dg_send(struct chat_devgroup* ctx, struct dg_invite* p, uint8_t kind,
|
||||
return rc;
|
||||
}
|
||||
static void dg_changed(struct chat_devgroup* ctx) { chat_event_post(ctx->inst, CHAT_EVT_DEVGROUP_CHANGED, NULL, 0); }
|
||||
void chat_devgroup_revalidate_members(struct UTUN_INSTANCE* inst, uint64_t group) {
|
||||
struct chat_devgroup* ctx = dg_ctx(inst); if (!ctx) return;
|
||||
int changed = 0;
|
||||
for (unsigned i = 0; i < DG_SLOTS; i++) {
|
||||
struct dg_invite* p = &ctx->slots[i]; uint8_t x[32], ed[32];
|
||||
if (!dg_pending(p->state) || p->group != group) continue;
|
||||
if (!dg_peer(inst, group, p->peer, x, ed) && !memcmp(x, p->x, 32) && !memcmp(ed, p->ed, 32)) continue;
|
||||
p->state = p->state == DG_KEYS_SENT ? DG_UNKNOWN : DG_REJECTED;
|
||||
dg_clear_pins(p); changed = 1;
|
||||
OPENSSL_cleanse(p->last_pin, sizeof(p->last_pin)); OPENSSL_cleanse(p->last_return_pin, sizeof(p->last_return_pin));
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CHAT, "devgroup: invitation revoked group=%llu peer=%016llx state=%u",
|
||||
(unsigned long long)group, (unsigned long long)p->peer, p->state);
|
||||
}
|
||||
if (changed) dg_changed(ctx);
|
||||
}
|
||||
static int dg_keys(struct chat_devgroup* ctx, struct dg_invite* p) {
|
||||
struct devgroup_record r; uint8_t ep[32], xp[32], data[64 + DEVGROUP_WIRE_MAX];
|
||||
int rc = devgroup_load_local(ctx->inst, &r, ep, xp);
|
||||
@@ -250,7 +267,9 @@ int chat_devgroup_init(struct UTUN_INSTANCE* inst) {
|
||||
struct chat_devgroup* ctx = u_calloc(1, sizeof(*ctx));
|
||||
if (!ctx) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT,"devgroup: context allocation failed"); return -1; }
|
||||
ctx->inst = inst; CC(inst)->devgroup = ctx;
|
||||
if (etcp_router_bind(inst, ETCP_RT_ID_DEVGROUP, dg_receive) || etcp_router_require_crypto(inst, ETCP_RT_ID_DEVGROUP, ROUTER_FLAG_ENCRYPTED|ROUTER_FLAG_SIGNED)) goto fail;
|
||||
if (etcp_router_bind(inst, ETCP_RT_ID_DEVGROUP, dg_receive)
|
||||
|| etcp_router_require_crypto(inst, ETCP_RT_ID_DEVGROUP, ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT)
|
||||
|| etcp_router_set_authorize(inst, ETCP_RT_ID_DEVGROUP, topo_group_chat_allowed)) goto fail;
|
||||
ctx->timer = uasync_set_timeout(inst->ua, 10000, ctx, dg_tick,"devgroup"); if (!ctx->timer) goto fail;
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CHAT,"devgroup: invitation service started"); return 0;
|
||||
fail:
|
||||
@@ -262,6 +281,7 @@ void chat_devgroup_cancel_pending(struct UTUN_INSTANCE* inst) {
|
||||
struct dg_invite* p = &ctx->slots[i]; if (!dg_pending(p->state)) continue;
|
||||
p->state = p->state == 3 ? 7 : 5;
|
||||
dg_clear_pins(p);
|
||||
OPENSSL_cleanse(p->last_pin, sizeof(p->last_pin)); OPENSSL_cleanse(p->last_return_pin, sizeof(p->last_return_pin));
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CHAT, "devgroup: invitation cancelled peer=%016llx state=%u", (unsigned long long)p->peer, p->state);
|
||||
}
|
||||
dg_changed(ctx);
|
||||
@@ -296,6 +316,7 @@ int chat_devgroup_accept(struct UTUN_INSTANCE* inst, const char* id, const char*
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: invalid acceptance arguments"); return -1;
|
||||
}
|
||||
struct dg_invite* p = dg_find(ctx, binary);
|
||||
if (p) chat_devgroup_revalidate_members(inst, p->group);
|
||||
if (!p || get_time_tb()>p->expires || p->state != (p->outgoing ? DG_WAIT_RETURN : DG_WAIT_PIN)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: acceptance unavailable"); return -1;
|
||||
}
|
||||
|
||||
@@ -96,6 +96,9 @@ extern "C" {
|
||||
#define CHAT_EVT_DEVGROUP_RESULT 71 /* request_id LE8 + success1 + JSON; может содержать PIN, не логировать */
|
||||
#define CHAT_EVT_DEVGROUP_CHANGED 70 /* Обновление nodes/devgroup: перезагрузить снимки участников. */
|
||||
#define CHAT_EVT_NETWORK_STATS 69 /* Native POD снимок ETCP-подключений и линков, chat_network_stats.h. */
|
||||
#define CHAT_EVT_DM_START_RESULT 72 /* request/conv/generation:8 native + success:1; только результат конкретного запроса UI. */
|
||||
#define CHAT_EVT_DM_MEMBERS 73 /* conv_len:1,conv,generation:LE8,count:LE2,(side:1,member:wire260)*count. */
|
||||
#define CHAT_EVT_GROUP_POLICY_RESULT 74 /* request/group:8LE + JSON: preview или результат смены прав приглашения. */
|
||||
|
||||
// ── Подключение получателя событий ──
|
||||
|
||||
|
||||
@@ -305,7 +305,7 @@ int chat_feedback_set(struct UTUN_INSTANCE* inst, const char* channel, const uin
|
||||
memcpy(r.message_id, message_id, 32); if (len) memcpy(r.data, data, len);
|
||||
uint8_t author_key[32];
|
||||
enum ms_author_status status = member_sync_author_key(inst, channel, inst->node_id, author_key);
|
||||
if (status != MS_AUTHOR_ACTIVE) {
|
||||
if (status != MS_AUTHOR_ACTIVE || !topo_node_sqlite_member_allowed(inst->topo_sqlite_db, channel, inst->node_id)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "feedback: local action denied ch=%s node=%016llx status=%d",
|
||||
channel, (unsigned long long)inst->node_id, status); goto finish;
|
||||
}
|
||||
@@ -455,7 +455,9 @@ static int fb_apply_page(void* ctx, const char* channel, uint64_t peer, const ui
|
||||
|
||||
static int fb_peer(void* ctx, const char* channel, uint64_t peer) {
|
||||
struct UTUN_INSTANCE* inst = ctx; uint8_t key[32];
|
||||
return member_sync_verified_ed25519(inst, channel, peer, key) == 0;
|
||||
return topo_node_sqlite_member_allowed(inst->topo_sqlite_db, channel, inst->node_id) &&
|
||||
topo_node_sqlite_member_allowed(inst->topo_sqlite_db, channel, peer) &&
|
||||
member_sync_verified_ed25519(inst, channel, peer, key) == 0;
|
||||
}
|
||||
|
||||
static const struct merkle_sync_data_ops fb_ops = {
|
||||
@@ -492,7 +494,7 @@ int chat_feedback_member_update(struct UTUN_INSTANCE* inst, uint64_t node_id) {
|
||||
while ((scan = fb_next_channel(inst, channel, next, 1)) > 0) {
|
||||
strcpy(channel, next);
|
||||
char table[40], sql[384]; if (fb_table(inst, channel, table, NULL) < 0) return -1;
|
||||
snprintf(sql, sizeof(sql), "SELECT DISTINCT f.bucket FROM %s f LEFT JOIN peers_%s p ON p.node_id=f.node_id"
|
||||
snprintf(sql, sizeof(sql), "SELECT DISTINCT f.bucket FROM %s f LEFT JOIN peers_%s p ON p.node_id=f.node_id AND p.record_state=0"
|
||||
" WHERE f.node_id=?1 OR p.signed_by=?1 ORDER BY f.bucket", table, channel);
|
||||
sqlite3_stmt* st = NULL;
|
||||
int rc = sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL);
|
||||
@@ -529,7 +531,8 @@ void chat_feedback_member_committed(struct UTUN_INSTANCE* inst, uint64_t node_id
|
||||
merkle_sync_revalidate_peers(inst, MT_DATA_FEEDBACK, channel);
|
||||
if (!merkle_sync_registered(inst, MT_DATA_FEEDBACK)) return;
|
||||
char table[40], sql[512]; if (fb_table(inst, channel, table, NULL) < 0) return;
|
||||
snprintf(sql, sizeof(sql), "SELECT DISTINCT f.message_id,f.kind FROM %s f LEFT JOIN peers_%s p ON p.node_id=f.node_id"
|
||||
snprintf(sql, sizeof(sql), "SELECT DISTINCT f.message_id,f.kind FROM %s f LEFT JOIN peers_%s p"
|
||||
" ON p.node_id=f.node_id AND p.record_state=0"
|
||||
" WHERE (f.node_id=?1 OR p.signed_by=?1) AND (?2=0 OR f.message_id>?3 OR (f.message_id=?3 AND f.kind>?4))"
|
||||
" ORDER BY f.message_id,f.kind LIMIT 1", table, channel);
|
||||
struct chat_feedback_record r = {0}; int after = 0;
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
*/
|
||||
#include "chat_headless_control.h"
|
||||
#include "chat_core.h"
|
||||
#include "group_policy.h"
|
||||
#include "chat_core_priv.h"
|
||||
#include "chat_event.h"
|
||||
#include "chat_poll.h"
|
||||
@@ -22,6 +23,8 @@
|
||||
#include "invite_link.h"
|
||||
#include "invite_build.h"
|
||||
#include "../dm/dm_core.h"
|
||||
#include "../dm/dm_media.h"
|
||||
#include "../dm/dm_history.h"
|
||||
#include "../dm/dm_crypto.h"
|
||||
#include "../call/call.h"
|
||||
#include "../call/call_proto.h"
|
||||
@@ -838,6 +841,12 @@ static void hc_handle_dm_start(struct headless_client* cli, int id, const char*
|
||||
if (json_get_str(cli, "ch", ch, sizeof(ch)) < 0) ch[0] = '\0';
|
||||
uint64_t target = strtoull(node_id_str, NULL, 16);
|
||||
if (!target || !hc->inst) { send_response(cli, id, NULL, "invalid node_id"); return; }
|
||||
char mode_name[16] = "device";
|
||||
if (json_get_str(cli, "mode", mode_name, sizeof(mode_name)) < 0) strcpy(mode_name, "device");
|
||||
if (strcmp(mode_name, "device") && strcmp(mode_name, "groups")) {
|
||||
send_response(cli, id, NULL, "mode must be 'device' or 'groups'"); return;
|
||||
}
|
||||
enum dm_mode mode = !strcmp(mode_name, "groups") ? DM_MODE_GROUPS : DM_MODE_DEVICE;
|
||||
|
||||
sqlite3* db = chat_core_get_db(hc->inst);
|
||||
uint8_t x25519[32], ed25519[32]; char name[MS_USERINFO_MAX + 1];
|
||||
@@ -863,9 +872,12 @@ static void hc_handle_dm_start(struct headless_client* cli, int id, const char*
|
||||
int final = sqlite3_finalize(st);
|
||||
if (final != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_HEADLESS, "headless: DM identity finalize rc=%d", final); ok = 0; }
|
||||
if (!ok) { send_response(cli, id, NULL, "failed to start DM (node not found?)"); return; }
|
||||
if (dm_start(hc->inst, target, x25519, ed25519, name, ch[0] ? ch : NULL)) { send_response(cli, id, NULL, "DM start failed"); return; }
|
||||
uint64_t conv_num = 0;
|
||||
if (dm_start(hc->inst, target, x25519, ed25519, name, ch[0] ? ch : NULL, mode, &conv_num)) {
|
||||
send_response(cli, id, NULL, "DM start failed"); return;
|
||||
}
|
||||
|
||||
char conv[64]; uint64_t conv_num = dm_derive_conv_id(hc->inst->node_id, target);
|
||||
char conv[64];
|
||||
snprintf(conv, sizeof(conv), "%llu", (unsigned long long)conv_num);
|
||||
char resp[256]; snprintf(resp, sizeof(resp), "{\"conv_id\":\"%s\",\"started\":true}", conv);
|
||||
send_response(cli, id, resp, NULL);
|
||||
@@ -905,6 +917,27 @@ static void hc_handle_dm_send(struct headless_client* cli, int id, const char* j
|
||||
send_response(cli, id, "{\"sent\":true}", NULL);
|
||||
}
|
||||
|
||||
/* Ручная загрузка либо явное открытие; строковые числа сохраняют все 64 бита JSON. */
|
||||
static void hc_handle_dm_file(struct headless_client* cli, int id, const char* json) {
|
||||
(void)json;
|
||||
char conv[64], action[16], author[32], seq[32], generation[32];
|
||||
if (json_get_str(cli, "conv_id", conv, sizeof(conv)) < 0 || json_get_str(cli, "action", action, sizeof(action)) < 0 ||
|
||||
json_get_str(cli, "author", author, sizeof(author)) < 0 || json_get_str(cli, "seq", seq, sizeof(seq)) < 0 ||
|
||||
json_get_str(cli, "generation", generation, sizeof(generation)) < 0 ||
|
||||
(strcmp(action, "download") && strcmp(action, "opened"))) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_HEADLESS, "headless DM file: invalid arguments request=%d", id);
|
||||
send_response(cli, id, NULL, "required: conv_id, author, seq, generation, action=download|opened (strings)"); return;
|
||||
}
|
||||
char *a_end, *s_end, *g_end; errno = 0;
|
||||
uint64_t a = strtoull(author, &a_end, 10), s = strtoull(seq, &s_end, 10), g = strtoull(generation, &g_end, 10);
|
||||
if (errno || !a || !s || !g || author[0] == '-' || seq[0] == '-' || generation[0] == '-' ||
|
||||
*a_end || *s_end || *g_end || !dm_history_current(cli->hc->inst, conv, g) ||
|
||||
dm_media_user_action(cli->hc->inst, conv, a, s, !strcmp(action, "opened"))) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_HEADLESS, "headless DM file: rejected request=%d conv=%s action=%s", id, conv, action);
|
||||
send_response(cli, id, NULL, "attachment action rejected (check identity, generation and local file)");
|
||||
} else send_response(cli, id, "{\"accepted\":true}", NULL);
|
||||
}
|
||||
|
||||
/* ── Звонки (P2P аудио) ── */
|
||||
|
||||
static uint64_t hc_gen_call_id(void) {
|
||||
@@ -1062,11 +1095,28 @@ static void hc_handle_command(struct headless_client* cli, const char* json) {
|
||||
int id = json_get_int(cli, "id", 0);
|
||||
char cmd[32];
|
||||
if (json_get_str(cli, "cmd", cmd, sizeof(cmd)) || !*cmd) { send_response(cli, id, NULL, "missing/invalid cmd"); goto done; }
|
||||
if (!strcmp(cmd, "group_policy")) {
|
||||
char channel[21], token[65], keep[RECV_BUF_SIZE]; char* result = NULL;
|
||||
if (json_get_str(cli, "ch", channel, sizeof(channel))) { send_response(cli, id, NULL, "missing channel"); goto done; }
|
||||
token[0] = 0; strcpy(keep, "[]");
|
||||
for (unsigned i = 0; i < request.count; i++) {
|
||||
const char* key = request.fields[i].key;
|
||||
if ((!strcmp(key, "token") && (json_get_str(cli, key, token, sizeof(token)) || strlen(token) != 64)) ||
|
||||
(!strcmp(key, "keep") && json_get_str(cli, key, keep, sizeof(keep))) ||
|
||||
(!strcmp(key, "all_members") && request.fields[i].kind != 2)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_HEADLESS, "headless: invalid group policy parameter key=%s", key);
|
||||
send_response(cli, id, NULL, "invalid group policy parameter"); goto done;
|
||||
}
|
||||
}
|
||||
group_policy_command(cli->hc->inst, channel, json_get_int(cli, "all_members", 0), token[0] ? token : NULL, keep, &result);
|
||||
send_response(cli, id, result, result ? NULL : "group policy unavailable"); u_free(result); goto done;
|
||||
}
|
||||
if (!strcmp(cmd, "poll_create") || !strcmp(cmd, "vote") || !strcmp(cmd, "react")
|
||||
|| !strcmp(cmd, "feedback") || !strcmp(cmd, "reaction_catalog")) {
|
||||
hc_handle_feedback(cli, id, json); goto done;
|
||||
}
|
||||
if (!strncmp(cmd, "devgroup_", 9)) { hc_handle_devgroup(cli, id, cmd); goto done; }
|
||||
if (!strcmp(cmd, "dm_file")) { hc_handle_dm_file(cli, id, json); goto done; }
|
||||
for (int i = 0; cmd_names[i]; i++) {
|
||||
if (strcmp(cmd, cmd_names[i]) == 0) { cmd_handlers[i](cli, id, json); goto done; }
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include "member_sync.h"
|
||||
#include "chat_event.h"
|
||||
#include "chat_sync.h"
|
||||
#include "group_policy.h"
|
||||
|
||||
#include "../utun_instance.h"
|
||||
#include "../routing_layer/topo_node_sqlite.h"
|
||||
@@ -42,16 +43,28 @@ struct join_key_entry {
|
||||
|
||||
static uint64_t _now_tb(void) { return get_time_tb(); }
|
||||
|
||||
static void _keys_purge_expired(struct UTUN_INSTANCE* inst) {
|
||||
/* Ключ остаётся действительным только пока connection и инвайтер допущены, а инвайтер имеет право приглашать. */
|
||||
static int inviter_allowed(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t inviter) {
|
||||
char channel[21]; snprintf(channel, sizeof(channel), "%llu", (unsigned long long)group);
|
||||
if (!inst->join_stopping && topo_group_chat_allowed(inst, group, inviter) &&
|
||||
group_policy_can_invite(inst, channel, inviter) == 1) return 1;
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "%s: invitation access denied ch=%llu inviter=%016llx", CJ_ID,
|
||||
(unsigned long long)group, (unsigned long long)inviter);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void keys_revalidate(struct UTUN_INSTANCE* inst, uint64_t group) {
|
||||
uint64_t now = _now_tb();
|
||||
struct join_key_entry** p = &inst->join_keys;
|
||||
while (*p) {
|
||||
if ((*p)->expiry_tb <= now) {
|
||||
struct join_key_entry* key = *p;
|
||||
int expired = key->expiry_tb <= now;
|
||||
if (expired || ((!group || key->channel_id == group) && !inviter_allowed(inst, key->channel_id, key->inviter_id))) {
|
||||
struct join_key_entry* r = *p;
|
||||
*p = r->next;
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_CHAT_SYNC, "%s: join-key expired key=%016llx ch=%llu inviter=%016llx",
|
||||
CJ_ID, (unsigned long long)r->join_key, (unsigned long long)r->channel_id,
|
||||
(unsigned long long)r->inviter_id);
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: join-key removed ch=%llu inviter=%016llx reason=%s",
|
||||
CJ_ID, (unsigned long long)r->channel_id, (unsigned long long)r->inviter_id,
|
||||
expired ? "expired" : "access revoked");
|
||||
u_free(r);
|
||||
} else {
|
||||
p = &(*p)->next;
|
||||
@@ -64,7 +77,8 @@ static int _keys_put(struct UTUN_INSTANCE* inst, uint64_t channel_id, uint64_t j
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "%s: invalid key registration", CJ_ID);
|
||||
return -1;
|
||||
}
|
||||
_keys_purge_expired(inst);
|
||||
keys_revalidate(inst, channel_id);
|
||||
if (!inviter_allowed(inst, channel_id, inviter_id)) return -1;
|
||||
for (struct join_key_entry* e = inst->join_keys; e; e = e->next) {
|
||||
if (e->channel_id != channel_id || e->join_key != join_key) continue;
|
||||
if (e->inviter_id != inviter_id) {
|
||||
@@ -88,7 +102,7 @@ static int _keys_put(struct UTUN_INSTANCE* inst, uint64_t channel_id, uint64_t j
|
||||
}
|
||||
|
||||
static struct join_key_entry* _keys_find(struct UTUN_INSTANCE* inst, uint64_t channel_id, uint64_t join_key) {
|
||||
_keys_purge_expired(inst);
|
||||
keys_revalidate(inst, channel_id);
|
||||
for (struct join_key_entry* e = inst->join_keys; e; e = e->next)
|
||||
if (e->channel_id == channel_id && e->join_key == join_key) return e;
|
||||
return NULL;
|
||||
@@ -156,48 +170,49 @@ int chat_join_parse_member(const uint8_t* data, size_t len, struct join_member_d
|
||||
|
||||
/* ── Обработка на инвайтере: верификация + добавление мембера + подпись дерева ── */
|
||||
|
||||
void chat_join_process_request(struct UTUN_INSTANCE* inst, uint64_t group_id,
|
||||
int chat_join_process_request(struct UTUN_INSTANCE* inst, uint64_t group_id,
|
||||
uint64_t from_node, uint64_t join_key,
|
||||
const uint8_t* member_data, size_t member_len) {
|
||||
if (!inst || inst->join_stopping || !group_id || group_id > INT64_MAX || !from_node) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: invalid/stopped process request", CJ_ID); return;
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: invalid/stopped process request", CJ_ID); return CHAT_CONNECT_ERROR;
|
||||
}
|
||||
struct join_member_data m;
|
||||
if (chat_join_parse_member(member_data, member_len, &m) != 0) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "%s: process_request parse failed ch=%llu from=%016llx", CJ_ID, (unsigned long long)group_id, (unsigned long long)from_node);
|
||||
return;
|
||||
return CHAT_CONNECT_ERROR;
|
||||
}
|
||||
|
||||
char ch_id[32]; snprintf(ch_id, sizeof(ch_id), "%llu", (unsigned long long)group_id);
|
||||
sqlite3* db = inst->topo_sqlite_db;
|
||||
if (group_policy_can_invite(inst, ch_id, inst->node_id) != 1) return CHAT_CONNECT_AUTH;
|
||||
|
||||
/* 0. верификация join-ключа: принимаем только выданные нами (инвайтером) ключи */
|
||||
struct join_key_entry* ke = _keys_find(inst, group_id, join_key);
|
||||
if (!ke || ke->inviter_id != inst->node_id) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "%s: process_request invalid join key ch=%s from=%016llx key=%016llx — drop",
|
||||
CJ_ID, ch_id, (unsigned long long)from_node, (unsigned long long)join_key);
|
||||
return;
|
||||
return CHAT_CONNECT_AUTH;
|
||||
}
|
||||
|
||||
/* 1. привязка идентичности: node_id = derive(x25519) */
|
||||
if (sc_derive_node_id_from_pubkey(m.x25519) != m.node_id) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: process_request node_id/x25519 mismatch (forgery) nid=0x%016llx ch=%s",
|
||||
CJ_ID, (unsigned long long)m.node_id, ch_id);
|
||||
return;
|
||||
return CHAT_CONNECT_ERROR;
|
||||
}
|
||||
|
||||
/* 2. верификация join_sig: sign(ch_x||ch_ed||node_id||x25519||join_ts) ключом ed25519 */
|
||||
uint8_t ch_x[32], ch_ed[32];
|
||||
if (!db || topo_node_sqlite_channel_get(db, ch_id, NULL, 0, NULL, ch_x, ch_ed, NULL) != 0) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "%s: process_request no channel ch=%s", CJ_ID, ch_id);
|
||||
return;
|
||||
return CHAT_CONNECT_ERROR;
|
||||
}
|
||||
uint8_t vmsg[128];
|
||||
int vlen = member_sync_build_join_msg(ch_x, ch_ed, m.node_id, m.x25519, m.join_ts, vmsg, (int)sizeof(vmsg));
|
||||
if (vlen < 0 || sc_ed25519_verify(m.ed25519, vmsg, (size_t)vlen, m.join_sig) != SC_OK) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: process_request invalid join_sig nid=0x%016llx ch=%s",
|
||||
CJ_ID, (unsigned long long)m.node_id, ch_id);
|
||||
return;
|
||||
return CHAT_CONNECT_ERROR;
|
||||
}
|
||||
|
||||
/* Локальная запись доверенная: до неё проверяем также профиль и уже известный ключ идентичности. */
|
||||
@@ -209,14 +224,14 @@ void chat_join_process_request(struct UTUN_INSTANCE* inst, uint64_t group_id,
|
||||
if (lookup == TOPO_SQLITE_LOOKUP_ERROR || (lookup == 0 && memcmp(trusted_ed, m.ed25519, sizeof(trusted_ed))) ||
|
||||
member_sync_verify_local_record(db, ch_id, &record) != 1) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: member validation failed ch=%s node=%016llx lookup=%d", CJ_ID,
|
||||
ch_id, (unsigned long long)m.node_id, lookup); return;
|
||||
ch_id, (unsigned long long)m.node_id, lookup); return CHAT_CONNECT_ERROR;
|
||||
}
|
||||
|
||||
/* 3. добавить мембера + подписать его pubkey (дерево приглашений) */
|
||||
uint8_t tree_sig[64];
|
||||
if (member_sync_sign_pubkey(inst->my_ed25519_privkey, m.x25519, tree_sig) != 0) {
|
||||
if (member_sync_sign_join(inst->my_ed25519_privkey, m.join_sig, tree_sig) != 0) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: process_request sign_pubkey FAILED nid=0x%016llx", CJ_ID, (unsigned long long)m.node_id);
|
||||
return;
|
||||
return CHAT_CONNECT_ERROR;
|
||||
}
|
||||
|
||||
int rc = member_sync_put(inst, ch_id, m.node_id, m.x25519, m.ed25519,
|
||||
@@ -225,7 +240,7 @@ void chat_join_process_request(struct UTUN_INSTANCE* inst, uint64_t group_id,
|
||||
if (rc < 0) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: process_request member_sync_put FAILED ch=%s nid=0x%016llx rc=%d",
|
||||
CJ_ID, ch_id, (unsigned long long)m.node_id, rc);
|
||||
return;
|
||||
return CHAT_CONNECT_ERROR;
|
||||
}
|
||||
if (rc & MS_APPLY_CHANGED) {
|
||||
/* member_sync уже запускает сессию после commit; GUI получает общий формат результата. */
|
||||
@@ -234,6 +249,7 @@ void chat_join_process_request(struct UTUN_INSTANCE* inst, uint64_t group_id,
|
||||
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: process_request accepted joiner=0x%016llx ch=%s via=0x%016llx key=%016llx — signed by me",
|
||||
CJ_ID, (unsigned long long)m.node_id, ch_id, (unsigned long long)from_node, (unsigned long long)join_key);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Ожидание прикладного ACK: надёжная доставка router сама повторяет потерянные пакеты. */
|
||||
@@ -271,6 +287,11 @@ static void registration_finish(struct chat_join_registration* req, int result)
|
||||
while (*p && *p != req) p = &(*p)->next;
|
||||
if (*p) *p = req->next;
|
||||
if (req->timer) uasync_cancel_timeout(req->inst->ua, req->timer);
|
||||
if (result == CHAT_JOIN_OK && !topo_group_chat_allowed(req->inst, req->channel_id, req->target_node_id)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "%s: registration confirmation denied ch=%llu peer=%016llx", CJ_ID,
|
||||
(unsigned long long)req->channel_id, (unsigned long long)req->target_node_id);
|
||||
result = CHAT_JOIN_CANCELLED;
|
||||
}
|
||||
if (result == CHAT_JOIN_OK) {
|
||||
if (chat_join_store_local_key(req->inst, req->channel_id, req->join_key) != 0) result = CHAT_JOIN_ERROR;
|
||||
}
|
||||
@@ -299,10 +320,26 @@ void chat_join_cancel_registration(struct chat_join_registration* req) {
|
||||
if (req) registration_finish(req, CHAT_JOIN_CANCELLED);
|
||||
}
|
||||
|
||||
void chat_join_revalidate_members(struct UTUN_INSTANCE* inst, uint64_t group) {
|
||||
if (!inst) return;
|
||||
keys_revalidate(inst, group);
|
||||
/* Callback завершённой регистрации может изменить список. */
|
||||
for (;;) {
|
||||
struct chat_join_registration* denied = NULL;
|
||||
for (struct chat_join_registration* r = inst->join_registrations; r; r = r->next) {
|
||||
if (group && r->channel_id != group) continue;
|
||||
if (!topo_group_chat_allowed(inst, r->channel_id, r->target_node_id) ||
|
||||
!inviter_allowed(inst, r->channel_id, inst->node_id)) { denied = r; break; }
|
||||
}
|
||||
if (!denied) break;
|
||||
registration_finish(denied, CHAT_JOIN_CANCELLED);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── etcp_router recv ── */
|
||||
|
||||
static void join_recv_cb(struct ETCP_CONN* conn, struct ll_entry* entry) {
|
||||
if (!entry || !conn || !entry->dgram || entry->len < ROUTER_SVC_PAYLOAD_OFF + 1) {
|
||||
if (!entry || !conn || !entry->dgram || entry->len < ROUTER_SVC_PAYLOAD_OFF) {
|
||||
if (entry) { queue_dgram_free(entry); queue_entry_free(entry); }
|
||||
return;
|
||||
}
|
||||
@@ -312,6 +349,11 @@ static void join_recv_cb(struct ETCP_CONN* conn, struct ll_entry* entry) {
|
||||
const uint8_t* d = entry->dgram;
|
||||
uint64_t from_node; memcpy(&from_node, d + ROUTER_SVC_SRC_OFF, 8);
|
||||
uint64_t group_id; memcpy(&group_id, d + ROUTER_SVC_GROUP_OFF, 8);
|
||||
if (entry->len == ROUTER_SVC_PAYLOAD_OFF) {
|
||||
queue_dgram_free(entry); queue_entry_free(entry);
|
||||
chat_join_revalidate_members(inst, group_id);
|
||||
return;
|
||||
}
|
||||
uint8_t required = ROUTER_FLAG_SIGNED | ROUTER_FLAG_ENCRYPTED;
|
||||
if (from_node != inst->node_id && (d[ROUTER_SVC_FLAGS_OFF] & required) != required) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "%s: unprotected join packet rejected from=%016llx ch=%llu", CJ_ID,
|
||||
@@ -350,10 +392,26 @@ static void join_recv_cb(struct ETCP_CONN* conn, struct ll_entry* entry) {
|
||||
return;
|
||||
}
|
||||
} else if (subcmd == JOIN_SUBCMD_REQUEST_FWD) {
|
||||
/* payload: [subcmd:1][join_key:8][member...] */
|
||||
if (plen < 1 + 8) { queue_dgram_free(entry); queue_entry_free(entry); return; }
|
||||
/* payload: [subcmd:1][join_key:8][attempt:8][member...] */
|
||||
if (plen < 25) { queue_dgram_free(entry); queue_entry_free(entry); return; }
|
||||
uint64_t join_key; memcpy(&join_key, payload + 1, 8);
|
||||
chat_join_process_request(inst, group_id, from_node, join_key, payload + 1 + 8, plen - 1 - 8);
|
||||
uint64_t attempt, node; memcpy(&attempt, payload + 9, 8); memcpy(&node, payload + 17, 8);
|
||||
int32_t result = chat_join_process_request(inst, group_id, from_node, join_key, payload + 17, plen - 17);
|
||||
{
|
||||
struct ll_entry* reply = ll_alloc_lldgram(22);
|
||||
if (!reply) DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "join result allocation failed");
|
||||
else {
|
||||
reply->len = 22; reply->dgram[0] = ETCP_RT_ID_JOIN; reply->dgram[1] = JOIN_SUBCMD_REQUEST_RESULT;
|
||||
memcpy(reply->dgram + 2, &attempt, 8); memcpy(reply->dgram + 10, &node, 8); memcpy(reply->dgram + 18, &result, 4);
|
||||
if (etcp_route_send(inst, group_id, from_node, reply, 0, ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT))
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "join result send failed ch=%llu", (unsigned long long)group_id);
|
||||
}
|
||||
}
|
||||
} else if (subcmd == JOIN_SUBCMD_REQUEST_RESULT && plen == 21) {
|
||||
uint64_t attempt, node; int32_t result;
|
||||
memcpy(&attempt, payload + 1, 8); memcpy(&node, payload + 9, 8); memcpy(&result, payload + 17, 4);
|
||||
if (!result || result == CHAT_CONNECT_AUTH || result == CHAT_CONNECT_ERROR)
|
||||
chat_sync_join_inviter_result(inst, group_id, from_node, attempt, node, result);
|
||||
} else {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "%s: unknown subcmd=%02x from=%016llx", CJ_ID, subcmd, (unsigned long long)from_node);
|
||||
}
|
||||
@@ -370,6 +428,11 @@ int chat_join_init(struct UTUN_INSTANCE* inst) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: etcp_router_bind failed", CJ_ID);
|
||||
return -1;
|
||||
}
|
||||
if (etcp_router_require_crypto(inst, ETCP_RT_ID_JOIN, ROUTER_FLAG_SIGNED | ROUTER_FLAG_ENCRYPTED) ||
|
||||
etcp_router_set_authorize(inst, ETCP_RT_ID_JOIN, topo_group_chat_allowed)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: router policy initialization failed", CJ_ID);
|
||||
chat_join_destroy(inst); return -1;
|
||||
}
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: initialized", CJ_ID);
|
||||
return 0;
|
||||
}
|
||||
@@ -396,6 +459,12 @@ struct chat_join_registration* chat_join_register_key(struct UTUN_INSTANCE* inst
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: invalid registration arguments", CJ_ID);
|
||||
return NULL;
|
||||
}
|
||||
if (!target_node_id) target_node_id = inst->node_id;
|
||||
if (!inviter_allowed(inst, channel_id, inst->node_id) || !topo_group_chat_allowed(inst, channel_id, target_node_id)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "%s: registration denied ch=%llu peer=%016llx", CJ_ID,
|
||||
(unsigned long long)channel_id, (unsigned long long)target_node_id);
|
||||
return NULL;
|
||||
}
|
||||
for (struct chat_join_registration* r = inst->join_registrations; r; r = r->next) {
|
||||
if (r->channel_id == channel_id && r->join_key == join_key) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "%s: registration already pending ch=%llu", CJ_ID, (unsigned long long)channel_id);
|
||||
@@ -405,7 +474,7 @@ struct chat_join_registration* chat_join_register_key(struct UTUN_INSTANCE* inst
|
||||
struct chat_join_registration* req = u_calloc(1, sizeof(*req));
|
||||
if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: registration allocation failed", CJ_ID); return NULL; }
|
||||
req->inst = inst; req->channel_id = channel_id; req->join_key = join_key;
|
||||
req->target_node_id = target_node_id ? target_node_id : inst->node_id;
|
||||
req->target_node_id = target_node_id;
|
||||
req->callback = callback; req->arg = arg;
|
||||
int local = req->target_node_id == inst->node_id;
|
||||
req->timer = uasync_set_timeout(inst->ua, local ? 0 : JOIN_REGISTER_TIMEOUT_TB, req, registration_timer, "join_register");
|
||||
@@ -433,11 +502,12 @@ uint64_t chat_join_lookup_inviter(struct UTUN_INSTANCE* inst, uint64_t channel_i
|
||||
}
|
||||
|
||||
int chat_join_forward_request(struct UTUN_INSTANCE* inst, uint64_t channel_id,
|
||||
uint64_t inviter_id, uint64_t join_key,
|
||||
uint64_t inviter_id, uint64_t join_key, uint64_t attempt,
|
||||
const struct join_member_data* m) {
|
||||
if (!inst || inst->join_stopping || !m || !channel_id || channel_id > INT64_MAX || !join_key || !inviter_id) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: invalid forward arguments", CJ_ID); return -1;
|
||||
}
|
||||
if (!inviter_allowed(inst, channel_id, inviter_id)) return -1;
|
||||
|
||||
uint8_t mbuf[MS_USERINFO_MAX + 256]; size_t mlen = 0;
|
||||
if (chat_join_serialize_member(m, mbuf, sizeof(mbuf), &mlen) != 0) {
|
||||
@@ -445,12 +515,13 @@ int chat_join_forward_request(struct UTUN_INSTANCE* inst, uint64_t channel_id,
|
||||
return -1;
|
||||
}
|
||||
|
||||
size_t plen = 1 + 8 + mlen;
|
||||
size_t plen = 1 + 8 + 8 + mlen;
|
||||
uint8_t* payload = u_malloc(plen);
|
||||
if (!payload) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: forward payload allocation failed bytes=%zu", CJ_ID, plen); return -1; }
|
||||
payload[0] = JOIN_SUBCMD_REQUEST_FWD;
|
||||
memcpy(payload + 1, &join_key, 8);
|
||||
memcpy(payload + 1 + 8, mbuf, mlen);
|
||||
memcpy(payload + 9, &attempt, 8);
|
||||
memcpy(payload + 17, mbuf, mlen);
|
||||
|
||||
struct ll_entry* entry = queue_entry_new(0);
|
||||
if (!entry) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: forward entry allocation failed", CJ_ID); u_free(payload); return -1; }
|
||||
|
||||
@@ -16,23 +16,26 @@
|
||||
* │ │ валиден ключ → только pending, БЕЗ BGP new_conn
|
||||
* │◀── REQUEST_FWD ───────────┤ │
|
||||
* │ verif JOIN,профиля,известного Ed25519,node_id=derive(x25519) │
|
||||
* │ member_sync_put(J, signed_by=A, signature=sign(A, J_x25519||"group_join"))
|
||||
* │ member_sync_put(J, signed_by=A, signature=sign(A, J_join_sig||"group_join"))
|
||||
* ├──── REQUEST_RESULT(attempt,J,result) ───────────────────▶│ C подтверждает активность A
|
||||
* │ ── merkle broadcast ──▶ (рекорд J дошёл по merkle) │
|
||||
* │ │── JOIN_READY(ch) ────────────▶│ добавление подтверждено у C
|
||||
*
|
||||
* КОНТРАКТ ЧЛЕНСТВА (обязателен для всех путей подключения):
|
||||
* - A и C уже участвуют в канале; A может совпадать с C.
|
||||
* - A и C уже участвуют в канале; A может совпадать с C. A должен быть доступен
|
||||
* и иметь право приглашать по текущей подписанной group_policy (по умолчанию owner/admin).
|
||||
* - NCD даёт транспорт J–C для протокола добавления. Наличие транспорта,
|
||||
* invite-ключа или локального TOPO_GROUP не даёт участия J в BGP-группе.
|
||||
* Инициатор удерживает отдельный NCD handle до результата/таймаута добавления;
|
||||
* закрывать его на транспортном UP, рассчитывая на владельца BGP, запрещено.
|
||||
* - После проверки JOIN_REQUEST инвайтер сохраняет мембера через member_sync.
|
||||
* Merkle распространяет запись между существующими участниками.
|
||||
* - Каждый узел разрешает групповую сессию J после принятия и сохранения
|
||||
* записи J в СВОЕЙ таблице участников. Подтверждение всех узлов не требуется.
|
||||
* - Каждый узел разрешает групповую сессию J после проверки записи J и всей цепочки
|
||||
* приглашения по текущей политике в СВОЕЙ таблице (admitted=1).
|
||||
* После получения страниц Merkle допуск пересчитывается в конце сверки.
|
||||
* - Проверка invite-ключа разрешает только обработку запроса добавления.
|
||||
* Временного членства и раннего BGP new_conn до сохранения мембера нет.
|
||||
* - JOIN_READY означает наличие записи J у C. Это не подтверждение доставки
|
||||
* - JOIN_READY означает допуск J у C и ответ A именно на текущую попытку. Это не подтверждение доставки
|
||||
* всем узлам и не окончание обмена топологией. BGP JOIN_GROUP / TABLE_COMPLETE
|
||||
* из topo_group.h относятся к отдельной групповой сессии.
|
||||
*
|
||||
@@ -46,7 +49,9 @@
|
||||
* 3. C проверяет JOIN_REQUEST: ключ и привязку идентичности J к транспорту.
|
||||
* Запрос идёт к A по существующему маршруту канала; маршрут через J не нужен.
|
||||
* 4. A проверяет запись, подписывает приглашение и сохраняет мембера.
|
||||
* После локального commit или принятия записи по Merkle C отправляет JOIN_READY.
|
||||
* A возвращает подписанный REQUEST_RESULT с attempt_id и node_id. C отправляет
|
||||
* JOIN_READY только после успешного ответа A и появления допуска J в своей таблице.
|
||||
* J удерживает транспорт до первой сверки MEMBERS; наличие старой записи не заменяет ответ A.
|
||||
* 5. JOIN_GROUP, пришедший раньше записи мембера, не создаёт сессию у получателя.
|
||||
* После commit member_sync повторно инициирует групповое согласование
|
||||
* с этим пиром, если транспорт уже установлен. Новый ETCP-коннект не требуется.
|
||||
@@ -93,8 +98,9 @@ extern "C" {
|
||||
|
||||
/* subcommands (первый байт payload etcp_router) */
|
||||
#define JOIN_SUBCMD_KEY_REGISTER 0x01 /* инвайтер → connection: {join_key:8} */
|
||||
#define JOIN_SUBCMD_REQUEST_FWD 0x02 /* connection → инвайтер: {join_key:8, member...} */
|
||||
#define JOIN_SUBCMD_REQUEST_FWD 0x02 /* connection → инвайтер: {join_key:8, attempt:8, member...} */
|
||||
#define JOIN_SUBCMD_KEY_REGISTER_ACK 0x03 /* connection → инвайтер: {join_key:8, status:1}; 0=stored, 1=rejected */
|
||||
#define JOIN_SUBCMD_REQUEST_RESULT 0x04 /* инвайтер → connection: attempt:8,node:8,result:i32; 0/CHAT_CONNECT_* */
|
||||
|
||||
#define JOIN_KEY_TTL_SECONDS 600 /* 10 минут хранения ключа на connection-узле */
|
||||
#define JOIN_REGISTER_TIMEOUT_TB 50000 /* 5 секунд на подтверждение */
|
||||
@@ -125,12 +131,16 @@ int chat_join_serialize_member(const struct join_member_data* m,
|
||||
* Ключи/подписи заимствованы у data,userinfo копируется;криптография здесь не проверяется. */
|
||||
int chat_join_parse_member(const uint8_t* data, size_t len, struct join_member_data* m);
|
||||
|
||||
/* Инициализация: etcp_router_bind(ETCP_RT_ID_JOIN). */
|
||||
/* Инициализация JOIN с обязательной подписью/шифрованием и допуском self/peer к CHAT-группе. 0/-1. */
|
||||
int chat_join_init(struct UTUN_INSTANCE* inst);
|
||||
|
||||
/* Снять обработчик, освободить ключи; ожидающие регистрации получают CANCELLED. */
|
||||
void chat_join_destroy(struct UTUN_INSTANCE* inst);
|
||||
|
||||
/* uasync после commit допуска/политики: убрать запрещённые ключи и завершить регистрации CANCELLED.
|
||||
* group=0 проверяет все каналы; callback регистрации вызывается после удаления её состояния. */
|
||||
void chat_join_revalidate_members(struct UTUN_INSTANCE* inst, uint64_t group);
|
||||
|
||||
/* Локальное приглашение (инвайтер == connection). 0=stored, -1=ошибка. */
|
||||
int chat_join_store_local_key(struct UTUN_INSTANCE* inst, uint64_t channel_id, uint64_t join_key);
|
||||
|
||||
@@ -141,19 +151,19 @@ struct chat_join_registration* chat_join_register_key(struct UTUN_INSTANCE* inst
|
||||
uint64_t target_node_id, uint64_t join_key, chat_join_registered_fn callback, void* arg);
|
||||
void chat_join_cancel_registration(struct chat_join_registration* registration);
|
||||
|
||||
/* Connection-узел: найти инвайтера по ключу. 0 = нет/истёк. */
|
||||
/* Connection-узел: найти инвайтера по ключу и текущему праву приглашать. 0 = нет/истёк/отозван. */
|
||||
uint64_t chat_join_lookup_inviter(struct UTUN_INSTANCE* inst, uint64_t channel_id, uint64_t join_key);
|
||||
|
||||
/* Connection-узел: форварднуть join-запрос инвайтеру по etcp_router. 0=ок, -1=ошибка. */
|
||||
int chat_join_forward_request(struct UTUN_INSTANCE* inst, uint64_t channel_id,
|
||||
uint64_t inviter_id, uint64_t join_key,
|
||||
uint64_t inviter_id, uint64_t join_key, uint64_t attempt,
|
||||
const struct join_member_data* m);
|
||||
|
||||
/* Инвайтер (локально, когда connection==инвайтер, или из recv REQUEST_FWD):
|
||||
* Проверка ключа,идентичности,JOIN,подписи профиля при наличии и известного Ed25519;
|
||||
* затем доверенная локальная запись с подписью приглашения. Join-only допускается.
|
||||
* Подключение группы инициирует member_sync после commit;при изменении CONNECT_RESULT имеет request_id=0. */
|
||||
void chat_join_process_request(struct UTUN_INSTANCE* inst, uint64_t group_id,
|
||||
int chat_join_process_request(struct UTUN_INSTANCE* inst, uint64_t group_id,
|
||||
uint64_t from_node, uint64_t join_key,
|
||||
const uint8_t* member_data, size_t member_len);
|
||||
|
||||
|
||||
@@ -216,8 +216,9 @@ static int member_query(struct UTUN_INSTANCE* inst, const char* ch_id, int singl
|
||||
"(SELECT CASE WHEN COUNT(*)=0 THEN 255 WHEN MAX(addr_type=1) THEN 1 WHEN MAX(addr_type=5) THEN 5"
|
||||
" WHEN MAX(addr_type=2) THEN 2 WHEN MAX(addr_type=6) THEN 6 WHEN MAX(addr_type=4) THEN 4"
|
||||
" WHEN MAX(addr_type=3) THEN 3 ELSE 0 END FROM node_addresses WHERE node_id=p.node_id)"
|
||||
" FROM \"%s\" p LEFT JOIN nodes n ON n.node_id=p.node_id %s ORDER BY p.node_id ASC",
|
||||
table, single ? "WHERE p.node_id=?" : "");
|
||||
" FROM \"%s\" p LEFT JOIN nodes n ON n.node_id=p.node_id"
|
||||
" WHERE p.record_state=0 AND (p.admitted=1 OR p.source!=0 OR p.deleted!=0) %s ORDER BY p.node_id ASC",
|
||||
table, single ? "AND p.node_id=?" : "");
|
||||
sqlite3_stmt* st = NULL;
|
||||
int rc = sqlite3_prepare_v2(cc->db, sql, -1, &st, NULL);
|
||||
if (rc == SQLITE_OK && single) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)node_id);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
#include "chat_core_priv.h"
|
||||
#include "../routing_layer/topo_node_sqlite.h"
|
||||
#include "chat_attachment.h"
|
||||
#include "chat_event.h"
|
||||
#include "chat_feedback.h"
|
||||
@@ -129,6 +130,9 @@ int chat_core_submit_message(struct UTUN_INSTANCE* inst, struct chat_msg_submit*
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: invalid message submission", CC_ID); return -1;
|
||||
}
|
||||
memset(req->message_id, 0, sizeof(req->message_id));
|
||||
if (!topo_node_sqlite_member_allowed(cc->db, req->channel_id, inst->node_id)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CHAT, "message denied: local member excluded ch=%s", req->channel_id); return -1;
|
||||
}
|
||||
struct DB_SYNC_INSTANCE* si = si_find(inst, req->channel_id);
|
||||
if (!si) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: no history for ch=%s", CC_ID, req->channel_id); return -1; }
|
||||
|
||||
@@ -661,7 +665,8 @@ static int msg_list_ids(struct UTUN_INSTANCE* inst, const char* channel, uint8_t
|
||||
uint8_t* temporary = u_malloc(capacity);
|
||||
if (!temporary) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "ID list: allocation bytes=%zu failed", capacity); return -1; }
|
||||
char sql[160];
|
||||
if (channel) snprintf(sql, sizeof(sql), "SELECT node_id FROM peers_%s WHERE deleted=0 ORDER BY node_id<0,node_id", channel);
|
||||
if (channel) snprintf(sql, sizeof(sql), "SELECT node_id FROM peers_%s"
|
||||
" WHERE record_state=0 AND deleted=0 AND admitted=1 ORDER BY node_id<0,node_id", channel);
|
||||
else strcpy(sql, "SELECT channel_id FROM channels ORDER BY created_at,channel_id");
|
||||
sqlite3_stmt* st = NULL; size_t used = 2; uint16_t count = 0; int full = 0;
|
||||
int rc = sqlite3_prepare_v2(cc->db, sql, -1, &st, NULL);
|
||||
@@ -803,6 +808,11 @@ static void md_download_progress_cb(void* arg, int blocks_done, int num_blocks)
|
||||
|
||||
static void md_download_done_cb(void* arg, int err) {
|
||||
struct md_done_ctx* ctx = (struct md_done_ctx*)arg;
|
||||
struct TOPO_GROUP* group = topo_groups_find(ctx->inst->topo_groups, strtoull(ctx->channel_id, NULL, 10));
|
||||
if (!group || group->stopping) {
|
||||
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "media completion discarded for removed/stopping channel=%s err=%d", ctx->channel_id, err);
|
||||
u_free(ctx); return;
|
||||
}
|
||||
struct chat_core_ctx* cc = CC(ctx->inst);
|
||||
if (!err) {
|
||||
if (!strcmp(ctx->content_type, ENCRYPTED_ATTACHMENT_TYPE)) {
|
||||
|
||||
@@ -103,7 +103,8 @@ int chat_core_get_my_profile(struct UTUN_INSTANCE* inst, const char* channel, ch
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: invalid read request"); return -1;
|
||||
}
|
||||
char table[80], sql[256]; peers_table_name(channel, table, sizeof(table));
|
||||
snprintf(sql, sizeof(sql), "SELECT userinfo,update_sig,update_ts,join_sig,ed25519_pubkey,source,join_ts FROM \"%s\" WHERE node_id=?", table);
|
||||
snprintf(sql, sizeof(sql), "SELECT userinfo,update_sig,update_ts,join_sig,ed25519_pubkey,source,join_ts"
|
||||
" FROM \"%s\" WHERE node_id=? AND record_state=0", table);
|
||||
sqlite3_stmt* st = NULL; const char* stage = "prepare";
|
||||
char profile[MS_USERINFO_MAX + 1]; uint8_t update[64], join[64], body[MS_USERINFO_MAX + 73]; uint64_t version, join_ts;
|
||||
int rc = sqlite3_prepare_v2(cc->db, sql, -1, &st, NULL);
|
||||
@@ -149,7 +150,7 @@ struct profile_current {
|
||||
static int profile_read_current(struct UTUN_INSTANCE* inst, const char* channel, struct profile_current* out) {
|
||||
sqlite3* db = CC(inst)->db; char table[80], sql[384]; peers_table_name(channel, table, sizeof(table));
|
||||
snprintf(sql, sizeof(sql), "SELECT join_sig,join_ts,update_ts,userinfo,update_sig,x25519_pubkey,ed25519_pubkey,source"
|
||||
" FROM \"%s\" WHERE node_id=?", table);
|
||||
" FROM \"%s\" WHERE node_id=? AND record_state=0", table);
|
||||
sqlite3_stmt* st = NULL; const char* stage = "prepare"; int found = 0;
|
||||
int rc = sqlite3_prepare_v2(db, sql, -1, &st, NULL);
|
||||
if (rc == SQLITE_OK) { stage = "bind"; rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)inst->node_id); }
|
||||
|
||||
@@ -59,6 +59,7 @@ static const struct chat_setting_def g_setting_defs[] = {
|
||||
{"storage_backfill_days", CHAT_SETTING_INT, 7, 1, 365},
|
||||
{"dm_media_ttl_sec", CHAT_SETTING_INT, 604800, 1, 31536000}, /* TTL custody M: 7 суток */
|
||||
{"dm_media_storage_mb", CHAT_SETTING_INT, 1024, 1, 1048576}, /* включая зарезервированные загрузки */
|
||||
{"dm_media_delete_on_open", CHAT_SETTING_BOOL, 1, 0, 1}, /* 0: до загрузки всеми адресатами; 1: до открытия */
|
||||
{"standby_active_sec", CHAT_SETTING_INT, 2, 1, 3600},
|
||||
{"standby_sleep_sec", CHAT_SETTING_INT, 60, 1, 86400},
|
||||
{"standby_min_sleep_sec", CHAT_SETTING_INT, 15, 1, 86400},
|
||||
|
||||
@@ -577,7 +577,7 @@ static void collect_member_detail(const struct chat_member_detail_req* req) {
|
||||
buf[24] = CHAT_MEMBER_DETAIL_UNAVAILABLE;
|
||||
if (!buf[24]) {
|
||||
char table[80], sql[160]; peers_table_name(group->channel_id, table, sizeof(table));
|
||||
snprintf(sql, sizeof(sql), "SELECT 1 FROM \"%s\" WHERE node_id=?", table);
|
||||
snprintf(sql, sizeof(sql), "SELECT 1 FROM \"%s\" WHERE node_id=? AND record_state=0", table);
|
||||
sqlite3_stmt* st = NULL;
|
||||
int rc = sqlite3_prepare_v2(CC(inst)->db, sql, -1, &st, NULL);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)node_id);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "chat_event.h"
|
||||
#include "chat_admin.h"
|
||||
#include "chat_join.h"
|
||||
#include "group_policy.h"
|
||||
#include "chat_join_port.h"
|
||||
#include "invite_link.h"
|
||||
#include "invite_build.h"
|
||||
@@ -13,7 +14,6 @@
|
||||
#include "group_avatar.h"
|
||||
#include "../routing_layer/topo_node_sqlite.h"
|
||||
#include "../routing_layer/topo_group.h"
|
||||
#include "../routing_layer/topo_group_invite.h"
|
||||
#include "../routing_layer/topo_group_connect.h"
|
||||
#include "../../lib/json_flat.h"
|
||||
#include "member_sync.h"
|
||||
@@ -64,6 +64,8 @@ struct join_pending {
|
||||
uint64_t node_id; /* joiner node_id */
|
||||
char ch_id[64];
|
||||
uint64_t attempt; /* идентификатор конкретного JOIN_REQUEST */
|
||||
uint64_t inviter, join_ts; /* Ожидаем подпись именно этого JOIN и ответ доступного инвайтера. */
|
||||
uint8_t join_sig[64], confirmed;
|
||||
uint64_t expires; /* deadline ожидания записи, timebase */
|
||||
struct chat_join_route route; /* Подтверждённый путь запроса, сохраняется до READY. */
|
||||
struct join_pending* next;
|
||||
@@ -459,7 +461,9 @@ static void cs_receive(struct chat_sync* cs, const struct chat_join_route* route
|
||||
case CS_MSG_JOIN_READY: DEBUG_TRACE(DEBUG_CATEGORY_MEMBER_SYNC, "%s: → handle JOIN_READY", CS_ID); cs_handle_join_ready(cs, peer, ch_id, pl, plen); break;
|
||||
case CS_MSG_ERROR: {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "%s: RECV ERROR from=%016llx ch=%s", CS_ID, (unsigned long long)peer, ch_id);
|
||||
cs_join_finish(cs, CHAT_CONNECT_ERROR);
|
||||
int32_t result = CHAT_CONNECT_ERROR;
|
||||
if (plen == 4) memcpy(&result, pl, 4);
|
||||
cs_join_finish(cs, result == CHAT_CONNECT_AUTH ? CHAT_CONNECT_AUTH : CHAT_CONNECT_ERROR);
|
||||
break;
|
||||
}
|
||||
case CS_MSG_CHANNEL_INVITE: cs_handle_channel_invite(cs, peer, ch_id, pl, plen); break;
|
||||
@@ -564,7 +568,7 @@ static int cs_sync_member(struct chat_sync* cs, uint64_t channel_id, uint64_t pe
|
||||
struct TOPO_GROUP* group = topo_groups_find(cs->inst->topo_groups, channel_id);
|
||||
if (!group || group->group_type != TOPO_GROUP_TYPE_CHAT) return 0;
|
||||
char sql[160]; sqlite3_stmt* st = NULL;
|
||||
snprintf(sql, sizeof(sql), "SELECT 1 FROM peers_%llu WHERE node_id=? AND deleted=0", (unsigned long long)channel_id);
|
||||
snprintf(sql, sizeof(sql), "SELECT 1 FROM peers_%llu WHERE node_id=? AND record_state=0 AND deleted=0", (unsigned long long)channel_id);
|
||||
int rc = sqlite3_prepare_v2(cs->inst->topo_sqlite_db, sql, -1, &st, NULL);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)peer);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_step(st);
|
||||
@@ -761,9 +765,9 @@ static void cs_feedback_done(uint64_t peer, const char* channel, int result, voi
|
||||
|
||||
static void _on_member_sync_done(uint64_t peer, const char* ns, int result, void* arg) {
|
||||
struct chat_sync* cs = (struct chat_sync*)arg;
|
||||
if (result == MT_OK) {
|
||||
if (result == MT_OK && cs && topo_group_chat_allowed(cs->inst, chat_channel_id(ns), peer)) {
|
||||
cs_resume_db_sync(cs, ns);
|
||||
if (cs && merkle_sync_start(cs->inst, MT_DATA_FEEDBACK, peer, ns, cs_feedback_done, cs) < 0)
|
||||
if (merkle_sync_start(cs->inst, MT_DATA_FEEDBACK, peer, ns, cs_feedback_done, cs) < 0)
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "%s: feedback sync unavailable ch=%s peer=%016llx", CS_ID, ns, (unsigned long long)peer);
|
||||
}
|
||||
DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "%s: member_sync %s ns=%s peer=%016llx",
|
||||
@@ -993,7 +997,7 @@ static int cs_refresh_channels(struct chat_sync* cs) {
|
||||
channels = grown; capacity = next;
|
||||
}
|
||||
struct channel_cache* channel = &channels[count++]; memset(channel, 0, sizeof(*channel)); strcpy(channel->channel_id, id);
|
||||
char sql[160]; snprintf(sql, sizeof(sql), "SELECT node_id FROM peers_%s WHERE deleted=0 ORDER BY node_id", id);
|
||||
char sql[192]; snprintf(sql, sizeof(sql), "SELECT node_id FROM peers_%s WHERE record_state=0 AND deleted=0 ORDER BY node_id", id);
|
||||
sqlite3_stmt* peers = NULL; int peer_capacity = 0;
|
||||
int prc = sqlite3_prepare_v2(db, sql, -1, &peers, NULL);
|
||||
if (prc == SQLITE_OK) while ((prc = sqlite3_step(peers)) == SQLITE_ROW) {
|
||||
@@ -1293,6 +1297,8 @@ int chat_sync_join_link(struct UTUN_INSTANCE* inst, uint64_t request, const char
|
||||
int chat_sync_offer_link(struct UTUN_INSTANCE* inst, uint64_t request, uint64_t channel, const char* link, size_t len) {
|
||||
struct InviteData invite;
|
||||
if (!channel || invite_link_decode(link, len, &invite, NULL, 0)) return CHAT_CONNECT_INVALID;
|
||||
char ch[32]; snprintf(ch, sizeof(ch), "%llu", (unsigned long long)channel);
|
||||
if (group_policy_can_invite(inst, ch, inst->node_id) != 1) return CHAT_CONNECT_AUTH;
|
||||
return cs_connect_invite(inst, request, &invite, channel);
|
||||
}
|
||||
|
||||
@@ -1483,6 +1489,20 @@ static void cs_handle_join_info_resp(struct chat_sync* cs, uint64_t peer,
|
||||
{
|
||||
uint8_t msg[128];
|
||||
join_ts = (uint64_t)ntp_time_get_seconds(cs->inst);
|
||||
char query[128]; sqlite3_stmt* st = NULL;
|
||||
snprintf(query, sizeof(query), "SELECT COALESCE(MAX(join_ts),0) FROM peers_%s WHERE node_id=? AND record_state=0", ch_id);
|
||||
int rc = sqlite3_prepare_v2(db, query, -1, &st, NULL);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)myid);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_step(st);
|
||||
uint64_t previous = rc == SQLITE_ROW ? (uint64_t)sqlite3_column_int64(st, 0) : UINT64_MAX;
|
||||
int final = sqlite3_finalize(st);
|
||||
if (rc != SQLITE_ROW || final != SQLITE_OK || previous >= INT64_MAX || join_ts > INT64_MAX) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "join epoch unavailable ch=%s rc=%d previous=%llu", ch_id, rc,
|
||||
(unsigned long long)previous); cs_join_finish(cs, CHAT_CONNECT_ERROR); return;
|
||||
}
|
||||
if (join_ts <= previous) join_ts = previous + 1;
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_MEMBER_SYNC, "join epoch ch=%s previous=%llu next=%llu", ch_id,
|
||||
(unsigned long long)previous, (unsigned long long)join_ts);
|
||||
int mlen = member_sync_build_join_msg(ch_x25519, ch_ed, myid, my_x25519, join_ts, msg, (int)sizeof(msg));
|
||||
if (mlen < 0) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "join signature input invalid"); cs_join_finish(cs, CHAT_CONNECT_ERROR); return; }
|
||||
if (sc_ed25519_sign(cs->inst->my_ed25519_privkey, msg, (size_t)mlen, join_sig) != SC_OK) {
|
||||
@@ -1531,7 +1551,8 @@ static void cs_handle_join_info_resp(struct chat_sync* cs, uint64_t peer,
|
||||
/* ─── pending-джойнеры (connection-узел): node_id → прямое соединение ─── */
|
||||
|
||||
static int cs_pending_add(struct chat_sync* cs, uint64_t node_id, const char* ch_id,
|
||||
const struct chat_join_route* route, uint64_t attempt) {
|
||||
const struct chat_join_route* route, uint64_t attempt, uint64_t inviter,
|
||||
const struct join_member_data* member) {
|
||||
unsigned count = 0; uint64_t now = get_time_tb();
|
||||
struct join_pending** link = &cs->join_pending;
|
||||
while (*link) {
|
||||
@@ -1543,7 +1564,8 @@ static int cs_pending_add(struct chat_sync* cs, uint64_t node_id, const char* ch
|
||||
}
|
||||
if (item->node_id == node_id) {
|
||||
if (item->attempt == attempt && !strcmp(item->ch_id, ch_id)) {
|
||||
if (item->route.handle == route->handle && !memcmp(item->route.public_key, route->public_key, 32)) return 0;
|
||||
if (item->route.handle == route->handle && !memcmp(item->route.public_key, route->public_key, 32) &&
|
||||
item->inviter == inviter && item->join_ts == member->join_ts && !memcmp(item->join_sig, member->join_sig, 64)) return 0;
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "JOIN attempt changed authenticated route"); return -1;
|
||||
}
|
||||
count++;
|
||||
@@ -1554,6 +1576,7 @@ static int cs_pending_add(struct chat_sync* cs, uint64_t node_id, const char* ch
|
||||
struct join_pending* jp = u_calloc(1, sizeof(*jp));
|
||||
if (!jp) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "join waiter allocation failed"); return -1; }
|
||||
jp->route = *route; jp->attempt = attempt; jp->node_id = node_id;
|
||||
jp->inviter = inviter; jp->join_ts = member->join_ts; memcpy(jp->join_sig, member->join_sig, 64);
|
||||
jp->expires = now + (route->handle ? CS_CARRIER_TIMEOUT_MS : CS_JOIN_TIMEOUT_MS) * 10;
|
||||
snprintf(jp->ch_id, sizeof(jp->ch_id), "%s", ch_id); jp->next = cs->join_pending; cs->join_pending = jp;
|
||||
return 0;
|
||||
@@ -1584,23 +1607,81 @@ static void cs_pending_prune(struct chat_sync* cs) {
|
||||
}
|
||||
}
|
||||
|
||||
void chat_sync_revalidate_join(struct UTUN_INSTANCE* inst, uint64_t group) {
|
||||
struct chat_sync* cs = cs_of(inst);
|
||||
if (!cs || !cs->initialized) return;
|
||||
struct join_pending** link = &cs->join_pending;
|
||||
while (*link) {
|
||||
struct join_pending* p = *link;
|
||||
uint64_t channel = strtoull(p->ch_id, NULL, 10);
|
||||
if ((group && group != channel) || (topo_group_chat_allowed(inst, channel, p->inviter) &&
|
||||
group_policy_can_invite(inst, p->ch_id, p->inviter) == 1)) { link = &p->next; continue; }
|
||||
*link = p->next;
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "join waiter revoked ch=%s peer=%016llx inviter=%016llx attempt=%016llx", p->ch_id,
|
||||
(unsigned long long)p->node_id, (unsigned long long)p->inviter, (unsigned long long)p->attempt);
|
||||
int32_t code = CHAT_CONNECT_AUTH;
|
||||
uint8_t error[5] = {CS_MSG_ERROR}; memcpy(error + 1, &code, 4);
|
||||
cs_send_attempt(cs, p->ch_id, p->node_id, error, sizeof(error), p->attempt, &p->route);
|
||||
if (p->route.handle && inst->join_carrier) inst->join_carrier->close(inst->join_carrier->ctx, p->route.handle, 0);
|
||||
u_free(p);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── отправка JOIN_READY джойнеру + снятие pending (connection-узел) ─── */
|
||||
|
||||
static void cs_send_join_ready(struct chat_sync* cs, const char* ch_id, uint64_t node_id) {
|
||||
uint64_t group = strtoull(ch_id, NULL, 10);
|
||||
chat_sync_revalidate_join(cs->inst, group);
|
||||
if (!topo_group_chat_allowed(cs->inst, group, node_id)) return;
|
||||
uint8_t data[9] = { CS_MSG_JOIN_READY }; memcpy(data + 1, &group, 8);
|
||||
int sent = 0;
|
||||
struct join_pending** link = &cs->join_pending;
|
||||
while (*link) {
|
||||
struct join_pending* pending = *link;
|
||||
if (pending->node_id != node_id || strcmp(pending->ch_id, ch_id)) { link = &pending->next; continue; }
|
||||
if (!pending->confirmed) { link = &pending->next; continue; }
|
||||
sqlite3_stmt* st = NULL; char sql[160];
|
||||
snprintf(sql, sizeof(sql), "SELECT join_ts,join_sig FROM peers_%s WHERE node_id=? AND record_state=0", ch_id);
|
||||
int rc = sqlite3_prepare_v2(cs->inst->topo_sqlite_db, sql, -1, &st, NULL);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)node_id);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_step(st);
|
||||
int matches = rc == SQLITE_ROW && (uint64_t)sqlite3_column_int64(st, 0) == pending->join_ts &&
|
||||
sqlite3_column_bytes(st, 1) == 64 && !memcmp(sqlite3_column_blob(st, 1), pending->join_sig, 64);
|
||||
sqlite3_finalize(st);
|
||||
if (!matches) { link = &pending->next; continue; }
|
||||
if (cs_send_attempt(cs, ch_id, node_id, data, sizeof(data), pending->attempt, &pending->route)) { link = &pending->next; continue; }
|
||||
if (pending->route.handle && cs->inst->join_carrier)
|
||||
cs->inst->join_carrier->close(cs->inst->join_carrier->ctx, pending->route.handle, 1);
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_MEMBER_SYNC, "join ready peer=%016llx attempt=%016llx",
|
||||
(unsigned long long)node_id, (unsigned long long)pending->attempt);
|
||||
*link = pending->next; u_free(pending);
|
||||
sent++;
|
||||
}
|
||||
cs_schedule_sync(cs, group, node_id, "join_accepted");
|
||||
if (sent) cs_schedule_sync(cs, group, node_id, "join_accepted");
|
||||
}
|
||||
|
||||
void chat_sync_join_inviter_result(struct UTUN_INSTANCE* inst, uint64_t channel, uint64_t inviter,
|
||||
uint64_t attempt, uint64_t node, int result) {
|
||||
struct chat_sync* cs = cs_of(inst);
|
||||
if (!cs || !cs->initialized) return;
|
||||
chat_sync_revalidate_join(inst, channel);
|
||||
char ch[32]; snprintf(ch, sizeof(ch), "%llu", (unsigned long long)channel);
|
||||
struct join_pending** link = &cs->join_pending;
|
||||
while (*link) {
|
||||
struct join_pending* p = *link;
|
||||
if (p->node_id != node || p->attempt != attempt || p->inviter != inviter || strcmp(p->ch_id, ch)) {
|
||||
link = &p->next; continue;
|
||||
}
|
||||
if (!result) { p->confirmed = 1; cs_send_join_ready(cs, ch, node); return; }
|
||||
int32_t code = result == CHAT_CONNECT_AUTH ? CHAT_CONNECT_AUTH : CHAT_CONNECT_ERROR;
|
||||
uint8_t error[5] = {CS_MSG_ERROR}; memcpy(error + 1, &code, 4);
|
||||
cs_send_attempt(cs, ch, node, error, sizeof(error), attempt, &p->route);
|
||||
*link = p->next; u_free(p);
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "JOIN rejected ch=%s node=%016llx inviter=%016llx result=%d", ch,
|
||||
(unsigned long long)node, (unsigned long long)inviter, result);
|
||||
return;
|
||||
}
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_CHAT_SYNC, "ignore stale inviter response ch=%s attempt=%016llx", ch, (unsigned long long)attempt);
|
||||
}
|
||||
|
||||
static void cs_on_member_applied(const char* ch_id, uint64_t node_id, const uint8_t* x25519,
|
||||
@@ -1639,10 +1720,11 @@ static void cs_handle_join_request(struct chat_sync* cs, uint64_t peer,
|
||||
|
||||
/* валидация ключа */
|
||||
uint64_t inviter = chat_join_lookup_inviter(cs->inst, channel_id, join_key);
|
||||
if (inviter == 0) {
|
||||
if (inviter == 0 || group_policy_can_invite(cs->inst, ch_id, inviter) != 1) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "%s: JOIN_REQUEST invalid/expired join key ch=%s joiner=0x%016llx",
|
||||
CS_ID, ch_id, (unsigned long long)m.node_id);
|
||||
uint8_t err[1] = { CS_MSG_ERROR }; cs_send_attempt(cs, ch_id, peer, err, 1, attempt, route);
|
||||
uint8_t err[5] = { CS_MSG_ERROR }; int32_t code = CHAT_CONNECT_AUTH; memcpy(err + 1, &code, 4);
|
||||
cs_send_attempt(cs, ch_id, peer, err, sizeof(err), attempt, route);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1650,26 +1732,24 @@ static void cs_handle_join_request(struct chat_sync* cs, uint64_t peer,
|
||||
локально либо через Merkle; существующий транспорт обслуживает JOIN_REQUEST. */
|
||||
|
||||
/* добавить в pending (ждут JOIN_READY по приходу рекорда через merkle) */
|
||||
if (cs_pending_add(cs, m.node_id, ch_id, route, attempt) < 0) return;
|
||||
if (cs_pending_add(cs, m.node_id, ch_id, route, attempt, inviter, &m) < 0) return;
|
||||
|
||||
/* форвард инвайтеру (или локальная обработка, если инвайтер == мы) */
|
||||
if (inviter == cs->inst->node_id) {
|
||||
chat_join_process_request(cs->inst, channel_id, peer, join_key, member_data, member_len);
|
||||
int result = chat_join_process_request(cs->inst, channel_id, peer, join_key, member_data, member_len);
|
||||
chat_sync_join_inviter_result(cs->inst, channel_id, inviter, attempt, m.node_id, result);
|
||||
/* мы и есть connection-узел + инвайтер: рекорд добавлен локально,
|
||||
merkle-apply-коллбэк (cs_on_member_applied) здесь не сработает —
|
||||
отправляем JOIN_READY напрямую, если мембер реально добавлен. */
|
||||
if (topo_node_sqlite_member_allowed(cs->inst->topo_sqlite_db, ch_id, m.node_id)) {
|
||||
if (!result && topo_node_sqlite_member_allowed(cs->inst->topo_sqlite_db, ch_id, m.node_id)) {
|
||||
cs_send_join_ready(cs, ch_id, m.node_id);
|
||||
if (cs->join.inviter && !cs->join.server_offer && cs->join.channel == channel_id &&
|
||||
cs->join.peer == m.node_id && cs->join.key == join_key)
|
||||
cs_join_finish(cs, 0);
|
||||
} else {
|
||||
uint8_t error = CS_MSG_ERROR; cs_send_attempt(cs, ch_id, peer, &error, 1, attempt, route);
|
||||
}
|
||||
} else {
|
||||
if (chat_join_forward_request(cs->inst, channel_id, inviter, join_key, &m) < 0) {
|
||||
uint8_t error = CS_MSG_ERROR; cs_send_attempt(cs, ch_id, peer, &error, 1, attempt, route);
|
||||
} else if (topo_node_sqlite_member_allowed(cs->inst->topo_sqlite_db, ch_id, m.node_id)) cs_send_join_ready(cs, ch_id, m.node_id);
|
||||
if (chat_join_forward_request(cs->inst, channel_id, inviter, join_key, attempt, &m) < 0)
|
||||
chat_sync_join_inviter_result(cs->inst, channel_id, inviter, attempt, m.node_id, CHAT_CONNECT_ERROR);
|
||||
}
|
||||
|
||||
cs_refresh_channels(cs);
|
||||
|
||||
@@ -100,6 +100,12 @@ void chat_sync_destroy(struct UTUN_INSTANCE* inst);
|
||||
int chat_sync_join_link(struct UTUN_INSTANCE* inst, uint64_t request, const char* link, size_t len);
|
||||
/* Единый формат результата: peer:8,result:4,channel:8,request:8, native; только uasync. */
|
||||
void chat_sync_join_result(struct UTUN_INSTANCE* inst, uint64_t request, uint64_t channel, uint64_t peer, int result);
|
||||
/* Подписанный ответ инвайтера на forwarded JOIN. Проверяет inviter, attempt и node ожидающего запроса. */
|
||||
void chat_sync_join_inviter_result(struct UTUN_INSTANCE* inst, uint64_t channel, uint64_t inviter,
|
||||
uint64_t attempt, uint64_t node, int result);
|
||||
/* uasync после commit: отменить ожидания JOIN_READY, если connection/инвайтер потеряли доступ или право приглашать.
|
||||
* group=0 проверяет все каналы. Джойнеру членство до сохранения его записи не требуется. */
|
||||
void chat_sync_revalidate_join(struct UTUN_INSTANCE* inst, uint64_t group);
|
||||
|
||||
/* uasync: пригласить адресата в свой channel. SERVER-ссылка запускает запрос с add_key и обычным
|
||||
* приглашением через self; успех возвращает сервер после своего JOIN. CHANNEL — обычный push-invite.
|
||||
|
||||
@@ -731,13 +731,21 @@ static void db_message_result(void* ctx, const char* ns, uint64_t peer, int resu
|
||||
db->dispatch_depth--; db_done_sweep(db); db_release(si);
|
||||
}
|
||||
|
||||
/* Текущий допуск CHAT и точный ключ ETCP обязательны даже при старом READY.
|
||||
* Настройки разрешены до готовности истории, но не через исключение MEMBERS/JOIN. */
|
||||
static int db_peer_allowed(struct DB_SYNC_INSTANCE* si, uint64_t peer) {
|
||||
if (!si || !si->enabled) return 0;
|
||||
struct UTUN_INSTANCE* inst = si->db_sync->inst;
|
||||
struct ETCP_CONN* conn = instance_find_conn(inst, peer);
|
||||
return conn && conn->links_up && !conn->close_requested && topo_group_chat_allowed(inst, si->group_id, peer) &&
|
||||
topo_node_sqlite_member_key_allowed(inst->topo_sqlite_db, si->ns, conn->crypto_ctx.peer_public_key);
|
||||
}
|
||||
|
||||
static int db_message_peer(void* ctx, const char* ns, uint64_t peer) {
|
||||
struct UTUN_INSTANCE* inst = ctx;
|
||||
struct DB_SYNC_INSTANCE* si = db_sync_instance_find(inst, strtoull(ns, NULL, 10));
|
||||
struct TOPO_GROUP* group = si ? topo_groups_find(inst->topo_groups, si->group_id) : NULL;
|
||||
struct ETCP_CONN* conn = instance_find_conn(inst, peer);
|
||||
return si && !si->sync_gated && !si->index.pending && topo_group_peer_ready(group, peer) && conn &&
|
||||
member_sync_validate_pubkey(inst, ns, conn->crypto_ctx.peer_public_key);
|
||||
return si && !si->sync_gated && !si->index.pending && topo_group_peer_ready(group, peer) && db_peer_allowed(si, peer);
|
||||
}
|
||||
static int db_sync_peer_throttled(struct DB_SYNC* db, struct SI_PEER* p) {
|
||||
struct ETCP_CONN* conn = p && !p->sound_on ? instance_find_conn(db->inst, p->node_id) : NULL;
|
||||
@@ -931,7 +939,12 @@ static void db_sync_recv_cb(struct ETCP_CONN* conn, struct ll_entry* e) {
|
||||
if (!e) return;
|
||||
struct UTUN_INSTANCE* inst = conn ? conn->instance : NULL;
|
||||
struct DB_SYNC_INSTANCE* si = e->dgram && e->len >= 10 ? db_sync_instance_find(inst, db_read64(e->dgram + 1)) : NULL;
|
||||
if (!si || !db_message_peer(inst, si->ns, conn->peer_node_id)) goto done;
|
||||
if (!si || !db_message_peer(inst, si->ns, conn->peer_node_id)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "history receive denied group=%llu peer=%016llx type=%u bytes=%u: no current admission or readiness",
|
||||
(unsigned long long)(e->dgram && e->len >= 9 ? db_read64(e->dgram + 1) : 0),
|
||||
(unsigned long long)(conn ? conn->peer_node_id : 0), e->dgram && e->len >= 10 ? e->dgram[9] : 0, e->len);
|
||||
goto done;
|
||||
}
|
||||
const uint8_t* data = e->dgram + 10; size_t len = e->len - 10;
|
||||
si->holds++;
|
||||
if (e->dgram[9] == DB_MSG_ACK_PUSH) {
|
||||
@@ -1245,8 +1258,16 @@ void db_sync_peer_policy_changed(struct UTUN_INSTANCE* inst, uint64_t peer) {
|
||||
}
|
||||
void db_sync_set_peer_sound(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer, int sound) {
|
||||
struct DB_SYNC_INSTANCE* si = db_sync_instance_find(inst, group);
|
||||
struct SI_PEER* p = si ? si_peer_add(si, peer) : NULL;
|
||||
if (p) { p->sound_on = !!sound; db_sync_peer_policy_changed(inst, peer); }
|
||||
if (!db_peer_allowed(si, peer)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "history SOUND_SETTING denied group=%llu peer=%016llx: no current CHAT admission",
|
||||
(unsigned long long)group, (unsigned long long)peer); return;
|
||||
}
|
||||
struct SI_PEER* p = si_peer_add(si, peer);
|
||||
if (!p) return;
|
||||
p->sound_on = !!sound;
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_CHAT_SYNC, "history SOUND_SETTING accepted group=%llu peer=%016llx sound=%u",
|
||||
(unsigned long long)group, (unsigned long long)peer, p->sound_on);
|
||||
db_sync_peer_policy_changed(inst, peer);
|
||||
}
|
||||
void db_sync_members_changed(struct UTUN_INSTANCE* inst, const char* ns) {
|
||||
struct DB_SYNC* db = inst ? inst->db_sync : NULL; if (!db) return;
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
* общему Merkle-дереву; изменения рассылаются PUSH. Постоянный ключ — message_id,
|
||||
* порядок отображения — (timestamp, message_id). Подпись защищает весь конверт и data.
|
||||
* Ядро создаёт модуль, чат включает его и регистрирует таблицы каналов.
|
||||
* Приём/отправка истории и её настройки требуют текущего допуска self/peer в живой
|
||||
* CHAT-группе и точного ключа ETCP. Для истории дополнительно нужны READY и готовый
|
||||
* индекс; исключения MEMBERS/JOIN не открывают ни PUSH/ACK, ни модель MESSAGES.
|
||||
* Обычно используется общая SQLite ядра; remove отключает таблицу от обмена,
|
||||
* сохраняя данные. Все вызовы и callbacks выполняются в потоке uasync. */
|
||||
#ifndef DB_SYNC_H
|
||||
@@ -115,6 +118,7 @@ int db_sync_read_root(struct DB_SYNC_INSTANCE* si, uint8_t root[32]);
|
||||
void db_sync_peer_set_paused(struct DB_SYNC_INSTANCE* si, uint64_t node_id, int paused);
|
||||
|
||||
// Per-(chat,peer) «play sound on message» flag: sound_off + sleeping peer → throttled.
|
||||
/* Принять настройку от допущенного пира до/после синхронизации истории; отказ не создаёт состояние пира. */
|
||||
void db_sync_set_peer_sound(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t peer_node_id, int sound_on);
|
||||
|
||||
// До успешного member_sync блокирует входящий и исходящий обмен. После снятия
|
||||
|
||||
@@ -263,7 +263,7 @@ static struct ga_peer* ga_find(struct group_avatar* ctx, uint64_t group, uint64_
|
||||
}
|
||||
|
||||
static int ga_allowed(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer) {
|
||||
if (!member_files_member(inst, group, inst->node_id, 0) || !member_files_member(inst, group, peer, 0)) return 0;
|
||||
if (!topo_group_chat_allowed(inst, group, peer)) return 0;
|
||||
struct TOPO_GROUP* g = topo_groups_find(inst->topo_groups, group);
|
||||
for (struct ll_entry* e = g && g->senders_list ? g->senders_list->head : NULL; e; e = e->next) {
|
||||
struct TOPO_GROUP_CONN_ITEM* item = (struct TOPO_GROUP_CONN_ITEM*)e->data;
|
||||
@@ -366,6 +366,7 @@ static void ga_receive(struct ETCP_CONN* conn, struct ll_entry* entry) {
|
||||
uint64_t group, peer; memcpy(&group, entry->dgram + ROUTER_SVC_GROUP_OFF, 8); memcpy(&peer, entry->dgram + ROUTER_SVC_SRC_OFF, 8);
|
||||
struct ga_peer* p = ga_find(ctx, group, peer, 0);
|
||||
if (entry->len == ROUTER_SVC_PAYLOAD_OFF) {
|
||||
if (!topo_group_chat_allowed(ctx->inst, group, peer)) { group_avatar_revalidate_members(ctx->inst, group); goto out; }
|
||||
if (p) { ga_release(p, 1); ga_release(p, 0); p->requested_at = 0; p->state_pending = 1; ga_schedule(p); }
|
||||
goto out;
|
||||
}
|
||||
@@ -510,6 +511,11 @@ int group_avatar_init(struct UTUN_INSTANCE* inst) {
|
||||
if (etcp_router_bind(inst, ETCP_RT_ID_GROUP_AVATAR, ga_receive)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "group avatar: router registration failed"); CC(inst)->group_avatar = NULL; u_free(ctx); return -1;
|
||||
}
|
||||
if (etcp_router_require_crypto(inst, ETCP_RT_ID_GROUP_AVATAR, ROUTER_FLAG_SIGNED | ROUTER_FLAG_ENCRYPTED) ||
|
||||
etcp_router_set_authorize(inst, ETCP_RT_ID_GROUP_AVATAR, topo_group_chat_allowed)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "group avatar: router access policy initialization failed");
|
||||
group_avatar_destroy(inst); return -1;
|
||||
}
|
||||
etcp_router_set_receive_ready(inst, ETCP_RT_ID_GROUP_AVATAR, ga_receive_ready);
|
||||
ctx->timer = uasync_set_timeout(inst->ua, 10000, ctx, ga_tick, "group_avatar");
|
||||
if (!ctx->timer) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "group avatar: initial timer failed"); group_avatar_destroy(inst); return -1; }
|
||||
@@ -524,6 +530,20 @@ void group_avatar_cancel_group(struct UTUN_INSTANCE* inst, uint64_t group) {
|
||||
while (*link) { struct ga_peer* p = *link; if (p->group != group) link = &p->next; else { *link = p->next; ga_free(p); } }
|
||||
}
|
||||
|
||||
void group_avatar_revalidate_members(struct UTUN_INSTANCE* inst, uint64_t group) {
|
||||
struct group_avatar* ctx = ga_context(inst);
|
||||
if (!ctx || ctx->closing) return;
|
||||
struct ga_peer** link = &ctx->peers;
|
||||
while (*link) {
|
||||
struct ga_peer* p = *link;
|
||||
if (p->group != group || topo_group_chat_allowed(inst, group, p->peer)) { link = &p->next; continue; }
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CHAT, "group avatar: revoke group=%llx peer=%llx tx=%d rx=%d pending=%d/%d/%d",
|
||||
(unsigned long long)group, (unsigned long long)p->peer, p->tx != NULL, p->rx != NULL,
|
||||
p->state_pending, p->get_pending, p->reply_pending);
|
||||
*link = p->next; ga_free(p);
|
||||
}
|
||||
}
|
||||
|
||||
void group_avatar_destroy(struct UTUN_INSTANCE* inst) {
|
||||
struct group_avatar* ctx = ga_context(inst); if (!ctx) return;
|
||||
ctx->closing = 1; if (ctx->timer) uasync_cancel_timeout(inst->ua, ctx->timer);
|
||||
|
||||
@@ -39,6 +39,8 @@ void group_avatar_destroy(struct UTUN_INSTANCE* inst);
|
||||
|
||||
// Отменить сборки/waiters/call_soon канала перед удалением группы; БД и router-сессия остаются у своих владельцев.
|
||||
void group_avatar_cancel_group(struct UTUN_INSTANCE* inst, uint64_t group);
|
||||
// После commit состава: снять сборки/waiters/call_soon отозванных участников только этого канала; БД сохраняется.
|
||||
void group_avatar_revalidate_members(struct UTUN_INSTANCE* inst, uint64_t group);
|
||||
|
||||
// ── Чтение и изменение аватарки ──
|
||||
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
#include "group_policy.h"
|
||||
#include "member_sync.h"
|
||||
#include "chat_core.h"
|
||||
#include "chat_core_priv.h"
|
||||
#include "chat_event.h"
|
||||
#include "chat_feedback.h"
|
||||
#include "db_sync.h"
|
||||
#include "../utun_instance.h"
|
||||
#include "../ntp_time.h"
|
||||
#include "../routing_layer/topo_node_sqlite.h"
|
||||
#include "../routing_layer/topo_group.h"
|
||||
#include "../routing_layer/topo_group_connect.h"
|
||||
#include "../transport_layer/etcp.h"
|
||||
#include "../transport_layer/secure_channel.h"
|
||||
#include "../member_files/protocol.h"
|
||||
#include "../../lib/mem.h"
|
||||
#include "../../lib/debug_config.h"
|
||||
#include "../../lib/json_flat.h"
|
||||
#include "../../lib/strbuf.h"
|
||||
#include <sqlite3.h>
|
||||
#include <openssl/crypto.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <limits.h>
|
||||
|
||||
static void gp_write64(uint8_t* p, uint64_t n) { for (int i = 7; i >= 0; i--) { p[i] = (uint8_t)n; n >>= 8; } }
|
||||
static uint64_t gp_read64(const uint8_t* p) { uint64_t n = 0; for (int i = 0; i < 8; i++) n = n * 256 + p[i]; return n; }
|
||||
|
||||
int group_policy_schema(sqlite3* db) {
|
||||
int rc = sqlite3_exec(db, "CREATE TABLE IF NOT EXISTS group_policy(channel_id TEXT PRIMARY KEY,"
|
||||
"version INTEGER NOT NULL,all_members INTEGER NOT NULL,signature BLOB NOT NULL)", NULL, NULL, NULL);
|
||||
if (rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "group policy schema: %s", sqlite3_errmsg(db));
|
||||
return rc == SQLITE_OK ? 0 : -1;
|
||||
}
|
||||
|
||||
void group_policy_encode(const struct group_policy* p, uint8_t out[GROUP_POLICY_WIRE_SIZE]) {
|
||||
gp_write64(out, p->version); out[8] = p->all_members; memcpy(out + 9, p->signature, 64);
|
||||
}
|
||||
|
||||
/* Домен и числовой канал исключают перенос подписанной настройки в другую группу. */
|
||||
static int gp_message(const char* channel, const struct group_policy* p, uint8_t body[40]) {
|
||||
uint64_t id = chat_channel_id(channel);
|
||||
if (!id || p->version > INT64_MAX || p->all_members > 1) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "group policy invalid channel/version/mode"); return -1;
|
||||
}
|
||||
memcpy(body, "utun.group.policy.v1", 20); gp_write64(body + 20, id);
|
||||
gp_write64(body + 28, p->version); body[36] = p->all_members; return 37;
|
||||
}
|
||||
|
||||
int group_policy_decode(sqlite3* db, const char* channel, const uint8_t* wire, struct group_policy* out) {
|
||||
struct group_policy p = {.version = gp_read64(wire), .all_members = wire[8]};
|
||||
memcpy(p.signature, wire + 9, 64);
|
||||
uint8_t pub[32], body[40]; int bytes = gp_message(channel, &p, body);
|
||||
if (bytes < 0 || topo_node_sqlite_channel_get(db, channel, NULL, 0, NULL, NULL, pub, NULL)) return -1;
|
||||
if ((!p.version && (p.all_members || memcmp(p.signature, (uint8_t[64]){0}, 64))) ||
|
||||
(p.version && sc_ed25519_verify(pub, body, bytes, p.signature) != SC_OK)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "group policy signature rejected ch=%s version=%llu", channel,
|
||||
(unsigned long long)p.version); return -1;
|
||||
}
|
||||
*out = p; return 0;
|
||||
}
|
||||
|
||||
int group_policy_get(sqlite3* db, const char* channel, struct group_policy* out) {
|
||||
sqlite3_stmt* st = NULL; uint8_t wire[GROUP_POLICY_WIRE_SIZE] = {0};
|
||||
int rc = sqlite3_prepare_v2(db, "SELECT version,all_members,signature FROM group_policy WHERE channel_id=?", -1, &st, NULL);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_text(st, 1, channel, -1, SQLITE_STATIC);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_step(st);
|
||||
if (rc == SQLITE_ROW) {
|
||||
if (sqlite3_column_type(st, 0) != SQLITE_INTEGER || sqlite3_column_int64(st, 0) <= 0 ||
|
||||
sqlite3_column_type(st, 1) != SQLITE_INTEGER || sqlite3_column_int(st, 1) < 0 || sqlite3_column_int(st, 1) > 1 ||
|
||||
sqlite3_column_bytes(st, 2) != 64 || !sqlite3_column_blob(st, 2)) rc = SQLITE_CORRUPT;
|
||||
else {
|
||||
gp_write64(wire, (uint64_t)sqlite3_column_int64(st, 0)); wire[8] = sqlite3_column_int(st, 1);
|
||||
memcpy(wire + 9, sqlite3_column_blob(st, 2), 64);
|
||||
}
|
||||
}
|
||||
int final = sqlite3_finalize(st);
|
||||
if ((rc != SQLITE_ROW && rc != SQLITE_DONE) || final != SQLITE_OK) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "group policy read ch=%s rc=%d final=%d: %s", channel, rc, final, sqlite3_errmsg(db));
|
||||
return -1;
|
||||
}
|
||||
return group_policy_decode(db, channel, wire, out);
|
||||
}
|
||||
|
||||
int group_policy_apply(sqlite3* db, const char* channel, const struct group_policy* input) {
|
||||
uint8_t wire[GROUP_POLICY_WIRE_SIZE]; struct group_policy p, old;
|
||||
group_policy_encode(input, wire);
|
||||
if (sqlite3_get_autocommit(db) || group_policy_decode(db, channel, wire, &p) || group_policy_get(db, channel, &old)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "group policy apply requires transaction and valid records ch=%s", channel); return -1;
|
||||
}
|
||||
/* При одинаковой версии ограничивающая политика побеждает: порядок доставки не влияет. */
|
||||
if (p.version < old.version || (p.version == old.version && p.all_members >= old.all_members)) return 0;
|
||||
sqlite3_stmt* st = NULL;
|
||||
int rc = sqlite3_prepare_v2(db, "INSERT INTO group_policy VALUES(?,?,?,?) ON CONFLICT(channel_id) DO UPDATE SET"
|
||||
" version=excluded.version,all_members=excluded.all_members,signature=excluded.signature", -1, &st, NULL);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_text(st, 1, channel, -1, SQLITE_STATIC);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 2, (sqlite3_int64)p.version);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int(st, 3, p.all_members);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 4, p.signature, 64, SQLITE_STATIC);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_step(st);
|
||||
int final = sqlite3_finalize(st);
|
||||
if (rc != SQLITE_DONE || final != SQLITE_OK || sqlite3_changes(db) != 1) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "group policy write ch=%s rc=%d final=%d: %s", channel, rc, final, sqlite3_errmsg(db));
|
||||
return -1;
|
||||
}
|
||||
DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "group policy received ch=%s version=%llu all=%u", channel,
|
||||
(unsigned long long)p.version, p.all_members); return 1;
|
||||
}
|
||||
|
||||
struct gp_member {
|
||||
uint64_t node, signer;
|
||||
uint8_t x[32], ed[32], join[64], valid, admin, previous, allowed, keep;
|
||||
size_t child, sibling; /* Индексы + 1, 0 — конец списка детей. */
|
||||
char name[128];
|
||||
};
|
||||
|
||||
static size_t gp_find(const struct gp_member* m, size_t count, uint64_t id) {
|
||||
size_t lo = 0, hi = count;
|
||||
while (lo < hi) { size_t mid = lo + (hi - lo) / 2; if (m[mid].node < id) lo = mid + 1; else hi = mid; }
|
||||
return lo < count && m[lo].node == id ? lo : count;
|
||||
}
|
||||
|
||||
/* Загружает удостоверения целиком. Ошибка проверки не превращается в массовое исключение. */
|
||||
static int gp_load(struct UTUN_INSTANCE* inst, const char* ch, struct gp_member** out, size_t* count, int proof_only) {
|
||||
sqlite3* db = inst->topo_sqlite_db; char sql[512]; sqlite3_stmt* st = NULL;
|
||||
snprintf(sql, sizeof(sql), "SELECT node_id,x25519_pubkey,ed25519_pubkey,join_sig,join_ts,update_sig,update_ts,"
|
||||
"userinfo,adm_tags,adm_tags_sig,signed_by,signature,deleted,admitted FROM peers_%s"
|
||||
" WHERE record_state=0 AND source=0 ORDER BY node_id<0,node_id", ch);
|
||||
int rc = sqlite3_prepare_v2(db, sql, -1, &st, NULL), result = -1;
|
||||
struct gp_member* list = NULL; size_t n = 0, capacity = 0;
|
||||
if (rc != SQLITE_OK) goto done;
|
||||
while ((rc = sqlite3_step(st)) == SQLITE_ROW) {
|
||||
if (n == capacity) {
|
||||
size_t next = capacity ? capacity * 2 : 16;
|
||||
if (next > UINT32_MAX / sizeof(*list)) { rc = SQLITE_TOOBIG; goto done; }
|
||||
struct gp_member* p = u_realloc(list, next * sizeof(*p));
|
||||
if (!p) { rc = SQLITE_NOMEM; goto done; } list = p; capacity = next;
|
||||
}
|
||||
struct gp_member* p = &list[n++]; memset(p, 0, sizeof(*p));
|
||||
p->node = (uint64_t)sqlite3_column_int64(st, 0); p->signer = (uint64_t)sqlite3_column_int64(st, 10);
|
||||
p->previous = sqlite3_column_int(st, 13) != 0;
|
||||
const char* info = (const char*)sqlite3_column_text(st, 7);
|
||||
if (info) json_flat_get(info, "name", p->name, sizeof(p->name));
|
||||
if (sqlite3_column_bytes(st, 1) != 32 || sqlite3_column_bytes(st, 2) != 32 ||
|
||||
sqlite3_column_bytes(st, 3) != 64 || sqlite3_column_bytes(st, 11) != 64 || sqlite3_column_int64(st, 4) <= 0) continue;
|
||||
struct ms_member_rec m = {.node_id = p->node, .x25519 = sqlite3_column_blob(st, 1), .ed25519 = sqlite3_column_blob(st, 2),
|
||||
.join_sig = sqlite3_column_blob(st, 3), .join_ts = (uint64_t)sqlite3_column_int64(st, 4),
|
||||
.update_sig = sqlite3_column_bytes(st, 5) == 64 ? sqlite3_column_blob(st, 5) : NULL,
|
||||
.update_ts = (uint64_t)sqlite3_column_int64(st, 6), .userinfo = info, .adm_tags = (const char*)sqlite3_column_text(st, 8),
|
||||
.adm_tags_sig = sqlite3_column_bytes(st, 9) == 64 ? sqlite3_column_blob(st, 9) : NULL,
|
||||
.signed_by = p->signer, .signature = sqlite3_column_blob(st, 11)};
|
||||
if (!m.x25519 || !m.ed25519 || !m.join_sig || !m.signature) { rc = SQLITE_NOMEM; goto done; }
|
||||
memcpy(p->x, m.x25519, 32);
|
||||
memcpy(p->ed, m.ed25519, 32);
|
||||
memcpy(p->join, m.join_sig, 64);
|
||||
int verified = member_sync_verify_local_record(db, ch, &m);
|
||||
if (verified < 0) goto done;
|
||||
int tree = member_sync_verify_signature(db, ch, &m);
|
||||
if (tree == MS_VERIFY_ERROR) goto done;
|
||||
p->valid = verified == 1 && tree == 1 && (proof_only || !sqlite3_column_int(st, 12)) && memcmp(m.join_sig, (uint8_t[64]){0}, 64);
|
||||
if (p->valid && m.adm_tags && m.adm_tags_sig && memcmp(m.adm_tags_sig, (uint8_t[64]){0}, 64)) {
|
||||
char role[MS_ADM_TAGS_MAX + 1];
|
||||
if (!json_flat_get(m.adm_tags, "admin", role, sizeof(role))) {
|
||||
const char* last = role;
|
||||
for (const char* it = role; *it; it++) if (*it == 'e' || *it == 'd') last = it;
|
||||
p->admin = *last == 'e';
|
||||
}
|
||||
}
|
||||
}
|
||||
if (rc == SQLITE_DONE) result = 0;
|
||||
done:
|
||||
if (sqlite3_finalize(st) != SQLITE_OK) result = -1;
|
||||
if (result < 0) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "group admission load ch=%s rc=%d: %s", ch, rc, sqlite3_errmsg(db)); u_free(list);
|
||||
} else { *out = list; *count = n; }
|
||||
return result;
|
||||
}
|
||||
|
||||
/* Обход от подписанных ключом группы корней; циклы и отсутствующие родители доступа не дают. */
|
||||
static int gp_evaluate(struct gp_member* m, size_t count, int all, uint64_t owner) {
|
||||
size_t* queue = count ? u_malloc(count * sizeof(*queue)) : NULL;
|
||||
if (count && !queue) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "group admission queue allocation count=%zu", count); return -1; }
|
||||
size_t head = 0, tail = 0;
|
||||
for (size_t i = 0; i < count; i++) { m[i].allowed = 0; m[i].child = m[i].sibling = 0; }
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
if (!m[i].valid) continue;
|
||||
if (!m[i].signer || m[i].keep) { m[i].allowed = 1; queue[tail++] = i; continue; }
|
||||
size_t p = gp_find(m, count, m[i].signer);
|
||||
if (p == count) continue;
|
||||
m[i].sibling = m[p].child; m[p].child = i + 1;
|
||||
}
|
||||
while (head < tail) {
|
||||
struct gp_member* parent = &m[queue[head++]];
|
||||
if (!all && !parent->admin && parent->node != owner) continue;
|
||||
for (size_t it = parent->child; it; it = m[it - 1].sibling) {
|
||||
m[it - 1].allowed = 1; queue[tail++] = it - 1;
|
||||
}
|
||||
}
|
||||
u_free(queue); return 0;
|
||||
}
|
||||
|
||||
/* Один обход всего дерева вместо повторной проверки каждого общего предка. */
|
||||
int group_policy_verify_members(struct UTUN_INSTANCE* inst, const char* channel, const uint64_t* nodes, size_t count) {
|
||||
struct gp_member* members = NULL; size_t total = 0;
|
||||
if (gp_load(inst, channel, &members, &total, 1)) return -1;
|
||||
int result = gp_evaluate(members, total, 1, 0) ? -1 : 1;
|
||||
for (size_t i = 0; result == 1 && i < count; i++) {
|
||||
size_t n = gp_find(members, total, nodes[i]);
|
||||
if (n == total || !members[n].allowed) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "member proof unresolved ch=%s node=%016llx", channel, (unsigned long long)nodes[i]);
|
||||
result = 0;
|
||||
}
|
||||
}
|
||||
u_free(members); return result;
|
||||
}
|
||||
|
||||
|
||||
struct group_policy_update {
|
||||
char channel[21];
|
||||
struct group_policy policy;
|
||||
struct gp_member* members;
|
||||
size_t count;
|
||||
int changes;
|
||||
};
|
||||
|
||||
void group_policy_discard(struct group_policy_update* update) {
|
||||
if (update) { u_free(update->members); u_free(update); }
|
||||
}
|
||||
|
||||
/* Проекция допуска сохраняется той же транзакцией, что и породившие её удостоверения. */
|
||||
int group_policy_project(struct UTUN_INSTANCE* inst, const char* channel, struct group_policy_update** out) {
|
||||
sqlite3* db = inst ? inst->topo_sqlite_db : NULL;
|
||||
if (out) *out = NULL;
|
||||
if (!db || !out || !chat_channel_id(channel) || strlen(channel) > 20 || sqlite3_get_autocommit(db)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "group admission project requires channel and caller transaction"); return -1;
|
||||
}
|
||||
struct group_policy_update* update = u_calloc(1, sizeof(*update));
|
||||
if (!update) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "group admission allocation ch=%s", channel); return -1; }
|
||||
strcpy(update->channel, channel);
|
||||
sqlite3_stmt* st = NULL; char sql[192]; uint64_t owner;
|
||||
if (group_policy_get(db, channel, &update->policy) ||
|
||||
topo_node_sqlite_channel_get(db, channel, NULL, 0, &owner, NULL, NULL, NULL) ||
|
||||
gp_load(inst, channel, &update->members, &update->count, 0) ||
|
||||
gp_evaluate(update->members, update->count, update->policy.all_members, owner)) goto error;
|
||||
snprintf(sql, sizeof(sql), "UPDATE peers_%s SET admitted=? WHERE node_id=? AND record_state=0", channel);
|
||||
if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) goto error;
|
||||
struct gp_member* m = update->members;
|
||||
for (size_t i = 0; i < update->count; i++) {
|
||||
if (m[i].previous == m[i].allowed) continue;
|
||||
if (sqlite3_bind_int(st, 1, m[i].allowed) != SQLITE_OK ||
|
||||
sqlite3_bind_int64(st, 2, (sqlite3_int64)m[i].node) != SQLITE_OK || sqlite3_step(st) != SQLITE_DONE ||
|
||||
sqlite3_changes(db) != 1 || sqlite3_reset(st) != SQLITE_OK) goto error;
|
||||
update->changes++;
|
||||
}
|
||||
{ int rc = sqlite3_finalize(st); st = NULL; if (rc != SQLITE_OK) goto error; }
|
||||
*out = update; return 0;
|
||||
error:
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "group admission projection failed ch=%s: %s", channel, sqlite3_errmsg(db));
|
||||
sqlite3_finalize(st); group_policy_discard(update); return -1;
|
||||
}
|
||||
|
||||
void group_policy_publish(struct UTUN_INSTANCE* inst, struct group_policy_update* update) {
|
||||
if (!update) return;
|
||||
const char* channel = update->channel;
|
||||
struct gp_member* m = update->members; size_t count = update->count;
|
||||
struct group_policy policy = update->policy;
|
||||
struct TOPO_GROUP* group = inst->topo_groups ? topo_groups_find(inst->topo_groups, chat_channel_id(channel)) : NULL;
|
||||
if (group) topo_group_revalidate_members(group);
|
||||
merkle_sync_revalidate_peers(inst, MT_DATA_MEMBERS, channel);
|
||||
size_t self = gp_find(m, count, inst->node_id);
|
||||
int restored = self < count && !m[self].previous && m[self].allowed;
|
||||
for (size_t i = 0; i < count; i++) if (restored || m[i].previous != m[i].allowed) {
|
||||
if (m[i].previous != m[i].allowed) {
|
||||
DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "group admission ch=%s node=%016llx signer=%016llx allowed=%u all=%u version=%llu",
|
||||
channel, (unsigned long long)m[i].node, (unsigned long long)m[i].signer, m[i].allowed, policy.all_members,
|
||||
(unsigned long long)policy.version);
|
||||
member_sync_notify_props(inst, channel, m[i].node);
|
||||
}
|
||||
struct TOPO_GROUP* g = inst->topo_groups ? topo_groups_find(inst->topo_groups, chat_channel_id(channel)) : NULL;
|
||||
struct ETCP_CONN* conn = instance_find_conn(inst, m[i].node);
|
||||
if (g && conn && m[i].allowed && conn->links_up && !conn->close_requested) {
|
||||
topo_group_new_conn(g, conn);
|
||||
/* Recovery завершён: отдельный обычный start подтверждает состав и ожидающему соседу. */
|
||||
if (restored && merkle_sync_start(inst, MT_DATA_MEMBERS, m[i].node, channel, NULL, NULL) < 0)
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "member exchange after admission unavailable ch=%s peer=%016llx",
|
||||
channel, (unsigned long long)m[i].node);
|
||||
}
|
||||
}
|
||||
if (update->changes) {
|
||||
merkle_sync_revalidate_peers(inst, MT_DATA_FEEDBACK, channel);
|
||||
db_sync_members_changed(inst, channel);
|
||||
uint8_t evt[21]; evt[0] = (uint8_t)strlen(channel); memcpy(evt + 1, channel, evt[0]);
|
||||
chat_event_post(inst, CHAT_EVT_MEMBERS_CHANGED, evt, evt[0] + 1);
|
||||
}
|
||||
group_policy_discard(update);
|
||||
}
|
||||
|
||||
int group_policy_refresh(struct UTUN_INSTANCE* inst, const char* channel) {
|
||||
sqlite3* db = inst ? inst->topo_sqlite_db : NULL;
|
||||
if (!db || !chat_channel_id(channel) || !sqlite3_get_autocommit(db)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "group admission refresh requires database outside transaction"); return -1;
|
||||
}
|
||||
struct group_policy_update* update = NULL;
|
||||
if (sqlite3_exec(db, "BEGIN IMMEDIATE /* admission */", NULL, NULL, NULL) != SQLITE_OK ||
|
||||
group_policy_project(inst, channel, &update) || sqlite3_exec(db, "COMMIT /* admission */", NULL, NULL, NULL) != SQLITE_OK) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "group admission transaction ch=%s: %s", channel, sqlite3_errmsg(db));
|
||||
if (!sqlite3_get_autocommit(db) && sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL) != SQLITE_OK) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "group admission rollback failed ch=%s", channel); utun_instance_stop(inst);
|
||||
}
|
||||
group_policy_discard(update); return -1;
|
||||
}
|
||||
group_policy_publish(inst, update); return 0;
|
||||
}
|
||||
|
||||
int group_policy_can_invite(struct UTUN_INSTANCE* inst, const char* channel, uint64_t inviter) {
|
||||
struct group_policy policy; struct gp_member* m = NULL; size_t count = 0; uint64_t owner;
|
||||
sqlite3* db = inst ? inst->topo_sqlite_db : NULL;
|
||||
/* Проверяем подписи и всю цепочку заново: обмен профилями не блокирует действующего приглашающего. */
|
||||
if (!db || !chat_channel_id(channel) || group_policy_get(db, channel, &policy) ||
|
||||
topo_node_sqlite_channel_get(db, channel, NULL, 0, &owner, NULL, NULL, NULL) || gp_load(inst, channel, &m, &count, 0)) return -1;
|
||||
int rc = gp_evaluate(m, count, policy.all_members, owner);
|
||||
size_t i = gp_find(m, count, inviter);
|
||||
int allowed = !rc && i < count && m[i].allowed && (policy.all_members || inviter == owner || m[i].admin);
|
||||
if (!allowed) DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "invitation denied ch=%s inviter=%016llx mode=%u version=%llu",
|
||||
channel, (unsigned long long)inviter, policy.all_members, (unsigned long long)policy.version);
|
||||
u_free(m); return rc ? -1 : allowed;
|
||||
}
|
||||
|
||||
/* Выбор передаётся строковыми uint64 ID: JSON number не сохраняет точность в интерфейсах. */
|
||||
static int gp_select(sqlite3* db, struct gp_member* m, size_t count, const char* json) {
|
||||
sqlite3_stmt* st = NULL;
|
||||
int rc = sqlite3_prepare_v2(db, "SELECT json_type(?1)", -1, &st, NULL);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_text(st, 1, json ? json : "[]", -1, SQLITE_STATIC);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_step(st);
|
||||
if (rc != SQLITE_ROW || !sqlite3_column_text(st, 0) || strcmp((const char*)sqlite3_column_text(st, 0), "array")) {
|
||||
rc = SQLITE_MISMATCH; goto done;
|
||||
}
|
||||
rc = sqlite3_finalize(st); st = NULL;
|
||||
if (rc == SQLITE_OK) rc = sqlite3_prepare_v2(db, "SELECT type,value FROM json_each(?1)", -1, &st, NULL);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_text(st, 1, json ? json : "[]", -1, SQLITE_STATIC);
|
||||
if (rc != SQLITE_OK) goto done;
|
||||
while ((rc = sqlite3_step(st)) == SQLITE_ROW) {
|
||||
const char* type = (const char*)sqlite3_column_text(st, 0); const char* text = (const char*)sqlite3_column_text(st, 1);
|
||||
if (!type || strcmp(type, "text") || !text || !*text || strlen(text) > 20 || strspn(text, "0123456789") != strlen(text)) {
|
||||
rc = SQLITE_MISMATCH; break;
|
||||
}
|
||||
char* end; uint64_t id = strtoull(text, &end, 10); char canonical[21]; snprintf(canonical, sizeof(canonical), "%llu", (unsigned long long)id);
|
||||
size_t i = gp_find(m, count, id);
|
||||
if (*end || strcmp(canonical, text) || i == count || !m[i].valid || !m[i].previous || m[i].keep) { rc = SQLITE_CONSTRAINT; break; }
|
||||
m[i].keep = 1;
|
||||
}
|
||||
done:
|
||||
{ int final = sqlite3_finalize(st); if (rc == SQLITE_DONE && final == SQLITE_OK) return 0; }
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "group policy invalid preservation selection rc=%d", rc); return -1;
|
||||
}
|
||||
|
||||
int group_policy_command(struct UTUN_INSTANCE* inst, const char* channel, int all, const char* token, const char* keep, char** output) {
|
||||
if (output) *output = NULL;
|
||||
sqlite3* db = inst ? inst->topo_sqlite_db : NULL; struct group_policy policy;
|
||||
struct gp_member* m = NULL; size_t count = 0; uint64_t owner; uint8_t root[32], key[32] = {0};
|
||||
struct strbuf json = strbuf_new(); char expected[65]; int result = -1, transaction = 0;
|
||||
const char* error = "unavailable";
|
||||
if (!output || !db || !chat_channel_id(channel) || (all != 0 && all != 1) || !sqlite3_get_autocommit(db)) goto done;
|
||||
if (group_policy_get(db, channel, &policy) || topo_node_sqlite_channel_get(db, channel, NULL, 0, &owner, NULL, NULL, NULL) ||
|
||||
merkle_sync_read_hash(inst, MT_DATA_MEMBERS, channel, 0, 0, root) || gp_load(inst, channel, &m, &count, 0)) goto done;
|
||||
for (int i = 0; i < 32; i++) snprintf(expected + i * 2, 3, "%02x", root[i]);
|
||||
int can_edit = topo_node_sqlite_channel_get_priv(db, channel, key) == 0;
|
||||
if (gp_select(db, m, count, keep) || gp_evaluate(m, count, all, owner)) { error = "invalid_selection"; goto done; }
|
||||
/* Ответ выделяем до записи: ошибка памяти не должна выдавать отказ уже выполненной операции. */
|
||||
if (strbuf_addf(&json, "{\"ok\":true,\"applied\":%s,\"all_members\":%s,\"can_edit\":%s,\"token\":\"%s\",\"affected\":[",
|
||||
token ? "true" : "false", (token ? all : policy.all_members) ? "true" : "false", can_edit ? "true" : "false", expected) < 0) goto done;
|
||||
int separator = 0;
|
||||
for (size_t i = 0; i < count; i++) if (m[i].previous && !m[i].allowed) {
|
||||
if (strbuf_addf(&json, "%s{\"node\":\"%llu\",\"inviter\":\"%llu\",\"name\":", separator++ ? "," : "",
|
||||
(unsigned long long)m[i].node, (unsigned long long)m[i].signer) < 0 || strbuf_add_json_string(&json, m[i].name) < 0 ||
|
||||
strbuf_addf(&json, "}") < 0) goto done;
|
||||
}
|
||||
if (strbuf_addf(&json, "]}") < 0) goto done;
|
||||
if (token) {
|
||||
if (!can_edit) { error = "forbidden"; goto done; }
|
||||
if (strcmp(token, expected)) { error = "conflict"; result = -2; goto done; }
|
||||
if (sqlite3_exec(db, "BEGIN IMMEDIATE /* group policy */", NULL, NULL, NULL) != SQLITE_OK) goto done;
|
||||
transaction = 1;
|
||||
for (size_t i = 0; i < count; i++) if (m[i].keep && m[i].signer) {
|
||||
uint8_t signature[64];
|
||||
if (member_sync_sign_join(key, m[i].join, signature) ||
|
||||
member_sync_put(inst, channel, m[i].node, m[i].x, m[i].ed, NULL, 0, NULL, 0, NULL, NULL, NULL, 0, 0, signature) < 0) goto done;
|
||||
}
|
||||
if (policy.version == INT64_MAX) { error = "version_exhausted"; goto done; }
|
||||
int64_t now = ntp_time_get_us(inst); uint64_t version = now > 0 ? (uint64_t)now : 1;
|
||||
policy.version = version > policy.version ? version : policy.version + 1; policy.all_members = all;
|
||||
uint8_t body[40]; int bytes = gp_message(channel, &policy, body);
|
||||
if (bytes < 0 || sc_ed25519_sign(key, body, bytes, policy.signature) != SC_OK ||
|
||||
group_policy_apply(db, channel, &policy) < 0 || merkle_sync_recompute_path(inst, MT_DATA_MEMBERS, channel, 0) < 0 ||
|
||||
sqlite3_exec(db, "COMMIT /* group policy */", NULL, NULL, NULL) != SQLITE_OK) goto done;
|
||||
transaction = 0;
|
||||
merkle_sync_changed(inst, MT_DATA_MEMBERS, channel);
|
||||
if (group_policy_refresh(inst, channel)) DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "group policy committed, admission refresh failed ch=%s", channel);
|
||||
DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "group policy committed ch=%s all=%d version=%llu", channel, all, (unsigned long long)policy.version);
|
||||
}
|
||||
*output = strbuf_detach(&json); result = 0;
|
||||
done:
|
||||
OPENSSL_cleanse(key, sizeof(key)); u_free(m); strbuf_free(&json);
|
||||
if (transaction && sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL) != SQLITE_OK) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "group policy rollback failed ch=%s", channel); utun_instance_stop(inst);
|
||||
}
|
||||
if (result < 0) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "group policy command failed ch=%s reason=%s", channel ? channel : "-", error);
|
||||
if (output) { char text[96]; snprintf(text, sizeof(text), "{\"ok\":false,\"error\":\"%s\"}", error); *output = u_strdup(text); }
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void group_policy_gui_command(void* arg) {
|
||||
struct group_policy_request* q = arg;
|
||||
if (!q || !q->inst) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "group policy invalid GUI request"); u_free(q); return; }
|
||||
char channel[32], *json = NULL;
|
||||
snprintf(channel, sizeof(channel), "%llu", (unsigned long long)q->group);
|
||||
group_policy_command(q->inst, channel, q->all_members, q->token[0] ? q->token : NULL, q->keep, &json);
|
||||
const char* response = json ? json : "{\"ok\":false,\"error\":\"unavailable\"}";
|
||||
size_t bytes = strlen(response); uint8_t* event = u_malloc(16 + bytes);
|
||||
if (!event) DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "group policy result allocation id=%llu", (unsigned long long)q->id);
|
||||
else {
|
||||
mf_put64(event, q->id); mf_put64(event + 8, q->group); memcpy(event + 16, response, bytes);
|
||||
chat_event_post(q->inst, CHAT_EVT_GROUP_POLICY_RESULT, event, (int)(16 + bytes)); u_free(event);
|
||||
}
|
||||
u_free(json); u_free(q);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/* Общие права приглашения CHAT-группы. Политика подписана ключом группы и входит в
|
||||
* MEMBERS Merkle вместе с удостоверениями участников. admitted — локальная проекция,
|
||||
* пересчитывается после завершённой сверки; подписи и роли исключённых сохраняются.
|
||||
* Все операции выполняются в uasync-потоке. Строки channel — канонический decimal ID. */
|
||||
#ifndef GROUP_POLICY_H
|
||||
#define GROUP_POLICY_H
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
struct UTUN_INSTANCE;
|
||||
struct sqlite3;
|
||||
struct group_policy_update;
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define GROUP_POLICY_WIRE_SIZE 73
|
||||
struct group_policy {
|
||||
uint64_t version; /* 0 — исходное admins-only; остальные версии подписаны. */
|
||||
uint8_t all_members; /* 0 — владелец/администраторы, 1 — все действующие участники. */
|
||||
uint8_t signature[64]; /* Ed25519 ключом группы: domain, channel, version, mode. */
|
||||
};
|
||||
|
||||
/* Схема общей таблицы политик; 0/-1, допускает внешнюю транзакцию. */
|
||||
int group_policy_schema(struct sqlite3* db);
|
||||
/* Прочитать и проверить политику существующей группы; 0/-1, out меняется только при успехе. */
|
||||
int group_policy_get(struct sqlite3* db, const char* channel, struct group_policy* out);
|
||||
/* Канонический wire; decode дополнительно проверяет подпись. 0/-1. */
|
||||
void group_policy_encode(const struct group_policy* policy, uint8_t out[GROUP_POLICY_WIRE_SIZE]);
|
||||
int group_policy_decode(struct sqlite3* db, const char* channel, const uint8_t* wire, struct group_policy* out);
|
||||
/* Применить более новую проверенную политику В транзакции caller; 1=изменена,0=прежняя,-1=ошибка. */
|
||||
int group_policy_apply(struct sqlite3* db, const char* channel, const struct group_policy* policy);
|
||||
/* Пересчитать admitted атомарно, затем снять недопущенные сессии; 0/-1. */
|
||||
int group_policy_refresh(struct UTUN_INSTANCE* inst, const char* channel);
|
||||
/* Рассчитать и записать допуск В открытой транзакции caller. 0/-1; out принадлежит caller.
|
||||
* После COMMIT вызвать publish, после ROLLBACK — discard. До publish внешних callbacks нет. */
|
||||
int group_policy_project(struct UTUN_INSTANCE* inst, const char* channel, struct group_policy_update** out);
|
||||
/* Уведомить сервисы после commit и освободить update; callbacks могут удалять группу. */
|
||||
void group_policy_publish(struct UTUN_INSTANCE* inst, struct group_policy_update* update);
|
||||
/* Освободить расчёт без уведомлений (rollback/stop); NULL допустим. */
|
||||
void group_policy_discard(struct group_policy_update* update);
|
||||
/* Проверить цепочки указанных записей до ключа группы, независимо от текущих ролей/отзыва.
|
||||
* 1 — все подтверждены, 0 — цепочка неполна/невалидна, -1 — SQL/crypto отказ.
|
||||
* Ничего не записывает; допускает транзакцию с ещё не опубликованным составом. */
|
||||
int group_policy_verify_members(struct UTUN_INSTANCE* inst, const char* channel, const uint64_t* nodes, size_t count);
|
||||
/* Текущее право подписи приглашения; 1/0/-1, ошибка никогда не разрешает действие. */
|
||||
int group_policy_can_invite(struct UTUN_INSTANCE* inst, const char* channel, uint64_t inviter);
|
||||
/* Предпросмотр/применение: token=NULL — preview; иначе проверка Merkle token и commit.
|
||||
* keep_json — массив decimal node ID в строках, NULL означает []. all_members=0/1.
|
||||
* Результат JSON принадлежит caller (u_free), включая ошибку/conflict. 0/-1/-2(conflict).
|
||||
* Чужая транзакция запрещена. Переподписывает только текущих проверенных участников. */
|
||||
int group_policy_command(struct UTUN_INSTANCE* inst, const char* channel, int all_members,
|
||||
const char* token, const char* keep_json, char** result);
|
||||
|
||||
/* Один u_calloc-блок, включая NUL-terminated keep[]; пустой token означает preview.
|
||||
* UI получает CHAT_EVT_GROUP_POLICY_RESULT: request/group:8LE + JSON. */
|
||||
struct group_policy_request {
|
||||
struct UTUN_INSTANCE* inst; /* Заполняется мостом при постановке в uasync. */
|
||||
uint64_t id, group; /* Корреляция запроса интерфейса и целевая группа. */
|
||||
int all_members; /* Предлагаемый режим 0/1. */
|
||||
char token[65]; /* Merkle root из preview; пусто для чтения. */
|
||||
char keep[]; /* JSON-массив строковых node ID. */
|
||||
};
|
||||
/* Выполнить команду, послать результат события и освободить request (включая отказ). */
|
||||
void group_policy_gui_command(void* request);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -81,7 +81,8 @@ static int select_nodes(struct UTUN_INSTANCE* inst, uint64_t channel, struct inv
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "invite_build: inactive context/invalid channel=%llu", (unsigned long long)channel); return -1;
|
||||
}
|
||||
char sql[256];
|
||||
snprintf(sql, sizeof(sql), "SELECT node_id,node_type FROM peers_%llu WHERE source=0 AND deleted=0 ORDER BY node_id<0,node_id",
|
||||
snprintf(sql, sizeof(sql), "SELECT node_id,node_type FROM peers_%llu"
|
||||
" WHERE record_state=0 AND source=0 AND deleted=0 AND admitted=1 ORDER BY node_id<0,node_id",
|
||||
(unsigned long long)channel);
|
||||
sqlite3_stmt* st = NULL; result->priority = 4;
|
||||
int rc = sqlite3_prepare_v2(cc->db, sql, -1, &st, NULL);
|
||||
|
||||
@@ -7,6 +7,23 @@
|
||||
* Участие в CHAT-группе определяется локальной таблицей peers_<channel_id>.
|
||||
* Сетевые адреса принадлежат topo_node и не входят в блок участника.
|
||||
* Модуль живёт внутри чат-сервиса; все операции и callbacks — в потоке uasync.
|
||||
*
|
||||
* Как принимается состав группы:
|
||||
* Отправитель проверяет, можно ли этому узлу получить состав данной группы.
|
||||
* Получатель сохраняет новые версии в той же таблице с пометкой «не проверена»,
|
||||
* связывая их с прежними проверенными записями. Пока идёт обмен, сервисы используют
|
||||
* только прежние записи: полученные данные ещё не меняют участников и их права.
|
||||
* Когда состав получен, узел проверяет подписи, цепочки приглашений, правила группы
|
||||
* и собственное право пользоваться данными. Затем одной транзакцией принимает
|
||||
* проверенные версии и пересчитывает права. Для неверной новой версии сохраняется
|
||||
* прежняя, если она всё ещё действительна; если обе неверны, удаляются обе версии
|
||||
* только этого участника. Исключение самого узла не удаляет весь состав группы.
|
||||
* Подписанный отзыв прав сохраняется: он не позволяет вернуть старые права из бэкапа.
|
||||
* Недостающий ключ или ошибка проверки — повод отложить решение, а не удалить запись.
|
||||
* У каждого параллельного обмена свой sync_id: отмена одного не затрагивает другие.
|
||||
* При завершении бэкап берётся с актуальной записи: старый обмен не затирает
|
||||
* более свежие проверенные изменения. При обрыве прежние записи остаются;
|
||||
* после перезапуска незавершённые версии убираются. Уведомления идут после commit.
|
||||
*/
|
||||
|
||||
#ifndef MEMBER_SYNC_H
|
||||
@@ -40,7 +57,7 @@ enum ms_author_status {
|
||||
enum ms_author_status member_sync_author_key(struct UTUN_INSTANCE* inst, const char* channel,
|
||||
uint64_t author, uint8_t key[32]);
|
||||
|
||||
/* Проверить source=0/deleted=0, привязку node_id/X25519 и доверенный Ed25519,
|
||||
/* Проверить source=0/deleted=0/admitted=1, привязку node_id/X25519 и доверенный Ed25519,
|
||||
* join_sig и подпись непосредственного пригласившего (либо ключом канала для signed_by=0).
|
||||
* Не обходит цепочку предков. inst/channel/key обязательны, channel — канонический uint64 decimal.
|
||||
* 0 — ключ записан в key[32], -1 — отказ, key не меняется. */
|
||||
@@ -78,20 +95,20 @@ struct ms_member_rec {
|
||||
const uint8_t* adm_tags_sig; /* 64 байта, может быть NULL */
|
||||
int storage; /* вход не используется: сохраняемое значение выводится из adm_tags.storage="yes" */
|
||||
uint64_t signed_by; /* дерево приглашений: 0 = корень (подписан ключом группы), иначе node_id инвайтера */
|
||||
const uint8_t* signature; /* 64 байта, Ed25519 подпись x25519||"group_join" ключом родителя (или канальным ed25519) */
|
||||
const uint8_t* signature; /* Ed25519 подпись join_sig||"group_join" ключом инвайтера/группы: привязана к группе и эпохе. */
|
||||
};
|
||||
|
||||
/* ── Дерево приглашений (подпись pubkey узла) ── */
|
||||
/* ── Дерево приглашений (подтверждение конкретного JOIN) ── */
|
||||
|
||||
#define MEMBER_SIGN_SUFFIX "group_join"
|
||||
#define MEMBER_SIGN_SUFFIX_LEN 10
|
||||
#define MEMBER_SIGN_MSG_SIZE (SC_PUBKEY_SIZE + MEMBER_SIGN_SUFFIX_LEN) /* 42 */
|
||||
#define MEMBER_SIGN_MSG_SIZE (64 + MEMBER_SIGN_SUFFIX_LEN) /* 74 */
|
||||
|
||||
/* Собрать сообщение подписи: x25519(32) || "group_join". Возвращает 42 или -1. */
|
||||
int member_sync_build_sign_msg(const uint8_t* x25519, uint8_t* out, int out_sz);
|
||||
/* Собрать сообщение подтверждения: join_sig(64) || "group_join". Возвращает 74 или -1. */
|
||||
int member_sync_build_sign_msg(const uint8_t* join_sig, uint8_t* out, int out_sz);
|
||||
|
||||
/* Подписать pubkey узла: sign(signer_ed_priv, x25519||"group_join"). Возвращает 0/-1. */
|
||||
int member_sync_sign_pubkey(const uint8_t* signer_ed_priv, const uint8_t* x25519, uint8_t sig[64]);
|
||||
/* Подтвердить конкретный JOIN (группа/узел/эпоха уже связаны join_sig). Возвращает 0/-1. */
|
||||
int member_sync_sign_join(const uint8_t* signer_ed_priv, const uint8_t* join_sig, uint8_t sig[64]);
|
||||
|
||||
/* Верифицировать подпись дерева мембера:
|
||||
* signed_by==0 → ключ канала (ch_ed25519), иначе ed25519 pubkey узла signed_by.
|
||||
@@ -132,9 +149,9 @@ int member_sync_subscribe_group(struct UTUN_INSTANCE* inst, struct TOPO_GROUP* g
|
||||
|
||||
/*
|
||||
* Запустить синхронизацию мемберов канала ch_id с пиром peer.
|
||||
* Сначала вызывает verify_and_purge, затем merkle_sync_start(MEMBERS).
|
||||
* Проверяет право обмена/запроса, затем вызывает merkle_sync_start(MEMBERS).
|
||||
* 0 — запрос принят; <0 — не принят, done_cb не вызывается (см. merkle_sync.h).
|
||||
* Ошибка очистки отклоняет запрос до merkle_sync_start, без done callback.
|
||||
* Прежние записи сохраняются для проверки и fallback после получения различий.
|
||||
*/
|
||||
int member_sync_start(struct UTUN_INSTANCE* inst, uint64_t peer,
|
||||
const char* ch_id, merkle_sync_done_cb done_cb, void* arg);
|
||||
@@ -244,7 +261,7 @@ int member_sync_verify_and_purge(struct UTUN_INSTANCE* inst, const char* ch_id)
|
||||
* ранее завершённые каналы сохраняют результат. Не держит SQLite cursor через callbacks. */
|
||||
int member_sync_verify_and_purge_all(struct UTUN_INSTANCE* inst);
|
||||
|
||||
/* COUNT(*) всех строк, включая placeholders/deleted; неверный аргумент/ошибка SQL дают 0. */
|
||||
/* Число опубликованных строк, включая placeholders/deleted; неверный аргумент/ошибка SQL дают 0. */
|
||||
int member_sync_count(struct UTUN_INSTANCE* inst, const char* ch_id);
|
||||
|
||||
/*
|
||||
@@ -256,6 +273,8 @@ int member_sync_count(struct UTUN_INSTANCE* inst, const char* ch_id);
|
||||
typedef void (*node_props_changed_fn)(uint64_t node_id, const char* adm_tags, const char* channel_id, void* arg);
|
||||
/* 0 — зарегистрирован, -1 — неверные аргументы/память; потребитель может откатить свой init. */
|
||||
int member_sync_add_props_cbk(struct UTUN_INSTANCE* inst, node_props_changed_fn fn, void* arg);
|
||||
/* После commit допуска/свойств: загрузить текущие теги и уведомить потребителей без открытого SQL cursor. */
|
||||
void member_sync_notify_props(struct UTUN_INSTANCE* inst, const char* channel, uint64_t node_id);
|
||||
void member_sync_remove_props_cbk(struct UTUN_INSTANCE* inst, node_props_changed_fn fn, void* arg);
|
||||
|
||||
/* После внешнего commit уведомить MEMBERS, свойства и планировщик CHAT-группы;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# member_sync — адаптер участников каналов
|
||||
|
||||
Поверх [merkle_sync](merkle_sync_doc.md) синхронизирует source=0 записи таблиц peers_<channel_id>.
|
||||
Поверх [merkle_sync](merkle_sync_doc.md) синхронизирует опубликованные source=0 записи таблиц peers_<channel_id>
|
||||
и подписанную политику приглашений. Правила допуска и переподписи: [group_invite_policy](../../doc/group_invite_policy.txt).
|
||||
Транспортный движок не занимается подписями и версиями участников.
|
||||
|
||||
Сверка контрактов member_sync.h и этого документа с реализацией завершена 26.10.2026.
|
||||
@@ -26,9 +27,26 @@ ver — каноническая десятичная строка1..INT_MAX,б
|
||||
Удаление используется для повреждённых записей. Конфликт одинаковых версий с разным содержимым
|
||||
не разрешается порядком доставки: отсутствие прогресса завершает Merkle-раунд ошибкой конфликта.
|
||||
|
||||
Входящая страница сначала целиком проверяется на корректность раскладки, длин и строк.
|
||||
Затем записи применяются под savepoint member_page. Ошибка откатывает всю страницу вместе с индексом.
|
||||
Уведомления GUI/топологии и changed выполняются после commit.
|
||||
Входящая страница целиком проверяется на раскладку, длины, строки и границы листа/курсора.
|
||||
Новые версии сохраняются в той же peers-таблице: record_state=1, sync_id своего обмена,
|
||||
previous_id прежней записи, sync_record с исходным wire. Служебная строка node_id=0
|
||||
хранит подписанную политику; её AUTOINCREMENT id даёт уникальный sync_id.
|
||||
Сервисы, GUI, исходящие страницы и Merkle видят только record_state=0.
|
||||
|
||||
После получения всех различий BEGIN IMMEDIATE копирует актуальные опубликованные строки
|
||||
в бэкап record_state=2 этого обмена. Сравнение версий, проверка подписей/цепочек,
|
||||
выбор новой записи или валидного бэкапа, admitted, общие идентичности и индексы
|
||||
входят в одну транзакцию. Если обе версии неверны, удаляется только этот участник.
|
||||
Подписанное исключение остаётся валидной записью; даже повреждение профиля не стирает
|
||||
независимый более свежий owner-блок при его восстановлении. Исключение самого узла
|
||||
закрывает доступ, но не удаляет остальные записи. Неполная цепочка или SQL/crypto-отказ
|
||||
откатывают обмен без разрушения прежнего состава. Уведомления идут после commit.
|
||||
|
||||
Параллельные обмены не видят pending друг друга. Поздний обмен сравнивается с текущей,
|
||||
а не начальной версией: свежие изменения и отзывы не откатываются. Отмена удаляет
|
||||
только свой sync_id; перезапуск удаляет все незавершённые версии. Формат peers изменён
|
||||
без миграции старых таблиц: неподдерживаемая схема явно отклоняется.
|
||||
|
||||
Каждый member_sync_apply_record также имеет savepoint, поэтому ошибка пересчёта не оставляет запись без индекса.
|
||||
Неудачный ROLLBACK TO не сопровождается RELEASE частичных данных:собственная операция
|
||||
пробует полный ROLLBACK,при чужой транзакции полный откат обязан выполнить caller.
|
||||
@@ -73,27 +91,33 @@ CHAT-группы и уведомляет props-потребителей; общ
|
||||
и повторно вызвать очистку; stop/destroy выполняются вне callbacks.
|
||||
|
||||
Локальный верификатор возвращает 1 (сохранить), 0 (доказанная порча), -1 (операционная ошибка).
|
||||
Отсутствие подписи/ожидаемой зависимости сохраняет запись; отказ SQL/OpenSSL не считается
|
||||
Отсутствие ожидаемой зависимости сохраняет запись; отказ SQL/OpenSSL не считается
|
||||
доказательством неверной подписи. verify_signature отдельно различает
|
||||
MS_VERIFY_DEFERRED и MS_VERIFY_ERROR. Проверка source=1 placeholders не выполняется.
|
||||
verify_and_purge_all останавливается на первом отказе; атомарность — по каналу,
|
||||
результаты ранее очищенных каналов сохраняются. init освобождает движок при отказе очистки,
|
||||
start отклоняет запрос до создания Merkle-сессии, без done callback.
|
||||
результаты ранее очищенных каналов сохраняются. init освобождает движок при отказе очистки.
|
||||
start проверяет право запроса, но не очищает прежние версии: они нужны для fallback при commit_pull.
|
||||
|
||||
Проверяются привязка node_id к X25519, доверенный Ed25519, подписи блока участника и владельца.
|
||||
Для дерева приглашений родитель может прийти позже ребёнка; finish повторно проверяет зависимости
|
||||
перед подтверждением корня. Это не отдельная карантинная таблица: промежуточные записи существуют в БД
|
||||
до finish, поэтому MT_OK нельзя заменять фактом их присутствия.
|
||||
finish проверяет имеющиеся ненулевые подписи непосредственных пригласивших;
|
||||
рекурсивного обхода цепочки предков нет, отсутствие сертификата само по себе пропускается.
|
||||
validate_pubkey и validate_peer проверяют наличие строки без фильтра source/deleted;
|
||||
допуск CHAT-группы отдельно проверяет topo_node_sqlite_member_allowed.
|
||||
Для дерева приглашений родитель может прийти позже ребёнка; commit_pull проверяет
|
||||
целый полученный состав до публикации общих ключей. group_policy обходит цепочки от
|
||||
корней и рассчитывает admitted по текущим правам. validate_peer требует живой CHAT-группы,
|
||||
допуска self/peer и совпадения ключа участника с аутентифицированным ETCP.
|
||||
validate_request разрешает лишь запрос к известному источнику с проверенным JOIN,
|
||||
когда собственного допуска ещё нет. Источник самостоятельно проверяет право выдачи;
|
||||
получатель не отдаёт хеши/записи в обратную сторону и проверяет результат после DONE.
|
||||
validate_pubkey остаётся вспомогательной проверкой наличия опубликованной строки;
|
||||
он не является разрешением на доступ.
|
||||
author_key требует source=0/deleted=0, доверенный ключ, join и сертификат непосредственного пригласившего.
|
||||
Возвращает ACTIVE, INACTIVE, PENDING, INVALID или ERROR; выходной ключ меняется только при ACTIVE.
|
||||
PENDING означает недостающий ключ/сертификат, а не отзыв членства или ошибку SQLite/OpenSSL.
|
||||
source=0 само по себе не доказывает полный допуск: dependency может разрешиться позднее.
|
||||
Ключи nodes общие; поступление ключа через другой канал пересматривает авторов с таким node_id/signed_by.
|
||||
verified_ed25519 сохраняет строгий контракт 0/-1 для radio/attachments и разрешает только ACTIVE.
|
||||
verified_ed25519 сохраняет строгий контракт 0/-1 для radio/attachments и разрешает только ACTIVE с admitted=1.
|
||||
author_key остаётся криптографической проверкой для исторических данных и не требует admitted.
|
||||
Подпись приглашения покрывает join_sig || "group_join": сертификат привязан к группе и эпохе.
|
||||
Переподпись одной эпохи предпочитает signed_by=0, затем меньший ID пригласившего;
|
||||
она обновляется независимо от версии профиля и не переносится между эпохами JOIN.
|
||||
|
||||
## Канонический хеш
|
||||
|
||||
@@ -109,7 +133,9 @@ userinfo ограничен 1024 байтами, adm_tags — 8192 байтам
|
||||
|
||||
## Страница на проводе
|
||||
|
||||
Начинается с count:2 big-endian. Далее count записей:
|
||||
Начинается с count:2 big-endian и политики: version:8BE, all_members:1, signature:64.
|
||||
Политика передаётся даже при count=0; ненулевая версия входит в Merkle как виртуальный лист с ключом 0.
|
||||
Далее count записей:
|
||||
node_id:8, x25519:32, ed25519:32, flags:1,
|
||||
при HAS_JOIN — join_sig:64 и join_ts:8,
|
||||
update_sig:64, update_ts:8, userinfo_len:2, userinfo,
|
||||
|
||||
@@ -15,14 +15,20 @@
|
||||
* round_id:16, accept_id:16, payload. round — диагностический счётчик;
|
||||
* пара случайных идентификаторов связывает запрос с текущим handshake обеих сторон.
|
||||
* Все целые заголовка big-endian. Один запрос на проход и один исходящий пакет на сессию.
|
||||
* Меньший node_id ведёт раунд: pull -> TURN -> reverse pull -> CHECK -> DONE. */
|
||||
* Меньший node_id ведёт обычный раунд: pull -> TURN -> reverse pull -> CHECK -> DONE.
|
||||
* В одностороннем запросе получатель ведёт BEGIN -> pull -> CHECK -> DONE;
|
||||
* источник подтверждает неизменность данных, после чего получатель проверяет и применяет их. */
|
||||
#define MS_HEADER 55
|
||||
#define MS_TOKEN_SIZE 16
|
||||
#define MS_QUERY_SIZE (10 + MT_KEY_SIZE)
|
||||
#define MS_PAGE_HEADER (1 + MT_KEY_SIZE)
|
||||
#define MS_PROGRESS_TIMEOUT_TB (30u * 10000u)
|
||||
/* В type два флага отделяют pull от обычного обмена и встречного pull того же пира. */
|
||||
#define MS_READ_ONLY 0x40
|
||||
#define MS_RESPONSE 0x80
|
||||
enum { MS_WAKE = 0x10, MS_BEGIN, MS_READY, MS_QUERY, MS_HASHES, MS_PAGE, MS_TURN, MS_CHECK, MS_DONE, MS_ERROR };
|
||||
enum { MS_IDLE, MS_BEGIN_WAIT, MS_PULL, MS_SERVE, MS_DONE_WAIT, MS_FAILED, MS_FAILING };
|
||||
enum { MS_IDLE, MS_BEGIN_WAIT, MS_PULL, MS_SERVE, MS_DONE_WAIT, MS_FAILED, MS_FAILING, MS_COMPLETE };
|
||||
enum { MS_EXCHANGE, MS_READER, MS_WRITER };
|
||||
|
||||
struct ms_request {
|
||||
struct ms_request* next;
|
||||
@@ -53,6 +59,7 @@ struct ms_session {
|
||||
void* work;
|
||||
void* deadline;
|
||||
void* pull;
|
||||
void* finished_pull; /* Отсоединяется до внешних callbacks; публикуется на выходе из receive. */
|
||||
int failure_error;
|
||||
struct ms_request* requests;
|
||||
uint64_t ns_id, peer, round, revision, verified_revision;
|
||||
@@ -64,6 +71,7 @@ struct ms_session {
|
||||
struct ms_frame stack[MT_MAX_LEVEL + 1];
|
||||
uint8_t previous_roots[2 * MT_HASH_SIZE];
|
||||
int previous_valid;
|
||||
uint8_t mode; /* EXCHANGE либо локальный READER/WRITER независимого однонаправленного запроса. */
|
||||
uint64_t sent_bytes, received_bytes;
|
||||
};
|
||||
|
||||
@@ -103,9 +111,9 @@ static int ms_namespace(const char* ns, uint64_t* id) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
static struct ms_session* ms_find(struct merkle_sync* ms, struct ETCP_CONN* conn, uint8_t dataset, uint64_t ns_id) {
|
||||
static struct ms_session* ms_find(struct merkle_sync* ms, struct ETCP_CONN* conn, uint8_t dataset, uint64_t ns_id, uint8_t mode) {
|
||||
for (struct ms_session* s = ms->sessions; s; s = s->next)
|
||||
if (s->conn == conn && s->dataset->id == dataset && s->ns_id == ns_id) return s;
|
||||
if (s->conn == conn && s->dataset->id == dataset && s->ns_id == ns_id && s->mode == mode) return s;
|
||||
return NULL;
|
||||
}
|
||||
|
||||
@@ -114,10 +122,11 @@ static void ms_notify(struct ms_session* s, int result) {
|
||||
struct ms_request* r = s->requests;
|
||||
s->requests = NULL;
|
||||
uint64_t peer = s->peer;
|
||||
uint8_t mode = s->mode;
|
||||
char ns[sizeof(s->ns)]; memcpy(ns, s->ns, sizeof(ns));
|
||||
void (*on_result)(void*, const char*, uint64_t, int) = s->dataset->ops->on_result;
|
||||
void* ctx = s->dataset->data_ctx;
|
||||
if (on_result) on_result(ctx, ns, peer, result);
|
||||
if (on_result && mode != MS_WRITER) on_result(ctx, ns, peer, result);
|
||||
while (r) {
|
||||
struct ms_request* next = r->next;
|
||||
merkle_sync_done_cb cb = r->cb; void* arg = r->arg;
|
||||
@@ -137,6 +146,14 @@ static void ms_abort_pull(struct ms_session* s) {
|
||||
s->dataset->ops->abort_pull(s->dataset->data_ctx, pull);
|
||||
}
|
||||
|
||||
/* Commit не вызывает внешний код; публикация откладывается до безопасного выхода из receive. */
|
||||
static int ms_commit_pull(struct ms_session* s) {
|
||||
int rc = s->dataset->ops->commit_pull(s->dataset->data_ctx, s->pull);
|
||||
if (rc >= 0 && s->dataset->ops->publish_pull) { s->finished_pull = s->pull; s->pull = NULL; }
|
||||
else ms_abort_pull(s);
|
||||
return rc;
|
||||
}
|
||||
|
||||
static void ms_stop_deadline(struct ms_session* s) {
|
||||
if (s->deadline) uasync_cancel_timeout(s->ms->inst->ua, s->deadline);
|
||||
s->deadline = NULL;
|
||||
@@ -171,6 +188,7 @@ static void ms_free(struct ms_session* s) {
|
||||
ms_stop_deadline(s);
|
||||
ms_abort_pull(s);
|
||||
ms_discard_tx(s);
|
||||
if (s->finished_pull) s->dataset->ops->abort_pull(s->dataset->data_ctx, s->finished_pull);
|
||||
while (s->requests) {
|
||||
struct ms_request* r = s->requests;
|
||||
s->requests = r->next; u_free(r);
|
||||
@@ -179,7 +197,7 @@ static void ms_free(struct ms_session* s) {
|
||||
u_free(s);
|
||||
}
|
||||
|
||||
static struct ms_session* ms_create(struct merkle_sync* ms, struct ETCP_CONN* conn, uint8_t dataset, uint64_t ns_id) {
|
||||
static struct ms_session* ms_create(struct merkle_sync* ms, struct ETCP_CONN* conn, uint8_t dataset, uint64_t ns_id, uint8_t mode) {
|
||||
if (!conn || !conn->send_input_q || !conn->initialized || !conn->links_up || conn->state == 2) return NULL;
|
||||
struct ms_session* s = u_calloc(1, sizeof(*s));
|
||||
if (!s) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: session allocation failed"); return NULL; }
|
||||
@@ -188,10 +206,11 @@ static struct ms_session* ms_create(struct merkle_sync* ms, struct ETCP_CONN* co
|
||||
s->peer = conn->peer_node_id; s->ns_id = ns_id;
|
||||
snprintf(s->ns, sizeof(s->ns), "%llu", (unsigned long long)ns_id);
|
||||
snprintf(s->tree_ns, sizeof(s->tree_ns), "%u:%s", dataset, s->ns);
|
||||
s->leader = ms->inst->node_id < s->peer;
|
||||
s->mode = mode;
|
||||
s->leader = mode == MS_READER || (mode == MS_EXCHANGE && ms->inst->node_id < s->peer);
|
||||
s->next = ms->sessions; ms->sessions = s;
|
||||
DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: attach dataset=%u ns=%s peer=%016llx conn=%p leader=%d",
|
||||
s->dataset->id, s->ns, (unsigned long long)s->peer, (void*)conn, s->leader);
|
||||
DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: attach dataset=%u ns=%s peer=%016llx conn=%p leader=%d mode=%u",
|
||||
s->dataset->id, s->ns, (unsigned long long)s->peer, (void*)conn, s->leader, mode);
|
||||
return s;
|
||||
}
|
||||
|
||||
@@ -207,8 +226,14 @@ static int ms_root(struct ms_session* s, uint8_t root[MT_HASH_SIZE]) {
|
||||
return merkle_sync_read_hash(s->ms->inst, s->dataset->id, s->ns, 0, 0, root);
|
||||
}
|
||||
|
||||
static int ms_can_run(struct ms_dataset* model, struct ETCP_CONN* conn, const char* ns) {
|
||||
if (model->ops->validate_peer && model->ops->validate_peer(model->data_ctx, ns, conn->peer_node_id) != 1) return 0;
|
||||
static int ms_allowed(struct ms_dataset* model, struct ETCP_CONN* conn, const char* ns, uint8_t mode) {
|
||||
if (mode == MS_READER)
|
||||
return model->ops->validate_request && model->ops->validate_request(model->data_ctx, ns, conn->peer_node_id) == 1;
|
||||
return !model->ops->validate_peer || model->ops->validate_peer(model->data_ctx, ns, conn->peer_node_id) == 1;
|
||||
}
|
||||
|
||||
static int ms_can_run(struct ms_dataset* model, struct ETCP_CONN* conn, const char* ns, uint8_t mode) {
|
||||
if (!ms_allowed(model, conn, ns, mode)) return 0;
|
||||
return model->ops->can_run ? model->ops->can_run(model->data_ctx, ns, conn->peer_node_id) == 1 : !conn->peer_sleep_phase;
|
||||
}
|
||||
|
||||
@@ -217,14 +242,20 @@ static void ms_sent(struct ll_queue* q, void* arg) {
|
||||
struct ll_entry* entry = s->tx;
|
||||
s->tx = NULL;
|
||||
if (!entry) return;
|
||||
int type = entry->dgram[9];
|
||||
int type = entry->dgram[9] & ~(MS_READ_ONLY | MS_RESPONSE);
|
||||
size_t len = entry->len;
|
||||
if (!s->conn->links_up || s->conn->state == 2 || s->conn->close_requested || q != s->conn->send_input_q ||
|
||||
!ms_can_run(s->dataset, s->conn, s->ns)) {
|
||||
const char* unavailable = !s->conn->links_up || s->conn->state == 2 || s->conn->close_requested || q != s->conn->send_input_q
|
||||
? "connection unavailable" : NULL;
|
||||
if (!unavailable && !ms_can_run(s->dataset, s->conn, s->ns, s->mode)) unavailable = "model send deferred";
|
||||
if (unavailable) {
|
||||
queue_dgram_free(entry); queue_entry_free(entry);
|
||||
ms_fail(s, MT_ERR_DISCONNECTED, "connection unavailable", 0);
|
||||
ms_fail(s, MT_ERR_DISCONNECTED, unavailable, 0);
|
||||
return;
|
||||
}
|
||||
if (type == MS_DONE && s->mode == MS_WRITER && s->revision != s->verified_revision) {
|
||||
queue_dgram_free(entry); queue_entry_free(entry);
|
||||
ms_fail(s, MT_ERR_CONFLICT, "pull source changed before confirmation", 1); return;
|
||||
}
|
||||
if (etcp_send(s->conn, entry) != 0) {
|
||||
queue_dgram_free(entry); queue_entry_free(entry);
|
||||
ms_fail(s, MT_ERR_IO, "transport rejected packet", 0);
|
||||
@@ -241,8 +272,9 @@ static void ms_sent(struct ll_queue* q, void* arg) {
|
||||
return;
|
||||
}
|
||||
if (type == MS_DONE) {
|
||||
s->state = MS_IDLE;
|
||||
s->state = s->mode == MS_WRITER ? MS_COMPLETE : MS_IDLE;
|
||||
ms_stop_deadline(s);
|
||||
if (s->mode == MS_WRITER) return; /* Выдача данных не подтверждает синхронизацию собственного состава. */
|
||||
if (s->revision == s->verified_revision) {
|
||||
s->dirty = 0;
|
||||
DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: converged dataset=%u ns=%s peer=%016llx round=%llu tx=%llu rx=%llu",
|
||||
@@ -265,7 +297,7 @@ static int ms_send(struct ms_session* s, int type, uint32_t request, const uint8
|
||||
e->len = (uint16_t)(MS_HEADER + len);
|
||||
e->dgram[0] = s->ms->svc_id;
|
||||
ms_write64(e->dgram + 1, s->ns_id);
|
||||
e->dgram[9] = (uint8_t)type;
|
||||
e->dgram[9] = (uint8_t)type | (s->mode ? MS_READ_ONLY : 0) | (s->mode == MS_WRITER ? MS_RESPONSE : 0);
|
||||
ms_write64(e->dgram + 10, type == MS_WAKE ? 0 : s->round);
|
||||
ms_write32(e->dgram + 18, request);
|
||||
e->dgram[22] = s->dataset->id;
|
||||
@@ -342,9 +374,12 @@ static int ms_walk_next(struct ms_session* s) {
|
||||
s->depth--;
|
||||
}
|
||||
s->waiting = 0;
|
||||
if (s->mode == MS_READER) {
|
||||
s->state = MS_DONE_WAIT;
|
||||
return ms_send(s, MS_CHECK, 0, NULL, 0);
|
||||
}
|
||||
if (s->pull) {
|
||||
int rc = s->dataset->ops->commit_pull(s->dataset->data_ctx, s->pull);
|
||||
ms_abort_pull(s);
|
||||
int rc = ms_commit_pull(s);
|
||||
if (rc < 0) return rc;
|
||||
}
|
||||
if (s->leader) { s->state = MS_SERVE; return ms_send(s, MS_TURN, 0, NULL, 0); }
|
||||
@@ -374,7 +409,7 @@ static void ms_work(void* arg) {
|
||||
struct ms_session* s = arg;
|
||||
s->work = NULL;
|
||||
if (s->state != MS_IDLE || !s->dirty || s->tx) return;
|
||||
if (!ms_can_run(s->dataset, s->conn, s->ns)) {
|
||||
if (!ms_can_run(s->dataset, s->conn, s->ns, s->mode)) {
|
||||
ms_fail(s, MT_ERR_DISCONNECTED, "model work deferred", 0); return;
|
||||
}
|
||||
int rc;
|
||||
@@ -388,9 +423,11 @@ void merkle_sync_changed(struct UTUN_INSTANCE* inst, uint8_t dataset, const char
|
||||
if (!ms || !ns) return;
|
||||
for (struct ms_session* s = ms->sessions; s; s = s->next) {
|
||||
if (s->dataset->id != dataset || strcmp(s->ns, ns)) continue;
|
||||
s->revision++; s->dirty = 1;
|
||||
s->revision++;
|
||||
if (s->mode != MS_EXCHANGE) continue; /* Pull — один запрос; изменение источника проверяется перед DONE. */
|
||||
s->dirty = 1;
|
||||
if (s->state == MS_FAILED) { s->state = MS_IDLE; s->previous_valid = 0; }
|
||||
if (s->state == MS_IDLE && ms_can_run(s->dataset, s->conn, s->ns) && ms_schedule(s) < 0) {
|
||||
if (s->state == MS_IDLE && ms_can_run(s->dataset, s->conn, s->ns, s->mode) && ms_schedule(s) < 0) {
|
||||
ms_fail(s, MT_ERR_IO, "change scheduling", 1); return;
|
||||
}
|
||||
}
|
||||
@@ -402,18 +439,19 @@ void merkle_sync_revalidate_peers(struct UTUN_INSTANCE* inst, uint8_t dataset, c
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: invalid permission namespace"); return;
|
||||
}
|
||||
char owned_ns[21]; strcpy(owned_ns, ns);
|
||||
uint64_t after = 0; int after_valid = 0;
|
||||
uint64_t after = 0; uint8_t after_mode = 0; int after_valid = 0;
|
||||
for (;;) {
|
||||
struct merkle_sync* ms = inst ? inst->msync : NULL;
|
||||
if (!ms || !ms->datasets[dataset].ops || !ms->datasets[dataset].ops->validate_peer) return;
|
||||
struct ms_session* selected = NULL;
|
||||
for (struct ms_session* s = ms->sessions; s; s = s->next)
|
||||
if (s->dataset->id == dataset && !strcmp(s->ns, owned_ns) && (!after_valid || s->peer > after)
|
||||
&& (!selected || s->peer < selected->peer)) selected = s;
|
||||
if (s->dataset->id == dataset && !strcmp(s->ns, owned_ns) &&
|
||||
(!after_valid || s->peer > after || (s->peer == after && s->mode > after_mode)) &&
|
||||
(!selected || s->peer < selected->peer || (s->peer == selected->peer && s->mode < selected->mode))) selected = s;
|
||||
if (!selected) return;
|
||||
after = selected->peer; after_valid = 1;
|
||||
if (selected->state != MS_FAILED
|
||||
&& selected->dataset->ops->validate_peer(selected->dataset->data_ctx, owned_ns, after) != 1)
|
||||
after = selected->peer; after_mode = selected->mode; after_valid = 1;
|
||||
if (selected->state != MS_FAILED && selected->state != MS_COMPLETE &&
|
||||
!ms_allowed(selected->dataset, selected->conn, owned_ns, selected->mode))
|
||||
ms_fail(selected, MT_ERR_DATA, "model permission revoked", 0);
|
||||
/* Callback мог удалить selected и соседние сессии: следующий поиск начинается заново. */
|
||||
}
|
||||
@@ -456,7 +494,8 @@ int merkle_sync_recompute_path(struct UTUN_INSTANCE* inst, uint8_t dataset, cons
|
||||
}
|
||||
|
||||
static int ms_serve_query(struct ms_session* s, uint32_t request, const uint8_t* p, size_t len) {
|
||||
if (s->state != MS_SERVE || len != MS_QUERY_SIZE || request != s->remote_request_id + 1) return MT_ERR_PROTOCOL;
|
||||
if (s->mode == MS_READER || s->state != MS_SERVE || len != MS_QUERY_SIZE || request != s->remote_request_id + 1)
|
||||
return MT_ERR_PROTOCOL;
|
||||
uint8_t level = p[0];
|
||||
uint64_t prefix = ms_read64(p + 1);
|
||||
merkle_key_t after; memcpy(after.bytes, p + 10, MT_KEY_SIZE);
|
||||
@@ -527,24 +566,28 @@ static void ms_receive(struct ETCP_CONN* conn, struct ll_entry* entry) {
|
||||
const uint8_t* d = entry->dgram;
|
||||
uint64_t ns_id = ms_read64(d + 1), round = ms_read64(d + 10);
|
||||
uint32_t request = ms_read32(d + 18);
|
||||
uint8_t type = d[9], dataset = d[22];
|
||||
uint8_t type = d[9] & ~(MS_READ_ONLY | MS_RESPONSE), dataset = d[22];
|
||||
uint8_t mode = d[9] & MS_READ_ONLY ? (d[9] & MS_RESPONSE ? MS_READER : MS_WRITER) : MS_EXCHANGE;
|
||||
struct ms_dataset* model = &ms->datasets[dataset];
|
||||
if (!model->ops) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: unknown dataset=%u", dataset);
|
||||
if (!model->ops || ((d[9] & MS_RESPONSE) && mode == MS_EXCHANGE) ||
|
||||
(mode != MS_EXCHANGE && !model->ops->validate_request)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: unsupported dataset=%u flags=%02x", dataset, d[9]);
|
||||
queue_dgram_free(entry); queue_entry_free(entry); return;
|
||||
}
|
||||
char ns[21]; snprintf(ns, sizeof(ns), "%llu", (unsigned long long)ns_id);
|
||||
if (model->ops->validate_peer && model->ops->validate_peer(model->data_ctx, ns, conn->peer_node_id) != 1) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: unauthorized ns=%s peer=%016llx", ns, (unsigned long long)conn->peer_node_id);
|
||||
if (!ms_allowed(model, conn, ns, mode)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: unauthorized dataset=%u ns=%s peer=%016llx type=%u",
|
||||
dataset, ns, (unsigned long long)conn->peer_node_id, type);
|
||||
queue_dgram_free(entry); queue_entry_free(entry); return;
|
||||
}
|
||||
if (!ms_can_run(model, conn, ns)) {
|
||||
if (!ms_can_run(model, conn, ns, mode)) {
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: model receive deferred dataset=%u ns=%s peer=%016llx",
|
||||
dataset, ns, (unsigned long long)conn->peer_node_id);
|
||||
queue_dgram_free(entry); queue_entry_free(entry); return;
|
||||
}
|
||||
struct ms_session* s = ms_find(ms, conn, dataset, ns_id);
|
||||
if (!s && (type == MS_WAKE || type == MS_BEGIN)) s = ms_create(ms, conn, dataset, ns_id);
|
||||
struct ms_session* s = ms_find(ms, conn, dataset, ns_id, mode);
|
||||
if (!s && ((mode == MS_EXCHANGE && (type == MS_WAKE || type == MS_BEGIN)) || (mode == MS_WRITER && type == MS_BEGIN)))
|
||||
s = ms_create(ms, conn, dataset, ns_id, mode);
|
||||
if (!s) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: no session ns=%s type=%u", ns, type);
|
||||
queue_dgram_free(entry); queue_entry_free(entry); return;
|
||||
@@ -569,7 +612,8 @@ static void ms_receive(struct ETCP_CONN* conn, struct ll_entry* entry) {
|
||||
goto done;
|
||||
}
|
||||
if (type == MS_WAKE) {
|
||||
if (!s->leader || round || request || len || !ms_token_empty(d + 23) || !ms_token_empty(d + 39)) rc = MT_ERR_PROTOCOL;
|
||||
if (mode != MS_EXCHANGE || !s->leader || round || request || len ||
|
||||
!ms_token_empty(d + 23) || !ms_token_empty(d + 39)) rc = MT_ERR_PROTOCOL;
|
||||
else {
|
||||
/* Follower мог отменить и заново подключить namespace. Старый запрос уже некому обслужить. */
|
||||
ms_discard_tx(s);
|
||||
@@ -589,6 +633,7 @@ static void ms_receive(struct ETCP_CONN* conn, struct ll_entry* entry) {
|
||||
memcpy(s->accept_id, token, sizeof(token));
|
||||
s->round = round; s->state = MS_SERVE; s->waiting = 0;
|
||||
s->request_id = s->remote_request_id = 0; s->dirty = 0;
|
||||
s->verified_revision = s->revision;
|
||||
rc = ms_send(s, MS_READY, 0, NULL, 0) < 0 ? MT_ERR_IO : 0;
|
||||
}
|
||||
} else if (type == MS_READY) {
|
||||
@@ -603,11 +648,18 @@ static void ms_receive(struct ETCP_CONN* conn, struct ll_entry* entry) {
|
||||
else if (type == MS_HASHES) rc = ms_receive_hashes(s, request, p, len);
|
||||
else if (type == MS_PAGE) rc = ms_receive_page(s, request, p, len);
|
||||
else if (type == MS_TURN) {
|
||||
if (s->leader || s->state != MS_SERVE || request || len) rc = MT_ERR_PROTOCOL;
|
||||
if (mode != MS_EXCHANGE || s->leader || s->state != MS_SERVE || request || len) rc = MT_ERR_PROTOCOL;
|
||||
else rc = ms_pull_start(s);
|
||||
} else if (type == MS_CHECK && mode == MS_WRITER) {
|
||||
if (s->state != MS_SERVE || request || len) rc = MT_ERR_PROTOCOL;
|
||||
else if (s->revision != s->verified_revision) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: pull source changed ns=%s peer=%016llx revision=%llu/%llu",
|
||||
ns, (unsigned long long)s->peer, (unsigned long long)s->revision, (unsigned long long)s->verified_revision);
|
||||
rc = MT_ERR_CONFLICT;
|
||||
} else rc = ms_send(s, MS_DONE, 0, NULL, 0) < 0 ? MT_ERR_IO : 0;
|
||||
} else if (type == MS_CHECK) {
|
||||
uint8_t roots[2 * MT_HASH_SIZE];
|
||||
if (!s->leader || s->state != MS_SERVE || request || len != MT_HASH_SIZE) rc = MT_ERR_PROTOCOL;
|
||||
if (mode != MS_EXCHANGE || !s->leader || s->state != MS_SERVE || request || len != MT_HASH_SIZE) rc = MT_ERR_PROTOCOL;
|
||||
else if (ms_root(s, roots) < 0) rc = MT_ERR_IO;
|
||||
else if (!memcmp(roots, p, MT_HASH_SIZE)) {
|
||||
if (model->ops->finish && model->ops->finish(model->data_ctx, ns) < 0) rc = MT_ERR_DATA;
|
||||
@@ -623,9 +675,21 @@ static void ms_receive(struct ETCP_CONN* conn, struct ll_entry* entry) {
|
||||
s->state = MS_IDLE; s->dirty = 1; rc = ms_schedule(s);
|
||||
}
|
||||
}
|
||||
} else if (type == MS_DONE && mode == MS_READER) {
|
||||
if (s->state != MS_DONE_WAIT || request || len || !s->pull) rc = MT_ERR_PROTOCOL;
|
||||
else {
|
||||
rc = ms_commit_pull(s);
|
||||
if (rc >= 0) {
|
||||
s->state = MS_COMPLETE; s->dirty = 0;
|
||||
ms_stop_deadline(s);
|
||||
DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: pull accepted dataset=%u ns=%s peer=%016llx round=%llu",
|
||||
dataset, ns, (unsigned long long)s->peer, (unsigned long long)s->round);
|
||||
notify = 1;
|
||||
}
|
||||
}
|
||||
} else if (type == MS_DONE) {
|
||||
uint8_t root[MT_HASH_SIZE];
|
||||
if (s->leader || s->state != MS_DONE_WAIT || request || len != MT_HASH_SIZE) rc = MT_ERR_PROTOCOL;
|
||||
if (mode != MS_EXCHANGE || s->leader || s->state != MS_DONE_WAIT || request || len != MT_HASH_SIZE) rc = MT_ERR_PROTOCOL;
|
||||
else if (ms_root(s, root) < 0) rc = MT_ERR_IO;
|
||||
else {
|
||||
s->state = MS_IDLE;
|
||||
@@ -647,9 +711,14 @@ static void ms_receive(struct ETCP_CONN* conn, struct ll_entry* entry) {
|
||||
}
|
||||
} else rc = MT_ERR_PROTOCOL;
|
||||
done:
|
||||
; /* C99: метка должна предшествовать инструкции, не объявлению. */
|
||||
void* finished = s->finished_pull; s->finished_pull = NULL;
|
||||
const struct merkle_sync_data_ops* ops = model->ops;
|
||||
void* data_ctx = model->data_ctx;
|
||||
queue_dgram_free(entry); queue_entry_free(entry);
|
||||
if (rc < 0) { ms_fail(s, rc, "receive", 1); return; }
|
||||
if (notify) ms_notify(s, MT_OK);
|
||||
if (rc < 0) ms_fail(s, rc, "receive", 1);
|
||||
else if (notify) ms_notify(s, MT_OK);
|
||||
if (finished) { ops->publish_pull(data_ctx, finished); ops->abort_pull(data_ctx, finished); }
|
||||
}
|
||||
|
||||
static void ms_conn_status(struct ETCP_CONN* conn, int event, void* arg) {
|
||||
@@ -664,7 +733,7 @@ static void ms_conn_status(struct ETCP_CONN* conn, int event, void* arg) {
|
||||
/* Удаляем до callback, чтобы повторный start не мог сохранить умирающую сессию. */
|
||||
struct ms_request* requests = s->requests; s->requests = NULL;
|
||||
uint64_t peer = s->peer; char ns[21]; memcpy(ns, s->ns, sizeof(ns));
|
||||
void (*on_result)(void*, const char*, uint64_t, int) = s->dataset->ops->on_result;
|
||||
void (*on_result)(void*, const char*, uint64_t, int) = s->mode == MS_WRITER ? NULL : s->dataset->ops->on_result;
|
||||
void* data_ctx = s->dataset->data_ctx;
|
||||
ms_free(s);
|
||||
DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: detach ns=%s peer=%016llx event=%d", ns, (unsigned long long)peer, event);
|
||||
@@ -700,8 +769,8 @@ int merkle_sync_init(struct UTUN_INSTANCE* inst, uint8_t svc_id, const struct me
|
||||
int merkle_sync_register(struct UTUN_INSTANCE* inst, uint8_t dataset, const struct merkle_sync_data_ops* ops, void* data_ctx) {
|
||||
struct merkle_sync* ms = inst ? inst->msync : NULL;
|
||||
if (!ms || ms->datasets[dataset].ops || !ops || !ops->update_bucket_hash || !ops->get_page
|
||||
|| (!ops->apply_items && !ops->apply_page && !ops->stage_page)
|
||||
|| ((ops->begin_pull || ops->commit_pull || ops->abort_pull || ops->stage_page)
|
||||
|| (!ops->apply_items && !ops->apply_page && !ops->stage_page) || (ops->validate_request && !ops->begin_pull)
|
||||
|| ((ops->begin_pull || ops->commit_pull || ops->abort_pull || ops->stage_page || ops->publish_pull)
|
||||
&& !(ops->begin_pull && ops->commit_pull && ops->abort_pull && ops->stage_page))) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: invalid dataset registration dataset=%u", dataset); return -1;
|
||||
}
|
||||
@@ -730,24 +799,29 @@ int merkle_sync_start(struct UTUN_INSTANCE* inst, uint8_t dataset, uint64_t peer
|
||||
uint64_t ns_id;
|
||||
struct ETCP_CONN* conn = inst ? instance_find_conn(inst, peer) : NULL;
|
||||
if (!ms || !ms->datasets[dataset].ops || ms_namespace(ns, &ns_id) < 0 || !conn || !conn->initialized || !conn->links_up || conn->state == 2
|
||||
|| peer == inst->node_id || conn == ms->closing_conn || !ms_can_run(&ms->datasets[dataset], conn, ns)) {
|
||||
|| conn->close_requested || peer == inst->node_id || conn == ms->closing_conn) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: start unavailable ns=%s peer=%016llx", ns ? ns : "-", (unsigned long long)peer);
|
||||
return -1;
|
||||
}
|
||||
uint8_t mode = ms_allowed(&ms->datasets[dataset], conn, ns, MS_EXCHANGE) ? MS_EXCHANGE : MS_READER;
|
||||
if (!ms_can_run(&ms->datasets[dataset], conn, ns, mode)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: start denied/deferred dataset=%u ns=%s peer=%016llx mode=%u",
|
||||
dataset, ns, (unsigned long long)peer, mode); return -1;
|
||||
}
|
||||
struct ms_request* r = NULL;
|
||||
if (cb) {
|
||||
r = u_calloc(1, sizeof(*r));
|
||||
if (!r) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: request allocation failed"); return -1; }
|
||||
r->cb = cb; r->arg = arg;
|
||||
}
|
||||
struct ms_session* s = ms_find(ms, conn, dataset, ns_id);
|
||||
if (!s) s = ms_create(ms, conn, dataset, ns_id);
|
||||
struct ms_session* s = ms_find(ms, conn, dataset, ns_id, mode);
|
||||
if (!s) s = ms_create(ms, conn, dataset, ns_id, mode);
|
||||
if (!s) { u_free(r); return -1; }
|
||||
if (s->state == MS_FAILING) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: start during terminal handoff ns=%s", ns);
|
||||
u_free(r); return -1;
|
||||
}
|
||||
if (s->state == MS_FAILED) { s->state = MS_IDLE; s->previous_valid = 0; }
|
||||
if (s->state == MS_FAILED || s->state == MS_COMPLETE) { s->state = MS_IDLE; s->previous_valid = 0; }
|
||||
s->dirty = 1;
|
||||
if (s->state == MS_IDLE && ms_schedule(s) < 0) {
|
||||
u_free(r); ms_fail(s, MT_ERR_IO, "start scheduling", 1); return -1;
|
||||
@@ -805,7 +879,8 @@ void merkle_sync_recheck_peer(struct UTUN_INSTANCE* inst, uint64_t peer) {
|
||||
if (!ms) return;
|
||||
struct ms_session* selected = NULL;
|
||||
for (struct ms_session* s = ms->sessions; s; s = s->next)
|
||||
if (s->peer == peer && s->state != MS_FAILED && !ms_can_run(s->dataset, s->conn, s->ns)) { selected = s; break; }
|
||||
if (s->peer == peer && s->state != MS_FAILED && s->state != MS_COMPLETE &&
|
||||
!ms_can_run(s->dataset, s->conn, s->ns, s->mode)) { selected = s; break; }
|
||||
if (!selected) return;
|
||||
ms_fail(selected, MT_ERR_DISCONNECTED, "model policy changed", 0);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* merkle_sync — обмен различиями между наборами записей по Merkle-дереву.
|
||||
* Движок сверяет хеши и передаёт страницы изменившихся листьев; модель данных
|
||||
* через data_ops читает записи, проверяет подписи/права и сохраняет изменения.
|
||||
* Сессия относится к (пир, dataset, namespace), отправка учитывает backpressure.
|
||||
* Сессия относится к (пир, dataset, namespace, направление), отправка учитывает backpressure.
|
||||
* Wire v4 привязывает запросы к свежим round_id и accept_id обеих сторон;
|
||||
* отменённая сессия не может подтвердить новый запрос прежними пакетами.
|
||||
* Все операции — в потоке uasync; один движок на UTUN_INSTANCE. */
|
||||
@@ -47,6 +47,12 @@ struct merkle_sync_data_ops {
|
||||
int (*apply_page)(void* ctx, const char* ns, uint64_t peer, uint64_t prefix, int after_valid,
|
||||
const merkle_key_t* after, const merkle_key_t* next, int more, const uint8_t* data, size_t len);
|
||||
int (*validate_peer)(void* ctx, const char* ns, uint64_t peer); // допуск пира: только 1 разрешает обмен
|
||||
/* Необязательное основание запросить данные, когда двусторонний обмен ещё запрещён.
|
||||
* Только 1 разрешает локальный pull; удалённая сторона всё равно проверяет validate_peer.
|
||||
* Такой получатель не отдаёт ни записей, ни хешей своего дерева. Ответы принимаются
|
||||
* только в созданную локально сессию. Требуются все четыре callback отложенного приёма;
|
||||
* commit_pull проверяет данные перед публикацией. Без validate_request режима нет. */
|
||||
int (*validate_request)(void* ctx, const char* ns, uint64_t peer);
|
||||
/* Политика работы (например sleep/sound), 1 разрешает обмен; без callback спящий пир отложен. */
|
||||
int (*can_run)(void* ctx, const char* ns, uint64_t peer);
|
||||
/* Результат каждой контрольной точки/ошибки, включая автоматические раунды.
|
||||
@@ -58,10 +64,18 @@ struct merkle_sync_data_ops {
|
||||
* stage_page накапливает страницы без изменения опубликованных хешей;
|
||||
* begin_pull возвращает <0 при ошибке; при успехе создаёт ненулевой pull.
|
||||
* commit_pull применяется в конце своего прохода, до TURN/CHECK; <0 — ошибка.
|
||||
* В одностороннем запросе commit_pull вызывается после DONE от неизменившегося источника.
|
||||
* Проверка зависимостей в этом режиме входит в commit_pull; finish не вызывается.
|
||||
* begin/stage/commit/abort не должны вызывать callbacks, отменяющие сессию;
|
||||
* внешние уведомления выполняются из publish_pull/on_result после выхода из автомата.
|
||||
* abort_pull освобождает pull после commit (включая отказ), restart, cancel, DOWN и destroy.
|
||||
* При этом режиме apply_items можно не задавать. */
|
||||
int (*begin_pull)(void* ctx, const char* ns, uint64_t peer, uint64_t round, void** pull);
|
||||
int (*commit_pull)(void* ctx, void* pull);
|
||||
/* Необязательное уведомление после успешного commit_pull, на безопасном выходе
|
||||
* из обработчика пакета. Может отменять сессии. Затем abort_pull освобождает контекст.
|
||||
* Позволяет сразу опубликовать принятые изменения, не ожидая обратного прохода. */
|
||||
void (*publish_pull)(void* ctx, void* pull);
|
||||
void (*abort_pull)(void* ctx, void* pull);
|
||||
int (*stage_page)(void* ctx, void* pull, uint64_t prefix, int after_valid, const merkle_key_t* after,
|
||||
const merkle_key_t* next, int more, const uint8_t* data, size_t len);
|
||||
@@ -85,10 +99,14 @@ int merkle_sync_registered(struct UTUN_INSTANCE* inst, uint8_t dataset);
|
||||
void merkle_sync_destroy(struct UTUN_INSTANCE* inst);
|
||||
|
||||
/* Подключить namespace к текущему ETCP-соединению и запросить контрольную точку.
|
||||
* Успех — подтверждение одинакового корня конкретного раунда, не вечный статус.
|
||||
* При validate_peer=1 успех подтверждает одинаковый корень конкретного раунда.
|
||||
* Иначе validate_request=1 разрешает только получение различий из неизменившегося
|
||||
* за проход источника. Его локальные лишние записи источнику не передаются.
|
||||
* После успешного pull модель может восстановить допуск в on_result; следующий start
|
||||
* выберет обычный обмен. Сам pull не создаёт подписку и не запускает обратную передачу.
|
||||
* Повторные запросы объединяются; каждый callback вызывается один раз, обход не сбрасывается.
|
||||
* После первого start изменения дерева автоматически синхронизируются с этим пиром.
|
||||
* cb может быть NULL. Нужен UP-пир, не self, с допуском validate_peer и can_run.
|
||||
* После первого обычного start изменения дерева автоматически синхронизируются с этим пиром.
|
||||
* cb может быть NULL. Нужен UP-пир, не self, с основанием запроса и can_run.
|
||||
* Без can_run обмен со спящим пиром откладывается.
|
||||
* <0: запрос не принят, callback не вызывается. DOWN/DELETE/REINIT завершают запросы ошибкой.
|
||||
* При блокировке исходящего пакета или ожидании ответа действует таймаут 30 секунд.
|
||||
|
||||
@@ -58,6 +58,9 @@ stage_page получает также prefix и оба курсора стра
|
||||
commit_pull публикует результат после завершения своего прохода, до TURN/CHECK.
|
||||
abort_pull освобождает контекст как после commit, так и при restart/cancel/ошибке.
|
||||
В этом режиме apply_items необязателен; атомарность публикации обеспечивает модель.
|
||||
Необязательный publish_pull вызывается только после успешного commit на выходе из
|
||||
обработчика пакета. Контекст уже отсоединён: уведомления могут отменять сессии;
|
||||
после них abort_pull освобождает контекст. Ошибка commit вызывает только abort_pull.
|
||||
|
||||
start подключает (dataset, namespace) к текущему соединению и запрашивает контрольную точку.
|
||||
Повторные start объединяются, обход не сбрасывается, каждый принятый callback вызывается один раз.
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
*
|
||||
* Личная беседа не создаёт отдельную TOPO_GROUP и не синхронизируется через member_sync/Merkle.
|
||||
* Для доставки использует маршрут из общей CHAT-группы участников.
|
||||
* Router проверяет актуальный допуск self/peer именно в группе пакета и подпись DATA/controls.
|
||||
* Отзыв маршрута закрывает его очереди, сохраняя личную историю и durable outbox;
|
||||
* следующая попытка может выбрать другую допущенную общую группу с тем же телом/адресатом.
|
||||
* - conv_id и content_key детерминированно выводятся обеими сторонами
|
||||
* из своих ключей и pubkey пира (см. dm_crypto.h) — без переговоров.
|
||||
* - сообщения идут через etcp_router (прямое соединение или релей),
|
||||
@@ -23,6 +26,8 @@
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
#include "dm_media.h"
|
||||
#include "dm_protocol.h"
|
||||
#include "../chat/chat_member.h"
|
||||
|
||||
struct UTUN_INSTANCE;
|
||||
|
||||
@@ -47,28 +52,28 @@ extern "C" {
|
||||
* Допускает хвост после тела; для отдельного сообщения вызывающий требует *out_len==avail.
|
||||
* Многобайтные числа имеют порядок байтов хоста; подпись/seq/идентичность здесь не проверяются. */
|
||||
static inline int dm_msg_len(const uint8_t* msg, size_t avail, size_t* out_len) {
|
||||
if (!msg || !out_len || avail < DM_MSG_FIXED_HDR) return 0;
|
||||
uint8_t ct_len = msg[32];
|
||||
if (avail < (size_t)DM_MSG_FIXED_HDR + ct_len + 2) return 0;
|
||||
uint16_t data_len;
|
||||
memcpy(&data_len, msg + DM_MSG_FIXED_HDR + ct_len, 2);
|
||||
if (data_len < 16 || data_len > DM_DATA_MAX + 16) return 0;
|
||||
size_t media_len = ct_len == 4 && !memcmp(msg + DM_MSG_FIXED_HDR, "file", 4) ? DM_MEDIA_DESC_SIZE : 0;
|
||||
size_t total = (size_t)DM_MSG_FIXED_HDR + ct_len + 2 + data_len + media_len + DM_MSG_SIG_SIZE;
|
||||
if (avail < total) return 0;
|
||||
*out_len = total;
|
||||
struct dm_message_view view;
|
||||
if (!out_len || dm_message_parse(msg, avail, &view)) return 0;
|
||||
*out_len = view.size;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* Выбрать общую CHAT-группу с путём router к peer, сначала preferred (0=без предпочтения).
|
||||
/* Выбрать живую CHAT-группу с допуском self/peer и путём router, сначала preferred (0=без предпочтения).
|
||||
* 1 и *group_id при успехе; 0 при отсутствии пути/ошибке, выход не меняется.
|
||||
* group_id обязателен; наличие пути не гарантирует успешной последующей отправки. */
|
||||
int dm_route_group(struct UTUN_INSTANCE* inst, uint64_t peer, uint64_t preferred, uint64_t* group_id);
|
||||
/* Проверить role=yes у peer в любой живой общей CHAT-группе self/peer.
|
||||
* Группа объявления роли не ограничивает маршрут личной передачи. peer=self проверяет локальную роль.
|
||||
* Учитывает только source=0, admitted=1, deleted=0 и действующий канал; путь не требуется.
|
||||
* 1=роль найдена, 0=нет роли/ошибка с диагностикой. role — непустое имя adm_tags, только uasync. */
|
||||
int dm_peer_role(struct UTUN_INSTANCE* inst, uint64_t peer, const char* role);
|
||||
/* Проверить точную длину msg/len, conv/author/recipient/seq, descriptor и подпись автора из nodes.
|
||||
* 0=корректно, -1=отказ. Нужна SQLite, владелец dm_core не требуется.
|
||||
* Не расшифровывает тело и не проверяет членство/роль router-отправителя: это обязанность вызывающего. */
|
||||
int dm_verify_message(struct UTUN_INSTANCE* inst, uint64_t recipient, const uint8_t* msg, size_t len);
|
||||
/* Проверить и расшифровать входящее тело для локального узла, атомарно сохранить историю/беседу/media.
|
||||
* Вызывающий проверяет сетевого отправителя/посредника. Общая группа автора и адресата не требуется;
|
||||
* личность автора, адресат и закреплённые стороны определяются подписанным телом и проверенными ключами.
|
||||
* 0 и receipt после commit либо точного повтора; -1 при отказе. Для file нужен живой dm_media.
|
||||
* Точный удалённый повтор подтверждается по отпечатку без восстановления истории; конфликт отклоняется.
|
||||
* receipt — обязательные 136 байт; при -1 буфер не использовать. Ошибка подписи квитанции возможна после commit. */
|
||||
@@ -98,7 +103,20 @@ void dm_core_destroy(struct UTUN_INSTANCE* inst);
|
||||
* Создание разрешено только после успешного SELECT без строки; существующие seq сохраняются. */
|
||||
int dm_start(struct UTUN_INSTANCE* inst, uint64_t peer_node_id,
|
||||
const uint8_t peer_x25519[32], const uint8_t peer_ed25519[32],
|
||||
const char* peer_name, const char* source_ch_id);
|
||||
const char* peer_name, const char* source_ch_id, enum dm_mode mode, uint64_t* result_conv);
|
||||
/* Вариант UI/headless: читает удостоверенные ключи node из SQLite и возвращает
|
||||
* фактический ID ядра после commit. 0/-1; остальные аргументы как у dm_start. */
|
||||
int dm_start_node(struct UTUN_INSTANCE* inst, uint64_t peer, const char* name, const char* source,
|
||||
enum dm_mode mode, uint64_t* result_conv);
|
||||
|
||||
/* Снимок действующей беседы для файлового модуля. parties: local/peer, key: content key.
|
||||
* NULL-выходы допустимы; 0=успех, -1=нет беседы/вышли из подгруппы/ошибка. Только uasync.
|
||||
* Секрет key вызывающий очищает после использования. Архив этим API не открывается. */
|
||||
int dm_conversation_access(struct UTUN_INSTANCE* inst, const char* conv, struct dm_party parties[2], uint8_t key[32]);
|
||||
/* Внутри транзакции выхода из подгруппы закрыть её GROUPS-беседы и исходящие очереди.
|
||||
* group_ed — прежний публичный ID подгруппы. 0/-1; без запущенного DM пустая операция.
|
||||
* Ключи истории/готовые файлы сохраняются. После commit вызвать dm_media_close_committed. */
|
||||
int dm_group_leave_prepare(struct UTUN_INSTANCE* inst, const uint8_t group_ed[32]);
|
||||
|
||||
/* Отправить сообщение в беседу. Шифрует, подписывает, сохраняет локально и
|
||||
* доставляет (прямое соединение при доступности пира, иначе mailbox).
|
||||
@@ -129,6 +147,16 @@ void dm_cancel_pending_history(struct UTUN_INSTANCE* inst, const char* conv);
|
||||
* Имя из БД либо nodes нормализуется по правилам dm_start; снимок не меняет сохранённые строки. */
|
||||
int dm_list_conversations_json(struct UTUN_INSTANCE* inst, char* buf, size_t buf_size, size_t* out_len);
|
||||
|
||||
#define DM_MEMBER_WIRE_SIZE (1 + CHAT_MEMBER_DISPLAY_SIZE)
|
||||
/* Только uasync. Текущие известные устройства закреплённых сторон GROUPS, включая offline.
|
||||
* [conv_len:1][conv:UTF8][generation:LE64][count:LE16][side:1,member:wire260]*count.
|
||||
* side=0 — свои,1 — собеседника. DEVICE/закрытая беседа возвращают пустой список.
|
||||
* Требуется текущее ненулевое generation; при ошибке out=NULL,size=0; успех0, ошибка-1.
|
||||
* Caller освобождает *out через u_free. Чтение не меняет снимки доставки сообщений. */
|
||||
int dm_members_snapshot(struct UTUN_INSTANCE* inst, const char* conv, uint64_t generation, uint8_t** out, size_t* size);
|
||||
/* Забирает u_calloc(dm_messages_req), отправляет CHAT_EVT_DM_MEMBERS только при успехе. */
|
||||
void dm_members_trampoline(void* arg);
|
||||
|
||||
/* Сообщения беседы в JSON: расшифрованное содержимое, статусы доставки и медиа.
|
||||
* count=1..200 задаёт предел; count<=0 или count>200 означает 200.
|
||||
* offset>=0 отсчитывается от новых сообщений; offset=0 выбирает последние count.
|
||||
@@ -149,6 +177,8 @@ struct dm_start_req {
|
||||
uint64_t peer_node_id; /* Другой участник общей группы; ключи читаются из nodes. */
|
||||
char source_ch_id[64]; /* Десятичный ID общей группы либо пустая строка. */
|
||||
char peer_name[128]; /* UTF-8 имя с NUL; dm_start ограничит до 63 байт. */
|
||||
enum dm_mode mode; /* Один переключатель DEVICE/GROUPS для обеих сторон. */
|
||||
uint64_t request_id; /* Ненулевой запрос получает CHAT_EVT_DM_START_RESULT после commit/ошибки. */
|
||||
};
|
||||
void dm_start_trampoline(void* arg);
|
||||
|
||||
@@ -178,8 +208,9 @@ struct dm_invite_req {
|
||||
uint64_t request_id, /* Ненулевой ID; cancel использует тот же ID. */
|
||||
peer_node_id, /* Фиксированный получатель, не локальный узел. */
|
||||
channel_id, /* Выбранная активная CHAT-группа отправителя. */
|
||||
conv_id, /* Заполняет uasync: выбранная PM либо новая DEVICE. */
|
||||
generation; /* Заполняет uasync до регистрации; проверяет callback. */
|
||||
char source_ch_id[64]; /* общая группа для новой PM; из шапки PM может быть пустой */
|
||||
char source_ch_id[64]; /* Общая CHAT-группа либо dm:<conv> из открытой PM; не менять режим этой беседы. */
|
||||
char peer_name[128]; /* UTF-8 имя для новой беседы с NUL. */
|
||||
int cancel; /* 0=создать, 1=отменить ожидающий request_id. */
|
||||
struct chat_invite_build* pending; /* Внутренний handle; запрос создаётся обнулённым. */
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
#define OPENSSL_API_COMPAT 0x10100000L
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/sha.h>
|
||||
#include <openssl/crypto.h>
|
||||
#include <openssl/err.h>
|
||||
#if OPENSSL_VERSION_NUMBER < 0x30200000L
|
||||
#error "DM attachments require OpenSSL 3.2 or newer with HPKE support"
|
||||
#endif
|
||||
#include <openssl/hpke.h>
|
||||
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
@@ -77,3 +83,105 @@ void dm_build_nonce(uint64_t conv_id, uint64_t author, uint64_t seq, uint8_t non
|
||||
SHA256_Final(hash, &ctx);
|
||||
memcpy(nonce, hash, DM_NONCE_SIZE);
|
||||
}
|
||||
|
||||
static const OSSL_HPKE_SUITE dm_wrap_suite = {
|
||||
OSSL_HPKE_KEM_ID_X25519, OSSL_HPKE_KDF_ID_HKDF_SHA256, OSSL_HPKE_AEAD_ID_AES_GCM_256
|
||||
};
|
||||
static const OSSL_HPKE_SUITE dm_file_suite = {
|
||||
OSSL_HPKE_KEM_ID_X25519, OSSL_HPKE_KDF_ID_HKDF_SHA256, OSSL_HPKE_AEAD_ID_EXPORTONLY
|
||||
};
|
||||
static const uint8_t dm_file_export_label[] = "utun-dm-file-ccm-v1";
|
||||
|
||||
static int dm_hpke_error(const char* stage) {
|
||||
const char* reason = ERR_reason_error_string(ERR_peek_last_error());
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM HPKE: %s reason=%s", stage, reason ? reason : "invalid input or provider failure");
|
||||
return -1;
|
||||
}
|
||||
|
||||
int dm_key_seal(const uint8_t public_key[32], const uint8_t* info, size_t info_size,
|
||||
const uint8_t secret[32], uint8_t out[DM_KEY_WRAP_SIZE]) {
|
||||
if (!public_key || (!info && info_size) || info_size > OSSL_HPKE_MAX_INFOLEN || !secret || !out)
|
||||
return dm_hpke_error("seal arguments");
|
||||
uint8_t wrapped[DM_KEY_WRAP_SIZE]; size_t enc_size = 32, encrypted_size = 48;
|
||||
OSSL_HPKE_CTX* ctx = OSSL_HPKE_CTX_new(OSSL_HPKE_MODE_BASE, dm_wrap_suite, OSSL_HPKE_ROLE_SENDER, NULL, NULL);
|
||||
int ok = ctx && OSSL_HPKE_encap(ctx, wrapped, &enc_size, public_key, 32, info, info_size) == 1 && enc_size == 32 &&
|
||||
OSSL_HPKE_seal(ctx, wrapped + 32, &encrypted_size, info, info_size, secret, 32) == 1 && encrypted_size == 48;
|
||||
OSSL_HPKE_CTX_free(ctx);
|
||||
if (!ok) return dm_hpke_error("seal");
|
||||
memcpy(out, wrapped, sizeof(wrapped));
|
||||
return 0;
|
||||
}
|
||||
|
||||
int dm_key_open(const uint8_t private_key[32], const uint8_t* info, size_t info_size,
|
||||
const uint8_t wrapped[DM_KEY_WRAP_SIZE], uint8_t out[32]) {
|
||||
if (!private_key || (!info && info_size) || info_size > OSSL_HPKE_MAX_INFOLEN || !wrapped || !out)
|
||||
return dm_hpke_error("open arguments");
|
||||
uint8_t secret[32] = {0}; size_t size = sizeof(secret);
|
||||
EVP_PKEY* key = EVP_PKEY_new_raw_private_key(EVP_PKEY_X25519, NULL, private_key, 32);
|
||||
OSSL_HPKE_CTX* ctx = OSSL_HPKE_CTX_new(OSSL_HPKE_MODE_BASE, dm_wrap_suite, OSSL_HPKE_ROLE_RECEIVER, NULL, NULL);
|
||||
int ok = key && ctx && OSSL_HPKE_decap(ctx, wrapped, 32, key, info, info_size) == 1 &&
|
||||
OSSL_HPKE_open(ctx, secret, &size, info, info_size, wrapped + 32, 48) == 1 && size == sizeof(secret);
|
||||
if (ok) memcpy(out, secret, sizeof(secret));
|
||||
OPENSSL_cleanse(secret, sizeof(secret)); OSSL_HPKE_CTX_free(ctx); EVP_PKEY_free(key);
|
||||
return ok ? 0 : dm_hpke_error("open/authentication");
|
||||
}
|
||||
|
||||
/* Канонический контекст не зависит от ещё не существующего хеша шифротекста/seq. */
|
||||
static size_t dm_file_context(uint64_t conv, uint64_t author, const uint8_t id[16],
|
||||
const struct dm_party parties[2], uint8_t out[256]) {
|
||||
const char domain[] = "utun-dm-file-v1";
|
||||
if (!conv || !author || !id || !parties) { dm_hpke_error("file context arguments"); return 0; }
|
||||
memcpy(out, domain, sizeof(domain)); size_t pos = sizeof(domain);
|
||||
for (unsigned i = 0; i < 8; i++) out[pos++] = (uint8_t)(conv >> (8 * i));
|
||||
for (unsigned i = 0; i < 8; i++) out[pos++] = (uint8_t)(author >> (8 * i));
|
||||
memcpy(out + pos, id, 16); pos += 16;
|
||||
if (dm_party_encode(parties, out + pos) || dm_party_encode(parties + 1, out + pos + DM_PARTY_WIRE_SIZE)) return 0;
|
||||
return pos + 2 * DM_PARTY_WIRE_SIZE;
|
||||
}
|
||||
|
||||
int dm_file_keys_create(uint64_t conv, uint64_t author, const uint8_t id[16], const struct dm_party parties[2],
|
||||
uint8_t out[DM_FILE_KEYS_SIZE], uint8_t file_key[32]) {
|
||||
if (!out || !file_key) return dm_hpke_error("file create outputs");
|
||||
uint8_t info[256], wrap_info[321], keys[DM_FILE_KEYS_SIZE], secret[32] = {0}, exported[32] = {0};
|
||||
size_t info_size = dm_file_context(conv, author, id, parties, info);
|
||||
if (!info_size) return -1;
|
||||
EVP_PKEY* key = NULL; size_t public_size = 32, private_size = sizeof(secret), enc_size = 32;
|
||||
OSSL_HPKE_CTX* ctx = OSSL_HPKE_CTX_new(OSSL_HPKE_MODE_BASE, dm_file_suite, OSSL_HPKE_ROLE_SENDER, NULL, NULL);
|
||||
int ok = ctx && OSSL_HPKE_keygen(dm_file_suite, keys, &public_size, &key, NULL, 0, NULL, NULL) == 1 && public_size == 32 &&
|
||||
EVP_PKEY_get_raw_private_key(key, secret, &private_size) == 1 && private_size == 32 &&
|
||||
OSSL_HPKE_encap(ctx, keys + 32, &enc_size, keys, 32, info, info_size) == 1 && enc_size == 32 &&
|
||||
OSSL_HPKE_export(ctx, exported, 32, dm_file_export_label, sizeof(dm_file_export_label)) == 1;
|
||||
if (ok) {
|
||||
memcpy(wrap_info, info, info_size); memcpy(wrap_info + info_size, keys, 64);
|
||||
for (unsigned side = 0; side < 2 && ok; side++) {
|
||||
wrap_info[info_size + 64] = (uint8_t)side;
|
||||
ok = !dm_key_seal(parties[side].x, wrap_info, info_size + 65, secret, keys + 64 + side * DM_KEY_WRAP_SIZE);
|
||||
}
|
||||
}
|
||||
if (ok) { memcpy(out, keys, sizeof(keys)); memcpy(file_key, exported, sizeof(exported)); }
|
||||
OPENSSL_cleanse(secret, sizeof(secret)); OPENSSL_cleanse(exported, sizeof(exported));
|
||||
OSSL_HPKE_CTX_free(ctx); EVP_PKEY_free(key);
|
||||
if (!ok) return dm_hpke_error("create file key pair/envelopes");
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_DM, "DM HPKE: prepared independent attachment key pair conv=%llu author=%llu envelopes=2",
|
||||
(unsigned long long)conv, (unsigned long long)author);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int dm_file_keys_open(uint64_t conv, uint64_t author, const uint8_t id[16], const struct dm_party parties[2],
|
||||
unsigned side, const uint8_t private_key[32], const uint8_t keys[DM_FILE_KEYS_SIZE], uint8_t out[32]) {
|
||||
if (side > 1 || !private_key || !keys || !out) return dm_hpke_error("file open arguments");
|
||||
uint8_t info[256], wrap_info[321], secret[32] = {0}, public_key[32], exported[32] = {0};
|
||||
size_t info_size = dm_file_context(conv, author, id, parties, info);
|
||||
if (!info_size) return -1;
|
||||
memcpy(wrap_info, info, info_size); memcpy(wrap_info + info_size, keys, 64); wrap_info[info_size + 64] = (uint8_t)side;
|
||||
if (dm_key_open(private_key, wrap_info, info_size + 65, keys + 64 + side * DM_KEY_WRAP_SIZE, secret)) return -1;
|
||||
EVP_PKEY* key = EVP_PKEY_new_raw_private_key(EVP_PKEY_X25519, NULL, secret, 32); size_t public_size = 32;
|
||||
OSSL_HPKE_CTX* ctx = OSSL_HPKE_CTX_new(OSSL_HPKE_MODE_BASE, dm_file_suite, OSSL_HPKE_ROLE_RECEIVER, NULL, NULL);
|
||||
int ok = key && ctx && EVP_PKEY_get_raw_public_key(key, public_key, &public_size) == 1 && public_size == 32 &&
|
||||
!memcmp(public_key, keys, 32) && OSSL_HPKE_decap(ctx, keys + 32, 32, key, info, info_size) == 1 &&
|
||||
OSSL_HPKE_export(ctx, exported, 32, dm_file_export_label, sizeof(dm_file_export_label)) == 1;
|
||||
if (ok) memcpy(out, exported, sizeof(exported));
|
||||
OPENSSL_cleanse(secret, sizeof(secret)); OPENSSL_cleanse(exported, sizeof(exported));
|
||||
OSSL_HPKE_CTX_free(ctx); EVP_PKEY_free(key);
|
||||
return ok ? 0 : dm_hpke_error("derive attachment stream key");
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include "../media_async/media_crypto.h"
|
||||
#include "dm_identity.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
@@ -42,6 +43,28 @@ int dm_derive_content_key(const uint8_t my_priv[32], const uint8_t peer_pub[32],
|
||||
* Усечение хеша не даёт математической гарантии отсутствия коллизий. */
|
||||
void dm_build_nonce(uint64_t conv_id, uint64_t author, uint64_t seq, uint8_t nonce[DM_NONCE_SIZE]);
|
||||
|
||||
#define DM_KEY_WRAP_SIZE 80 /* HPKE X25519 enc:32 + encrypted secret:32 + GCM tag:16. */
|
||||
#define DM_FILE_KEYS_SIZE (64 + 2 * DM_KEY_WRAP_SIZE) /* F_public, file enc, две упаковки F_private. */
|
||||
|
||||
/* Независимая HPKE-упаковка 32-байтового секрета к X25519 ключу. RFC9180 base,
|
||||
* X25519/HKDF-SHA256/AES-256-GCM. info связывает назначение/идентичность (до1024 байт).
|
||||
* 0/-1, выход не меняется при ошибке. Открытый секрет caller обязан очистить. */
|
||||
int dm_key_seal(const uint8_t public_key[32], const uint8_t* info, size_t info_size,
|
||||
const uint8_t secret[32], uint8_t out[DM_KEY_WRAP_SIZE]);
|
||||
int dm_key_open(const uint8_t private_key[32], const uint8_t* info, size_t info_size,
|
||||
const uint8_t wrapped[DM_KEY_WRAP_SIZE], uint8_t out[32]);
|
||||
|
||||
/* Создать собственную X25519 пару вложения и вывести файловый AES ключ через
|
||||
* HPKE export-only. F_private упакован независимо для parties[0] и parties[1].
|
||||
* parties идут в порядке автор/собеседник; conv/author/UUID фиксируются до worker.
|
||||
* 0/-1, выходы меняются только при успехе. Файловый ключ caller очищает после работы. */
|
||||
int dm_file_keys_create(uint64_t conv, uint64_t author, const uint8_t id[16], const struct dm_party parties[2],
|
||||
uint8_t out[DM_FILE_KEYS_SIZE], uint8_t file_key[32]);
|
||||
/* Раскрыть свою упаковку (side=0/1), проверить F_public и вывести тот же файловый
|
||||
* ключ. Собственный private_key соответствует parties[side]; 0/-1, out неизменен при ошибке. */
|
||||
int dm_file_keys_open(uint64_t conv, uint64_t author, const uint8_t id[16], const struct dm_party parties[2],
|
||||
unsigned side, const uint8_t private_key[32], const uint8_t keys[DM_FILE_KEYS_SIZE], uint8_t out[32]);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -19,7 +19,7 @@ int dm_history_init(struct UTUN_INSTANCE* inst) {
|
||||
const char* sql =
|
||||
"CREATE TABLE IF NOT EXISTS dm_history(conv_id TEXT PRIMARY KEY,generation INTEGER NOT NULL,hidden INTEGER NOT NULL);"
|
||||
"CREATE TABLE IF NOT EXISTS dm_deleted_messages(conv_id TEXT NOT NULL,dir INTEGER NOT NULL,seq INTEGER NOT NULL,"
|
||||
"body_hash BLOB NOT NULL,media_id BLOB,media_receipt BLOB,PRIMARY KEY(conv_id,dir,seq));"
|
||||
"author INTEGER NOT NULL,body_hash BLOB NOT NULL,media_id BLOB,media_receipt BLOB,PRIMARY KEY(conv_id,author,seq));"
|
||||
"CREATE TABLE IF NOT EXISTS dm_local_files(path TEXT PRIMARY KEY,conv_id TEXT NOT NULL,generation INTEGER NOT NULL,"
|
||||
"busy INTEGER NOT NULL DEFAULT 0,retry_at INTEGER NOT NULL DEFAULT 0);"
|
||||
"UPDATE dm_local_files SET busy=0;";
|
||||
@@ -68,17 +68,19 @@ int dm_history_show(struct UTUN_INSTANCE* inst, const char* conv) {
|
||||
}
|
||||
|
||||
int dm_history_deleted(struct UTUN_INSTANCE* inst, const uint8_t* body, size_t len, int dir, uint8_t* media_receipt) {
|
||||
uint64_t conv, seq; char id[64];
|
||||
uint64_t conv, seq, author; char id[64];
|
||||
if (media_receipt) memset(media_receipt, 0, DM_MEDIA_RECEIPT_SIZE);
|
||||
size_t actual;
|
||||
if (!dm_msg_len(body, len, &actual) || actual != len) return history_error(inst, "invalid replay body", NULL);
|
||||
memcpy(&conv, body, 8); memcpy(&seq, body + 8, 8);
|
||||
memcpy(&author, body + 24, 8);
|
||||
snprintf(id, sizeof(id), "%llu", (unsigned long long)conv);
|
||||
sqlite3_stmt* st = NULL;
|
||||
if (sqlite3_prepare_v2(inst->topo_sqlite_db,
|
||||
"SELECT body_hash,media_receipt FROM dm_deleted_messages WHERE conv_id=? AND dir=? AND seq=?", -1, &st, NULL) != SQLITE_OK)
|
||||
"SELECT body_hash,media_receipt FROM dm_deleted_messages WHERE conv_id=? AND author=? AND seq=?", -1, &st, NULL) != SQLITE_OK)
|
||||
return history_error(inst, "replay prepare", id);
|
||||
sqlite3_bind_text(st, 1, id, -1, SQLITE_STATIC); sqlite3_bind_int(st, 2, dir); sqlite3_bind_int64(st, 3, (sqlite3_int64)seq);
|
||||
sqlite3_bind_text(st, 1, id, -1, SQLITE_STATIC);
|
||||
sqlite3_bind_int64(st, 2, (sqlite3_int64)author); sqlite3_bind_int64(st, 3, (sqlite3_int64)seq);
|
||||
int rc = sqlite3_step(st), result = rc == SQLITE_DONE ? 0 : -1;
|
||||
if (rc == SQLITE_ROW) {
|
||||
uint8_t hash[32]; SHA256(body, len, hash);
|
||||
@@ -97,22 +99,26 @@ int dm_history_deleted(struct UTUN_INSTANCE* inst, const uint8_t* body, size_t l
|
||||
/* Каноническое тело восстанавливается из сохранённых полей; у file оно уже есть в dm_files. */
|
||||
static int history_remember(struct UTUN_INSTANCE* inst, const char* conv) {
|
||||
sqlite3_stmt* rows = NULL; sqlite3_stmt* put = NULL;
|
||||
const char* sql = "SELECT m.dir,m.seq,m.ts,m.author,m.ct,m.data,m.sig,f.body,f.receipt"
|
||||
" FROM dm_messages m LEFT JOIN dm_files f ON f.conv_id=m.conv_id AND f.dir=m.dir AND f.seq=m.seq WHERE m.conv_id=?";
|
||||
const char* sql = "SELECT m.dir,m.seq,m.ts,m.author,m.ct,m.data,m.sig,f.body,f.receipt,m.body"
|
||||
" FROM dm_messages m LEFT JOIN dm_files f ON f.conv_id=m.conv_id AND f.author=m.author AND f.seq=m.seq WHERE m.conv_id=?";
|
||||
if (sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &rows, NULL) != SQLITE_OK ||
|
||||
sqlite3_prepare_v2(inst->topo_sqlite_db,
|
||||
"INSERT INTO dm_deleted_messages(conv_id,dir,seq,body_hash,media_id,media_receipt) VALUES(?,?,?,?,?,?)",
|
||||
"INSERT INTO dm_deleted_messages(conv_id,dir,seq,body_hash,media_id,media_receipt,author) VALUES(?,?,?,?,?,?,?)",
|
||||
-1, &put, NULL) != SQLITE_OK) goto fail;
|
||||
sqlite3_bind_text(rows, 1, conv, -1, SQLITE_STATIC);
|
||||
int rc;
|
||||
while ((rc = sqlite3_step(rows)) == SQLITE_ROW) {
|
||||
uint8_t body[DM_MSG_FIXED_HDR + 255 + 2 + DM_DATA_MAX + 16 + DM_MEDIA_DESC_SIZE + DM_MSG_SIG_SIZE], hash[32];
|
||||
uint8_t body[DM_BODY_MAX], hash[32];
|
||||
size_t length = 0;
|
||||
uint64_t number = strtoull(conv, NULL, 10), seq = (uint64_t)sqlite3_column_int64(rows, 1);
|
||||
uint64_t ts = (uint64_t)sqlite3_column_int64(rows, 2), author = (uint64_t)sqlite3_column_int64(rows, 3);
|
||||
int ct = sqlite3_column_bytes(rows, 4), data = sqlite3_column_bytes(rows, 5), dir = sqlite3_column_int(rows, 0);
|
||||
const char* type = (const char*)sqlite3_column_text(rows, 4);
|
||||
if (ct == 4 && type && !memcmp(type, "file", 4)) {
|
||||
if (sqlite3_column_type(rows, 9) != SQLITE_NULL) {
|
||||
int bytes = sqlite3_column_bytes(rows, 9);
|
||||
if (bytes <= 0 || bytes > sizeof(body)) goto fail;
|
||||
length = (size_t)bytes; memcpy(body, sqlite3_column_blob(rows, 9), length);
|
||||
} else if (ct == 4 && type && !memcmp(type, "file", 4)) {
|
||||
int bytes = sqlite3_column_bytes(rows, 7);
|
||||
if (bytes <= 0 || bytes > sizeof(body)) goto fail;
|
||||
length = (size_t)bytes; memcpy(body, sqlite3_column_blob(rows, 7), length);
|
||||
@@ -134,6 +140,7 @@ static int history_remember(struct UTUN_INSTANCE* inst, const char* conv) {
|
||||
if (media) sqlite3_bind_blob(put, 5, media, 16, SQLITE_TRANSIENT);
|
||||
if (sqlite3_column_bytes(rows, 8) == DM_MEDIA_RECEIPT_SIZE)
|
||||
sqlite3_bind_blob(put, 6, sqlite3_column_blob(rows, 8), DM_MEDIA_RECEIPT_SIZE, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int64(put, 7, (sqlite3_int64)author);
|
||||
if (sqlite3_step(put) != SQLITE_DONE) goto fail;
|
||||
}
|
||||
if (rc != SQLITE_DONE) goto fail;
|
||||
@@ -155,7 +162,8 @@ int dm_history_delete(struct UTUN_INSTANCE* inst, const char* conv, uint64_t gen
|
||||
const char* statements[] = {
|
||||
"DELETE FROM dm_outbox WHERE conv_id=?", "DELETE FROM dm_messages WHERE conv_id=?",
|
||||
"INSERT INTO dm_history(conv_id,generation,hidden) VALUES(?,?,1)"
|
||||
" ON CONFLICT(conv_id) DO UPDATE SET generation=excluded.generation,hidden=1"
|
||||
" ON CONFLICT(conv_id) DO UPDATE SET generation=excluded.generation,hidden=1",
|
||||
"DELETE FROM dm_delivery WHERE conv_id=?"
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(statements) / sizeof(statements[0]); i++) {
|
||||
if (sqlite3_prepare_v2(db, statements[i], -1, &st, NULL) != SQLITE_OK) goto fail;
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
#include "dm_identity.h"
|
||||
#include "dm_crypto.h"
|
||||
#include "../utun_instance.h"
|
||||
#include "../routing_layer/devgroup.h"
|
||||
#include "../transport_layer/secure_channel.h"
|
||||
#include "../../lib/debug_config.h"
|
||||
#include <sqlite3.h>
|
||||
#include <openssl/crypto.h>
|
||||
#include <openssl/sha.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
static int identity_error(struct UTUN_INSTANCE* inst, const char* stage, uint64_t node) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM identity: %s node=%llu sql=%s", stage, (unsigned long long)node,
|
||||
inst && inst->topo_sqlite_db ? sqlite3_errmsg(inst->topo_sqlite_db) : "unavailable");
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Проверка формы не зависит от доступности устройства и локального каталога. */
|
||||
static int party_valid(const struct dm_party* party) {
|
||||
static const uint8_t zero[32] = {0};
|
||||
if (!party || !memcmp(party->ed, zero, 32) || !memcmp(party->x, zero, 32)) return 0;
|
||||
if (party->kind == DM_PARTY_GROUP) return party->node == 0;
|
||||
return party->kind == DM_PARTY_DEVICE && party->node && party->node == sc_derive_node_id_from_pubkey(party->x);
|
||||
}
|
||||
|
||||
int dm_party_encode(const struct dm_party* party, uint8_t out[DM_PARTY_WIRE_SIZE]) {
|
||||
if (!out || !party_valid(party)) return identity_error(NULL, "invalid party encode", party ? party->node : 0);
|
||||
out[0] = party->kind;
|
||||
for (unsigned i = 0; i < 8; i++) out[1 + i] = (uint8_t)(party->node >> (8 * i));
|
||||
memcpy(out + 9, party->ed, 32); memcpy(out + 41, party->x, 32);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int dm_party_decode(const uint8_t data[DM_PARTY_WIRE_SIZE], struct dm_party* out) {
|
||||
if (!data || !out) return identity_error(NULL, "invalid party decode arguments", 0);
|
||||
struct dm_party party = {.kind = data[0]};
|
||||
for (unsigned i = 0; i < 8; i++) party.node |= (uint64_t)data[1 + i] << (8 * i);
|
||||
memcpy(party.ed, data + 9, 32); memcpy(party.x, data + 41, 32);
|
||||
if (!party_valid(&party)) return identity_error(NULL, "invalid party encoding", party.node);
|
||||
*out = party;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int dm_party_equal(const struct dm_party* a, const struct dm_party* b) {
|
||||
return a && b && a->kind == b->kind && a->node == b->node && !memcmp(a->ed, b->ed, 32) && !memcmp(a->x, b->x, 32);
|
||||
}
|
||||
|
||||
/* Разные ключи одного ID означают конфликт, а не вторую сторону беседы. */
|
||||
static int party_same_id(const struct dm_party* a, const struct dm_party* b) {
|
||||
return a->kind == b->kind && (a->kind == DM_PARTY_DEVICE ? a->node == b->node : !memcmp(a->ed, b->ed, 32));
|
||||
}
|
||||
|
||||
/* Собственное устройство берём из ядра, удалённое — из проверенной проекции nodes. */
|
||||
int dm_party_resolve(struct UTUN_INSTANCE* inst, uint64_t node, enum dm_mode mode, struct dm_party* out) {
|
||||
if (!inst || !inst->topo_sqlite_db || !node || !out || (mode != DM_MODE_DEVICE && mode != DM_MODE_GROUPS))
|
||||
return identity_error(inst, "invalid resolve arguments", node);
|
||||
struct dm_party party = {.kind = DM_PARTY_DEVICE, .node = node};
|
||||
if (node == inst->node_id) {
|
||||
memcpy(party.ed, inst->my_ed25519_pubkey, 32); memcpy(party.x, inst->my_keys.public_key, 32);
|
||||
if (mode == DM_MODE_GROUPS) {
|
||||
struct devgroup_record record; uint8_t ed[32] = {0}, x[32] = {0};
|
||||
int found = devgroup_load_local(inst, &record, ed, x);
|
||||
OPENSSL_cleanse(ed, sizeof(ed)); OPENSSL_cleanse(x, sizeof(x));
|
||||
if (found < 0) return identity_error(inst, "local subgroup verification failed", node);
|
||||
if (found) {
|
||||
party.kind = DM_PARTY_GROUP; party.node = 0;
|
||||
memcpy(party.ed, record.ed, 32); memcpy(party.x, record.x, 32);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sqlite3_stmt* st = NULL;
|
||||
int rc = sqlite3_prepare_v2(inst->topo_sqlite_db,
|
||||
"SELECT x25519_pubkey,ed25519_pubkey FROM nodes WHERE node_id=?", -1, &st, NULL);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)node);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_step(st);
|
||||
if (rc == SQLITE_ROW && sqlite3_column_bytes(st, 0) == 32 && sqlite3_column_bytes(st, 1) == 32) {
|
||||
memcpy(party.x, sqlite3_column_blob(st, 0), 32); memcpy(party.ed, sqlite3_column_blob(st, 1), 32);
|
||||
} else {
|
||||
sqlite3_finalize(st);
|
||||
return identity_error(inst, "missing or damaged device keys", node);
|
||||
}
|
||||
if (sqlite3_finalize(st) != SQLITE_OK) return identity_error(inst, "device query finalization failed", node);
|
||||
if (!party_valid(&party)) return identity_error(inst, "device ID/key mismatch", node);
|
||||
if (mode == DM_MODE_GROUPS) {
|
||||
struct devgroup_record member;
|
||||
int found = devgroup_read_member(inst->topo_sqlite_db, node, &member);
|
||||
if (found < 0) return identity_error(inst, "subgroup membership verification failed", node);
|
||||
if (found) {
|
||||
struct devgroup_record record; struct devgroup_profile profile;
|
||||
if (devgroup_latest(inst->topo_sqlite_db, member.ed, &record, &profile) != 1)
|
||||
return identity_error(inst, "subgroup profile unavailable", node);
|
||||
if (memcmp(member.x, record.x, 32))
|
||||
return identity_error(inst, "subgroup profile and membership use different encryption keys", node);
|
||||
party.kind = DM_PARTY_GROUP; party.node = 0;
|
||||
memcpy(party.ed, record.ed, 32); memcpy(party.x, record.x, 32);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!party_valid(&party)) return identity_error(inst, "invalid resolved party", node);
|
||||
*out = party;
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_DM, "DM identity: resolved node=%llu mode=%d kind=%u",
|
||||
(unsigned long long)node, mode, party.kind);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int dm_party_member(struct UTUN_INSTANCE* inst, const struct dm_party* party, uint64_t node) {
|
||||
if (!inst || !inst->topo_sqlite_db || !party_valid(party) || !node)
|
||||
return identity_error(inst, "invalid membership arguments", node);
|
||||
if (party->kind == DM_PARTY_DEVICE && party->node != node) return 0;
|
||||
if (party->kind == DM_PARTY_GROUP && node != inst->node_id) {
|
||||
struct devgroup_record member;
|
||||
int found = devgroup_read_member(inst->topo_sqlite_db, node, &member);
|
||||
if (found <= 0) return found;
|
||||
return !memcmp(party->ed, member.ed, 32) && !memcmp(party->x, member.x, 32);
|
||||
}
|
||||
struct dm_party current;
|
||||
if (dm_party_resolve(inst, node, party->kind == DM_PARTY_GROUP ? DM_MODE_GROUPS : DM_MODE_DEVICE, ¤t)) return -1;
|
||||
return dm_party_equal(party, ¤t);
|
||||
}
|
||||
|
||||
int dm_party_private(struct UTUN_INSTANCE* inst, const struct dm_party* party, uint8_t out[32]) {
|
||||
if (!inst || !out || !party_valid(party)) return identity_error(inst, "invalid secret request", 0);
|
||||
if (party->kind == DM_PARTY_DEVICE) {
|
||||
if (party->node != inst->node_id || memcmp(party->x, inst->my_keys.public_key, 32) ||
|
||||
memcmp(party->ed, inst->my_ed25519_pubkey, 32)) return identity_error(inst, "device secret unavailable", party->node);
|
||||
memcpy(out, inst->my_keys.private_key, 32);
|
||||
return 0;
|
||||
}
|
||||
struct devgroup_record record; uint8_t ed[32] = {0}, x[32] = {0};
|
||||
int rc = devgroup_load_local(inst, &record, ed, x);
|
||||
int found = rc == 1 && !memcmp(party->ed, record.ed, 32) && !memcmp(party->x, record.x, 32);
|
||||
if (found) memcpy(out, x, 32);
|
||||
OPENSSL_cleanse(ed, sizeof(ed)); OPENSSL_cleanse(x, sizeof(x));
|
||||
if (!found) return identity_error(inst, "pinned subgroup secret unavailable", inst->node_id);
|
||||
return 0;
|
||||
}
|
||||
|
||||
uint64_t dm_identity_conv_id(enum dm_mode mode, const struct dm_party parties[2]) {
|
||||
if (!parties || (mode != DM_MODE_DEVICE && mode != DM_MODE_GROUPS) || !party_valid(parties) ||
|
||||
!party_valid(parties + 1) || party_same_id(parties, parties + 1)) {
|
||||
identity_error(NULL, "invalid conversation parties", 0); return 0;
|
||||
}
|
||||
if (mode == DM_MODE_DEVICE) {
|
||||
if (parties[0].kind != DM_PARTY_DEVICE || parties[1].kind != DM_PARTY_DEVICE || parties[0].node == parties[1].node) {
|
||||
identity_error(NULL, "device mode requires distinct devices", 0); return 0;
|
||||
}
|
||||
return dm_derive_conv_id(parties[0].node, parties[1].node);
|
||||
}
|
||||
const char domain[] = "utun-dm-groups-v1";
|
||||
uint8_t wire[2][DM_PARTY_WIRE_SIZE], data[sizeof(domain) + 2 * DM_PARTY_WIRE_SIZE], hash[32];
|
||||
if (dm_party_encode(parties, wire[0]) || dm_party_encode(parties + 1, wire[1])) return 0;
|
||||
unsigned first = memcmp(wire[0], wire[1], DM_PARTY_WIRE_SIZE) > 0;
|
||||
memcpy(data, domain, sizeof(domain)); memcpy(data + sizeof(domain), wire[first], DM_PARTY_WIRE_SIZE);
|
||||
memcpy(data + sizeof(domain) + DM_PARTY_WIRE_SIZE, wire[1 - first], DM_PARTY_WIRE_SIZE);
|
||||
SHA256(data, sizeof(data), hash);
|
||||
uint64_t id = 0;
|
||||
for (unsigned i = 0; i < 8; i++) id |= (uint64_t)hash[i] << (8 * i);
|
||||
id &= INT64_MAX;
|
||||
return id ? id : 1;
|
||||
}
|
||||
|
||||
/* Добавление не молча обрезает snapshot и не допускает принадлежность обеим сторонам. */
|
||||
static int targets_add(struct dm_target targets[DM_TARGET_MAX], size_t* count, size_t capacity, uint64_t node, unsigned side) {
|
||||
for (size_t i = 0; i < *count; i++) {
|
||||
if (targets[i].node != node) continue;
|
||||
if (targets[i].side == side) return 0;
|
||||
return identity_error(NULL, "device belongs to both parties", node);
|
||||
}
|
||||
if (*count >= capacity || *count >= DM_TARGET_MAX) return identity_error(NULL, "recipient snapshot limit exceeded", node);
|
||||
targets[*count].node = node; targets[*count].side = (uint8_t)side; ++*count;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int target_compare(const void* a, const void* b) {
|
||||
uint64_t x = ((const struct dm_target*)a)->node, y = ((const struct dm_target*)b)->node;
|
||||
return x > y ? 1 : x < y ? -1 : 0;
|
||||
}
|
||||
|
||||
int dm_identity_members(struct UTUN_INSTANCE* inst, const struct dm_party parties[2],
|
||||
struct dm_target* out, size_t capacity, size_t* count) {
|
||||
if (!inst || !inst->topo_sqlite_db || !parties || !out || !count || !capacity ||
|
||||
!party_valid(parties) || !party_valid(parties + 1) || party_same_id(parties, parties + 1))
|
||||
return identity_error(inst, "invalid recipient snapshot arguments", 0);
|
||||
struct dm_target targets[DM_TARGET_MAX] = {{0}}; size_t n = 0;
|
||||
for (unsigned side = 0; side < 2; side++) {
|
||||
const struct dm_party* party = parties + side;
|
||||
if (party->kind == DM_PARTY_DEVICE) {
|
||||
if (dm_party_member(inst, party, party->node) != 1 || targets_add(targets, &n, capacity, party->node, side)) return -1;
|
||||
continue;
|
||||
}
|
||||
int local = dm_party_member(inst, party, inst->node_id);
|
||||
if (local < 0 || (local && targets_add(targets, &n, capacity, inst->node_id, side))) return -1;
|
||||
sqlite3_stmt* st = NULL;
|
||||
int rc = sqlite3_prepare_v2(inst->topo_sqlite_db, "SELECT node_id FROM nodes WHERE devgroup_ed=?", -1, &st, NULL);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 1, party->ed, 32, SQLITE_STATIC);
|
||||
if (rc == SQLITE_OK) while ((rc = sqlite3_step(st)) == SQLITE_ROW) {
|
||||
uint64_t node = (uint64_t)sqlite3_column_int64(st, 0);
|
||||
if (node == inst->node_id) continue;
|
||||
int member = dm_party_member(inst, party, node);
|
||||
if (member != 1 || targets_add(targets, &n, capacity, node, side)) {
|
||||
sqlite3_finalize(st); return identity_error(inst, "inconsistent recipient membership", node);
|
||||
}
|
||||
}
|
||||
int final = sqlite3_finalize(st);
|
||||
if (rc != SQLITE_DONE || final != SQLITE_OK) return identity_error(inst, "recipient query failed", 0);
|
||||
}
|
||||
qsort(targets, n, sizeof(targets[0]), target_compare);
|
||||
memcpy(out, targets, n * sizeof(targets[0])); *count = n;
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_DM, "DM identity: recipient snapshot complete count=%zu", n);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int dm_identity_targets(struct UTUN_INSTANCE* inst, const struct dm_party parties[2],
|
||||
struct dm_target* out, size_t capacity, size_t* count) {
|
||||
struct dm_target members[DM_TARGET_MAX]; size_t n;
|
||||
if (!out || !count) return identity_error(inst, "missing recipient output", 0);
|
||||
if (dm_identity_members(inst, parties, members, capacity, &n)) return -1;
|
||||
unsigned sides = 0;
|
||||
for (size_t i = 0; i < n; i++) sides |= 1u << members[i].side;
|
||||
if (sides != 3) return identity_error(inst, "pinned party has no known devices", 0);
|
||||
memcpy(out, members, n * sizeof(*out)); *count = n;
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/* dm_identity — стороны личной беседы и снимок известных устройств.
|
||||
* DEVICE сохраняет пару узлов; GROUPS разрешает каждую сторону в подгруппу
|
||||
* либо узел при её отсутствии. Ошибка проверки не означает отсутствие группы.
|
||||
* Вызывать в uasync-потоке ядра; выходные структуры меняются только при успехе.
|
||||
* Стороны сохраняются в беседе: повторное разрешение при отправке запрещено. */
|
||||
#ifndef UTUN_DM_IDENTITY_H
|
||||
#define UTUN_DM_IDENTITY_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
struct UTUN_INSTANCE;
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
enum dm_mode { DM_MODE_DEVICE = 0, DM_MODE_GROUPS = 1 };
|
||||
enum dm_party_kind { DM_PARTY_DEVICE = 0, DM_PARTY_GROUP = 1 };
|
||||
#define DM_PARTY_WIRE_SIZE 73
|
||||
#define DM_TARGET_MAX 128
|
||||
|
||||
struct dm_party {
|
||||
uint8_t kind; /* dm_party_kind, независимо от режима беседы. */
|
||||
uint64_t node; /* DEVICE: ID узла; GROUP: строго 0. */
|
||||
uint8_t ed[32]; /* Публичный ключ подписи узла или ID подгруппы. */
|
||||
uint8_t x[32]; /* Проверенный публичный X25519 ключ той же стороны. */
|
||||
};
|
||||
|
||||
struct dm_target {
|
||||
uint64_t node; /* Конкретное устройство, в том числе офлайн. */
|
||||
uint8_t side; /* Индекс стороны в переданном массиве parties[2]. */
|
||||
};
|
||||
|
||||
/* 0/-1: разрешить известный node для mode, включая проверку локальных секретов
|
||||
* для собственной подгруппы. Отсутствующий/повреждённый узел — ошибка. */
|
||||
int dm_party_resolve(struct UTUN_INSTANCE* inst, uint64_t node, enum dm_mode mode, struct dm_party* out);
|
||||
|
||||
/* Проверить текущую принадлежность узла закреплённой стороне: 1=да,0=нет,-1=ошибка.
|
||||
* Не использовать текущую принадлежность вместо подписанного исторического snapshot. */
|
||||
int dm_party_member(struct UTUN_INSTANCE* inst, const struct dm_party* party, uint64_t node);
|
||||
|
||||
/* 0/-1: получить собственный X25519 секрет строго указанной стороны.
|
||||
* out — 32 байта; вызывающий очищает секрет после использования. */
|
||||
int dm_party_private(struct UTUN_INSTANCE* inst, const struct dm_party* party, uint8_t out[32]);
|
||||
|
||||
/* Канонический codec: kind:u8,node:LE64,ed:32,x:32; padding структуры не передаётся.
|
||||
* 0/-1, размеры буферов ровно DM_PARTY_WIRE_SIZE; decode проверяет форму и ID узла. */
|
||||
int dm_party_encode(const struct dm_party* party, uint8_t out[DM_PARTY_WIRE_SIZE]);
|
||||
int dm_party_decode(const uint8_t data[DM_PARTY_WIRE_SIZE], struct dm_party* out);
|
||||
|
||||
/* 1 при совпадении всех полей (без padding), иначе 0. Аргументы обязательны. */
|
||||
int dm_party_equal(const struct dm_party* a, const struct dm_party* b);
|
||||
|
||||
/* 0 при ошибке, иначе положительный 63-битный ID. DEVICE использует прежний
|
||||
* dm_derive_conv_id, GROUPS — домен и отсортированные полные стороны.
|
||||
* Одинаковые стороны отклоняются; ID не заменяет проверку полной пары. */
|
||||
uint64_t dm_identity_conv_id(enum dm_mode mode, const struct dm_party parties[2]);
|
||||
|
||||
/* 0/-1: собрать уникальные известные устройства обеих закреплённых сторон,
|
||||
* включая источник и офлайн. Сортировка по node; источник — обычная запись.
|
||||
* Переполнение capacity или DM_TARGET_MAX отклоняет весь snapshot. Каждая
|
||||
* сторона должна иметь хотя бы один узел; ошибки SQL/подписей не пропускаются. */
|
||||
int dm_identity_targets(struct UTUN_INSTANCE* inst, const struct dm_party parties[2],
|
||||
struct dm_target* out, size_t capacity, size_t* count);
|
||||
|
||||
/* Текущий состав для панели участников: тот же проверенный каталог и ограничения,
|
||||
* но подгруппа без известных устройств допустима. Список доставки не изменяется.
|
||||
* 0/-1; out/count меняются только при успехе. Устройства отсортированы по node_id. */
|
||||
int dm_identity_members(struct UTUN_INSTANCE* inst, const struct dm_party parties[2],
|
||||
struct dm_target* out, size_t capacity, size_t* count);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -6,10 +6,12 @@
|
||||
#include "dm_core.h"
|
||||
#include "dm_mailbox_media.h"
|
||||
#include "dm_media_priv.h"
|
||||
#include "dm_schema.h"
|
||||
#include "../utun_instance.h"
|
||||
#include "../ntp_time.h"
|
||||
#include "../routing_layer/etcp_router.h"
|
||||
#include "../routing_layer/topo_node_sqlite.h"
|
||||
#include "../routing_layer/topo_group.h"
|
||||
#include "../transport_layer/etcp.h"
|
||||
#include "../../lib/mem.h"
|
||||
#include "../../lib/ll_queue.h"
|
||||
@@ -23,7 +25,8 @@
|
||||
#define MB_PUT 1 /* [recipient:8][каноническое тело] */
|
||||
#define MB_DELIVER 2 /* [каноническое тело] */
|
||||
#define MB_RECEIPT 3 /* [подписанная квитанция получателя] */
|
||||
#define MB_STORED 4 /* [conv:8][seq:8] — durable PUT подтверждён суперузлом */
|
||||
#define MB_STORED 4 /* [conv:8][seq:8][recipient:8] — durable PUT конкретному устройству */
|
||||
#define MB_MEDIA_REQUEST 5 /* [recipient:8][file-message], отдельный заказ хранения */
|
||||
|
||||
struct dm_mb_state {
|
||||
struct UTUN_INSTANCE* inst;
|
||||
@@ -36,33 +39,11 @@ struct dm_mb_media* dm_mailbox_get_media(struct UTUN_INSTANCE* inst) {
|
||||
return inst && inst->dm_mailbox ? inst->dm_mailbox->media : NULL;
|
||||
}
|
||||
|
||||
/* Проверить роль в конкретной общей группе; storage не заменяет supernode. */
|
||||
static int mb_scope(struct dm_mb_state* mb, uint64_t gid, uint64_t super, uint64_t a, uint64_t b) {
|
||||
if (!gid) return 0;
|
||||
char ch[64];
|
||||
snprintf(ch, sizeof(ch), "%llu", (unsigned long long)gid);
|
||||
char sql[256];
|
||||
snprintf(sql, sizeof(sql), "SELECT adm_tags FROM \"peers_%s\" WHERE node_id=? AND deleted=0", ch);
|
||||
sqlite3_stmt* st = NULL;
|
||||
if (sqlite3_prepare_v2(mb->db, sql, -1, &st, NULL) != SQLITE_OK) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_DM, "%s: role query failed group=%llu: %s", MB_ID,
|
||||
(unsigned long long)gid, sqlite3_errmsg(mb->db));
|
||||
return 0;
|
||||
}
|
||||
sqlite3_bind_int64(st, 1, (sqlite3_int64)super);
|
||||
int allowed = 0;
|
||||
char role[16];
|
||||
if (sqlite3_step(st) == SQLITE_ROW) {
|
||||
const char* tags = (const char*)sqlite3_column_text(st, 0);
|
||||
allowed = tags && json_flat_get(tags, "supernode", role, sizeof(role)) == 0 && !strcmp(role, "yes");
|
||||
}
|
||||
sqlite3_finalize(st);
|
||||
return allowed && topo_node_sqlite_member_in_channel(mb->db, ch, a) &&
|
||||
topo_node_sqlite_member_in_channel(mb->db, ch, b);
|
||||
}
|
||||
|
||||
/* Подписать контроль и передать владение пакетом router, учитывая backpressure. */
|
||||
static int mb_send(struct dm_mb_state* mb, uint64_t gid, uint64_t dst, uint8_t cmd, const uint8_t* body, size_t len) {
|
||||
if (!dm_route_group(mb->inst, dst, gid, &gid)) {
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_DM, "%s: no admitted route peer=%llu cmd=%u", MB_ID, (unsigned long long)dst, cmd); return -1;
|
||||
}
|
||||
if (!etcp_router_conn_get(mb->inst, gid, dst, ETCP_RT_ID_DM_MAILBOX)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_DM, "%s: router channel allocation failed peer=%llu", MB_ID, (unsigned long long)dst);
|
||||
return -1;
|
||||
@@ -98,7 +79,7 @@ static int mb_exec(struct dm_mb_state* mb, const char* sql) {
|
||||
}
|
||||
|
||||
/* Принять неизменяемое тело; подтверждать только durable запись или точный повтор. */
|
||||
static void mb_put_received(struct dm_mb_state* mb, uint64_t gid, uint64_t source, const uint8_t* p, size_t len) {
|
||||
static void mb_put_received(struct dm_mb_state* mb, uint64_t gid, uint64_t source, const uint8_t* p, size_t len, int media_only) {
|
||||
if (len < 8) { DEBUG_WARN(DEBUG_CATEGORY_DM, "%s: short PUT bytes=%zu", MB_ID, len); return; }
|
||||
uint64_t recipient, author, seq;
|
||||
memcpy(&recipient, p, 8);
|
||||
@@ -107,15 +88,16 @@ static void mb_put_received(struct dm_mb_state* mb, uint64_t gid, uint64_t sourc
|
||||
if (dm_verify_message(mb->inst, recipient, p, len) != 0) return;
|
||||
memcpy(&seq, p + 8, 8);
|
||||
memcpy(&author, p + 24, 8);
|
||||
if (source != author || !mb_scope(mb, gid, mb->inst->node_id, author, recipient)) {
|
||||
if (source != author || !dm_peer_role(mb->inst, mb->inst->node_id, "supernode")) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_DM, "%s: unauthorized PUT source=%llu recipient=%llu group=%llu", MB_ID,
|
||||
(unsigned long long)source, (unsigned long long)recipient, (unsigned long long)gid);
|
||||
return;
|
||||
}
|
||||
sqlite3_stmt* st = NULL;
|
||||
if (mb_exec(mb, "BEGIN IMMEDIATE") != 0) return;
|
||||
if (dm_mailbox_media_register(mb->media, gid, recipient, p, len) != 0) {
|
||||
mb_exec(mb, "ROLLBACK");
|
||||
if (media_only) {
|
||||
if (!dm_message_media(p, len) || dm_mailbox_media_register(mb->media, gid, recipient, p, len) || mb_exec(mb, "COMMIT"))
|
||||
mb_exec(mb, "ROLLBACK");
|
||||
return;
|
||||
}
|
||||
int rc = sqlite3_prepare_v2(mb->db,
|
||||
@@ -140,14 +122,13 @@ static void mb_put_received(struct dm_mb_state* mb, uint64_t gid, uint64_t sourc
|
||||
sqlite3_finalize(st);
|
||||
st = NULL;
|
||||
rc = sqlite3_prepare_v2(mb->db,
|
||||
"INSERT INTO dm_pending_messages(recipient,sender,seq,msg,group_id,retry_at) VALUES(?,?,?,?,?,0)"
|
||||
"INSERT INTO dm_pending_messages(recipient,sender,seq,msg,retry_at) VALUES(?,?,?,?,0)"
|
||||
" ON CONFLICT(recipient,sender,seq) DO NOTHING", -1, &st, NULL);
|
||||
if (rc != SQLITE_OK) goto fail;
|
||||
sqlite3_bind_int64(st, 1, (sqlite3_int64)recipient);
|
||||
sqlite3_bind_int64(st, 2, (sqlite3_int64)author);
|
||||
sqlite3_bind_int64(st, 3, (sqlite3_int64)seq);
|
||||
sqlite3_bind_blob(st, 4, p, (int)len, SQLITE_STATIC);
|
||||
sqlite3_bind_int64(st, 5, (sqlite3_int64)gid);
|
||||
rc = sqlite3_step(st);
|
||||
if (rc != SQLITE_DONE) goto fail;
|
||||
sqlite3_finalize(st);
|
||||
@@ -164,7 +145,8 @@ static void mb_put_received(struct dm_mb_state* mb, uint64_t gid, uint64_t sourc
|
||||
sqlite3_finalize(st);
|
||||
st = NULL;
|
||||
if (mb_exec(mb, "COMMIT") != 0) goto fail;
|
||||
mb_send(mb, gid, author, MB_STORED, p, 16);
|
||||
uint8_t stored[24]; memcpy(stored, p, 16); memcpy(stored + 16, &recipient, 8);
|
||||
mb_send(mb, gid, author, MB_STORED, stored, sizeof(stored));
|
||||
DEBUG_INFO(DEBUG_CATEGORY_DM, "%s: stored sender=%llu recipient=%llu seq=%llu group=%llu", MB_ID,
|
||||
(unsigned long long)author, (unsigned long long)recipient, (unsigned long long)seq, (unsigned long long)gid);
|
||||
return;
|
||||
@@ -185,7 +167,7 @@ static void mb_receipt_received(struct dm_mb_state* mb, uint64_t gid, uint64_t s
|
||||
dm_accept_receipt(mb->inst, r);
|
||||
return;
|
||||
}
|
||||
if ((source != author && source != recipient) || !mb_scope(mb, gid, mb->inst->node_id, author, recipient)) {
|
||||
if (source != author && source != recipient) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_DM, "%s: receipt outside authorized scope source=%llu group=%llu", MB_ID,
|
||||
(unsigned long long)source, (unsigned long long)gid);
|
||||
return;
|
||||
@@ -205,14 +187,13 @@ static void mb_receipt_received(struct dm_mb_state* mb, uint64_t gid, uint64_t s
|
||||
sqlite3_finalize(st);
|
||||
st = NULL;
|
||||
rc = sqlite3_prepare_v2(mb->db,
|
||||
"INSERT INTO dm_mail_receipts(recipient,sender,seq,receipt,group_id) VALUES(?,?,?,?,?)"
|
||||
"INSERT INTO dm_mail_receipts(recipient,sender,seq,receipt) VALUES(?,?,?,?)"
|
||||
" ON CONFLICT(recipient,sender,seq) DO NOTHING", -1, &st, NULL);
|
||||
if (rc != SQLITE_OK) goto fail;
|
||||
sqlite3_bind_int64(st, 1, (sqlite3_int64)recipient);
|
||||
sqlite3_bind_int64(st, 2, (sqlite3_int64)author);
|
||||
sqlite3_bind_int64(st, 3, (sqlite3_int64)seq);
|
||||
sqlite3_bind_blob(st, 4, r, DM_RECEIPT_SIZE, SQLITE_STATIC);
|
||||
sqlite3_bind_int64(st, 5, (sqlite3_int64)gid);
|
||||
rc = sqlite3_step(st);
|
||||
if (rc != SQLITE_DONE) goto fail;
|
||||
sqlite3_finalize(st);
|
||||
@@ -255,12 +236,14 @@ static void mb_recv(struct ETCP_CONN* conn, struct ll_entry* entry) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_DM, "%s: unsigned control source=%llu", MB_ID, (unsigned long long)source);
|
||||
goto done;
|
||||
}
|
||||
if (p[0] == MB_PUT) mb_put_received(mb, gid, source, p + 1, len);
|
||||
if (!topo_group_chat_allowed(mb->inst, gid, source)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_DM, "%s: denied group=%llu source=%llu cmd=%u", MB_ID,
|
||||
(unsigned long long)gid, (unsigned long long)source, p[0]); goto done;
|
||||
}
|
||||
if (p[0] == MB_PUT || p[0] == MB_MEDIA_REQUEST) mb_put_received(mb, gid, source, p + 1, len, p[0] == MB_MEDIA_REQUEST);
|
||||
else if (p[0] == MB_DELIVER) {
|
||||
if (len < DM_MSG_FIXED_HDR) { DEBUG_WARN(DEBUG_CATEGORY_DM, "%s: short delivery", MB_ID); goto done; }
|
||||
uint64_t author;
|
||||
memcpy(&author, p + 25, 8);
|
||||
if (!mb_scope(mb, gid, source, author, mb->inst->node_id)) {
|
||||
if (!dm_peer_role(mb->inst, source, "supernode")) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_DM, "%s: delivery from non-supernode source=%llu", MB_ID, (unsigned long long)source);
|
||||
goto done;
|
||||
}
|
||||
@@ -268,10 +251,22 @@ static void mb_recv(struct ETCP_CONN* conn, struct ll_entry* entry) {
|
||||
if (dm_accept_message(mb->inst, p + 1, len, receipt) == 0)
|
||||
mb_send(mb, gid, source, MB_RECEIPT, receipt, sizeof(receipt));
|
||||
} else if (p[0] == MB_RECEIPT && len == DM_RECEIPT_SIZE) mb_receipt_received(mb, gid, source, p + 1);
|
||||
else if (p[0] == MB_STORED && len == 16) {
|
||||
uint64_t conv, seq;
|
||||
else if (p[0] == MB_STORED && len == 24) {
|
||||
uint64_t conv, seq, recipient;
|
||||
memcpy(&conv, p + 1, 8);
|
||||
memcpy(&seq, p + 9, 8);
|
||||
memcpy(&recipient, p + 17, 8);
|
||||
if (!dm_peer_role(mb->inst, source, "supernode")) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_DM, "DM mailbox: unauthorized STORED super=%llu", (unsigned long long)source); goto done;
|
||||
}
|
||||
char id[64]; snprintf(id, sizeof(id), "%llu", (unsigned long long)conv);
|
||||
sqlite3_stmt* st = dm_media_prepare(mb->inst,
|
||||
"UPDATE dm_delivery SET supernode=? WHERE conv_id=? AND author=? AND seq=? AND recipient=? AND receipt IS NULL");
|
||||
if (!st) goto done;
|
||||
sqlite3_bind_int64(st, 1, (sqlite3_int64)source); sqlite3_bind_text(st, 2, id, -1, SQLITE_STATIC);
|
||||
sqlite3_bind_int64(st, 3, (sqlite3_int64)mb->inst->node_id); sqlite3_bind_int64(st, 4, (sqlite3_int64)seq);
|
||||
sqlite3_bind_int64(st, 5, (sqlite3_int64)recipient);
|
||||
if (dm_media_step(mb->inst, st)) goto done;
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_DM, "%s: custody confirmed super=%llu conv=%llu seq=%llu (await recipient)", MB_ID,
|
||||
(unsigned long long)source, (unsigned long long)conv, (unsigned long long)seq);
|
||||
} else DEBUG_WARN(DEBUG_CATEGORY_DM, "%s: invalid command=%u bytes=%zu", MB_ID, p[0], len);
|
||||
@@ -288,7 +283,7 @@ static void mb_tick(void* arg) {
|
||||
for (int i = 0; i < 32; i++) {
|
||||
sqlite3_stmt* st = NULL;
|
||||
int rc = sqlite3_prepare_v2(mb->db,
|
||||
"SELECT recipient,sender,seq,msg,group_id FROM dm_pending_messages"
|
||||
"SELECT recipient,sender,seq,msg FROM dm_pending_messages"
|
||||
" WHERE retry_at<=? ORDER BY retry_at,seq LIMIT 1", -1, &st, NULL);
|
||||
if (rc != SQLITE_OK) goto fail;
|
||||
sqlite3_bind_int64(st, 1, now);
|
||||
@@ -296,7 +291,7 @@ static void mb_tick(void* arg) {
|
||||
if (rc == SQLITE_DONE) { sqlite3_finalize(st); break; }
|
||||
if (rc != SQLITE_ROW) goto fail;
|
||||
uint64_t recipient = (uint64_t)sqlite3_column_int64(st, 0), author = (uint64_t)sqlite3_column_int64(st, 1);
|
||||
uint64_t seq = (uint64_t)sqlite3_column_int64(st, 2), gid = (uint64_t)sqlite3_column_int64(st, 4);
|
||||
uint64_t seq = (uint64_t)sqlite3_column_int64(st, 2);
|
||||
size_t len = (size_t)sqlite3_column_bytes(st, 3);
|
||||
uint8_t* msg = u_malloc(len);
|
||||
if (!msg) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "%s: delivery allocation failed", MB_ID); sqlite3_finalize(st); break; }
|
||||
@@ -315,9 +310,8 @@ static void mb_tick(void* arg) {
|
||||
sqlite3_finalize(st);
|
||||
st = NULL;
|
||||
if (rc != SQLITE_DONE) { u_free(msg); goto fail; }
|
||||
uint64_t route_gid = gid;
|
||||
if (dm_route_group(mb->inst, recipient, gid, &route_gid) &&
|
||||
mb_scope(mb, route_gid, mb->inst->node_id, author, recipient)) {
|
||||
uint64_t route_gid = 0;
|
||||
if (dm_route_group(mb->inst, recipient, 0, &route_gid)) {
|
||||
mb_send(mb, route_gid, recipient, MB_DELIVER, msg, len);
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_DM, "%s: pending delivery sender=%llu recipient=%llu seq=%llu group=%llu", MB_ID,
|
||||
(unsigned long long)author, (unsigned long long)recipient, (unsigned long long)seq,
|
||||
@@ -342,13 +336,13 @@ int dm_mailbox_init(struct UTUN_INSTANCE* inst) {
|
||||
if (!mb) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "%s: state allocation failed", MB_ID); return -1; }
|
||||
mb->inst = inst;
|
||||
mb->db = inst->topo_sqlite_db;
|
||||
if (mb_exec(mb, "CREATE TABLE IF NOT EXISTS dm_pending_messages (recipient INTEGER NOT NULL,sender INTEGER NOT NULL,"
|
||||
"seq INTEGER NOT NULL,msg BLOB NOT NULL,group_id INTEGER NOT NULL,retry_at INTEGER NOT NULL DEFAULT 0,"
|
||||
"PRIMARY KEY(recipient,sender,seq))") != 0 ||
|
||||
mb_exec(mb, "CREATE TABLE IF NOT EXISTS dm_mail_receipts (recipient INTEGER NOT NULL,sender INTEGER NOT NULL,"
|
||||
"seq INTEGER NOT NULL,receipt BLOB NOT NULL,group_id INTEGER NOT NULL,"
|
||||
"PRIMARY KEY(recipient,sender,seq))") != 0) goto fail;
|
||||
if (dm_mailbox_schema_init(inst)) goto fail;
|
||||
if (etcp_router_bind(inst, ETCP_RT_ID_DM_MAILBOX, mb_recv) != 0) goto fail;
|
||||
if (etcp_router_require_crypto(inst, ETCP_RT_ID_DM_MAILBOX, ROUTER_FLAG_SIGNED) ||
|
||||
etcp_router_set_authorize(inst, ETCP_RT_ID_DM_MAILBOX, topo_group_chat_allowed)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_DM, "%s: router policy setup failed", MB_ID);
|
||||
etcp_router_unbind(inst, ETCP_RT_ID_DM_MAILBOX); goto fail;
|
||||
}
|
||||
inst->dm_mailbox = mb;
|
||||
mb->media = dm_mailbox_media_create(inst);
|
||||
if (!mb->media) {
|
||||
@@ -383,7 +377,7 @@ void dm_mailbox_destroy(struct UTUN_INSTANCE* inst) {
|
||||
u_free(mb);
|
||||
}
|
||||
|
||||
/* Найти суперузлы в общей группе обеих сторон; для PUT выбрать только один. */
|
||||
/* Объявления S ищутся среди своих групп, маршрут до выбранного S выбирается отдельно. */
|
||||
static int mb_to_supernodes(struct dm_mb_state* mb, uint64_t recipient, uint64_t author,
|
||||
uint8_t cmd, const uint8_t* body, size_t len) {
|
||||
uint64_t* ids = NULL;
|
||||
@@ -395,9 +389,9 @@ static int mb_to_supernodes(struct dm_mb_state* mb, uint64_t recipient, uint64_t
|
||||
for (int i = 0; i < count; i++) {
|
||||
char ch[64], sql[256];
|
||||
snprintf(ch, sizeof(ch), "%llu", (unsigned long long)ids[i]);
|
||||
if (!topo_node_sqlite_member_in_channel(mb->db, ch, author) ||
|
||||
!topo_node_sqlite_member_in_channel(mb->db, ch, recipient)) continue;
|
||||
snprintf(sql, sizeof(sql), "SELECT node_id,adm_tags FROM \"peers_%s\" WHERE deleted=0 ORDER BY node_id", ch);
|
||||
if (!topo_group_chat_allowed(mb->inst, ids[i], mb->inst->node_id)) continue;
|
||||
snprintf(sql, sizeof(sql), "SELECT node_id,adm_tags FROM \"peers_%s\""
|
||||
" WHERE record_state=0 AND source=0 AND deleted=0 AND admitted=1 ORDER BY node_id", ch);
|
||||
sqlite3_stmt* st = NULL;
|
||||
if (sqlite3_prepare_v2(mb->db, sql, -1, &st, NULL) != SQLITE_OK) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_DM, "%s: supernode query failed: %s", MB_ID, sqlite3_errmsg(mb->db));
|
||||
@@ -410,17 +404,16 @@ static int mb_to_supernodes(struct dm_mb_state* mb, uint64_t recipient, uint64_t
|
||||
char role[16];
|
||||
if (!tags || json_flat_get(tags, "supernode", role, sizeof(role)) != 0 || strcmp(role, "yes")) continue;
|
||||
if (node == mb->inst->node_id || node == recipient || node == author) continue;
|
||||
struct ETCP_ROUTER_PATH path = etcp_router_get_path(mb->inst, ids[i], node, ETCP_RT_ID_DM_MAILBOX);
|
||||
if (!path.next_hop_node_id) continue;
|
||||
if (!topo_group_chat_allowed(mb->inst, ids[i], node)) continue;
|
||||
if (!mb_send(mb, ids[i], node, cmd, body, len)) {
|
||||
sent++;
|
||||
if (cmd == MB_PUT) break;
|
||||
if (cmd == MB_PUT || cmd == MB_MEDIA_REQUEST) break;
|
||||
}
|
||||
}
|
||||
if (rc != SQLITE_ROW && rc != SQLITE_DONE)
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_DM, "%s: supernode scan failed rc=%d", MB_ID, rc);
|
||||
sqlite3_finalize(st);
|
||||
if (sent && cmd == MB_PUT) break;
|
||||
if (sent && (cmd == MB_PUT || cmd == MB_MEDIA_REQUEST)) break;
|
||||
}
|
||||
u_free(ids);
|
||||
return sent;
|
||||
@@ -451,3 +444,17 @@ void dm_mailbox_receipt(struct UTUN_INSTANCE* inst, const uint8_t* receipt) {
|
||||
memcpy(&recipient, receipt + 32, 8);
|
||||
mb_to_supernodes(mb, recipient, author, MB_RECEIPT, receipt, DM_RECEIPT_SIZE);
|
||||
}
|
||||
|
||||
int dm_mailbox_store_file(struct UTUN_INSTANCE* inst, uint64_t recipient, const uint8_t* msg, size_t len) {
|
||||
struct dm_mb_state* mb = inst ? inst->dm_mailbox : NULL;
|
||||
uint64_t author = 0;
|
||||
if (len >= DM_MSG_FIXED_HDR) memcpy(&author, msg + 24, 8);
|
||||
if (!mb || author != inst->node_id || !dm_message_media(msg, len) || dm_verify_message(inst, recipient, msg, len)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM mailbox: invalid local file storage request"); return -1;
|
||||
}
|
||||
uint8_t body[8 + DM_MEDIA_BODY_MAX]; memcpy(body, &recipient, 8); memcpy(body + 8, msg, len);
|
||||
int sent = mb_to_supernodes(mb, recipient, author, MB_MEDIA_REQUEST, body, len + 8);
|
||||
if (sent <= 0) DEBUG_DEBUG(DEBUG_CATEGORY_DM, "DM file storage: waiting for authorized reachable S recipient=%llu",
|
||||
(unsigned long long)recipient);
|
||||
return sent > 0 ? 0 : -1;
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
/*
|
||||
* dm_mailbox.h — очередь offline-сообщений на суперузлах
|
||||
*
|
||||
* Суперузел общей группы хранит непрозрачное подписанное тело и повторяет
|
||||
* Суперузел хранит непрозрачное подписанное тело и повторяет
|
||||
* доставку при появлении маршрута до получателя, независимо от отправителя.
|
||||
* Копия удаляется только по квитанции получателя после commit в dm_core.
|
||||
* Квитанция сохраняется отдельно для повторов PUT и доставки отправителю.
|
||||
*
|
||||
* Роль S — supernode=yes в подписанных adm_tags общей CHAT-группы A/B; storage её не заменяет.
|
||||
* При PUT от A в одной транзакции фиксируются сообщение и, для file, задание dm_mailbox_media.
|
||||
* Роль S — supernode=yes в подписанных adm_tags допущенной общей группы проверяющего узла и S.
|
||||
* Пары A/S и S/B выбирают маршруты независимо; общая группа A/S/B не требуется.
|
||||
* Каждая передача проверяет текущий допуск self/peer и подпись router, включая controls.
|
||||
* Группа не записывается в pending/receipt; отзыв пути сохраняет очередь и позволяет другой маршрут.
|
||||
* storage не заменяет роль supernode. Квитанция точного сообщения завершает прежнюю работу и после отзыва роли S.
|
||||
* PUT фиксирует сообщение; независимый MEDIA_REQUEST регистрирует задание dm_mailbox_media.
|
||||
* Сервис ETCP_RT_ID_DM_MAILBOX (0x35); все пакеты подписаны router, числа native-endian:
|
||||
* PUT=1 [recipient:8][message]; DELIVER=2 [message]; RECEIPT=3 [receipt:136]; STORED=4 [conv:8][seq:8].
|
||||
* PUT=1 [recipient:8][message]; DELIVER=2 [message]; RECEIPT=3 [receipt:136]; STORED=4 [conv:8][seq:8][recipient:8].
|
||||
* MEDIA_REQUEST=5 [recipient:8][file-message]; ACK текста не отменяет потребность в файловом хранилище.
|
||||
* STORED подтверждает запись на S, только подписанная B квитанция подтверждает доставку сообщения.
|
||||
* Текст не имеет TTL. Таймер 1с обрабатывает до 32 задач с retry_at через 5с.
|
||||
* Входные буферы заимствуются на время вызова; их копией в пакете/SQLite владеет соответствующий owner.
|
||||
@@ -28,13 +33,13 @@ extern "C" {
|
||||
#endif
|
||||
|
||||
/* Все API в uasync. init после chat_core_init, с живыми SQLite/ua/router ядра:
|
||||
* создаёт таблицы, media-координатор и таймер; 0=успех (также повторный init), -1=ошибка.
|
||||
* создаёт таблицы, media-координатор и таймер независимо от dm_core_init; 0=успех, -1=ошибка.
|
||||
* destroy отменяет таймеры/bind до освобождения owner, сохраняет pending/receipts в SQLite.
|
||||
* Вызывать после остановки dm_media, до chat_core_destroy; inst=NULL допустим для destroy. */
|
||||
int dm_mailbox_init(struct UTUN_INSTANCE* inst);
|
||||
void dm_mailbox_destroy(struct UTUN_INSTANCE* inst);
|
||||
|
||||
/* Поставить PUT выбранному доступному суперузлу общей с recipient группы.
|
||||
/* Поставить PUT выбранному доступному суперузлу общей с локальным автором группы.
|
||||
* 0 означает принятие router, хранение подтверждается отдельно на проводе.
|
||||
* До квитанции получателя источник сохраняет задачу в durable outbox.
|
||||
* sender должен совпадать с inst->node_id; msg/len — полное подписанное тело dm_core.
|
||||
@@ -42,6 +47,9 @@ void dm_mailbox_destroy(struct UTUN_INSTANCE* inst);
|
||||
* -1 при ошибке, отсутствии доступного S или backpressure; сам вызов outbox не создаёт. */
|
||||
int dm_mailbox_put(struct UTUN_INSTANCE* inst, uint64_t recipient, uint64_t sender,
|
||||
const uint8_t* msg, size_t msg_len);
|
||||
/* Независимый от text outbox заказ хранения file на S. Те же авторизация/выбор S,
|
||||
* сообщение в mailbox не создаёт. 0=принято transport, -1=нет S/ошибка; caller повторяет. */
|
||||
int dm_mailbox_store_file(struct UTUN_INSTANCE* inst, uint64_t recipient, const uint8_t* msg, size_t msg_len);
|
||||
|
||||
/* Попытаться передать точную квитанцию всем доступным суперузлам общих групп сторон.
|
||||
* receipt — DM_RECEIPT_SIZE байт (см. dm_core.h), проверяется подпись получателя.
|
||||
|
||||
@@ -2,16 +2,20 @@
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <openssl/sha.h>
|
||||
#include <openssl/rand.h>
|
||||
|
||||
#include "../../lib/mem.h"
|
||||
#include "../../lib/debug_config.h"
|
||||
#include "../../lib/json_flat.h"
|
||||
#include "dm_media_priv.h"
|
||||
#include "dm_media_receipt.h"
|
||||
#include "dm_schema.h"
|
||||
#include "dm_mailbox_media.h"
|
||||
#include "../utun_instance.h"
|
||||
#include "../ntp_time.h"
|
||||
#include "../routing_layer/etcp_router.h"
|
||||
#include "../routing_layer/topo_node_sqlite.h"
|
||||
#include "../routing_layer/topo_group.h"
|
||||
|
||||
struct dm_mb_media {
|
||||
struct UTUN_INSTANCE* inst;
|
||||
@@ -28,13 +32,12 @@ int dm_mailbox_media_register(struct dm_mb_media* media, uint64_t group, uint64_
|
||||
uint64_t author, seq;
|
||||
memcpy(&author, body + 24, 8); memcpy(&seq, body + 8, 8);
|
||||
uint8_t hash[32]; SHA256(body, len, hash);
|
||||
sqlite3_stmt* st = dm_media_prepare(inst, "INSERT INTO dm_media_jobs(id,author,recipient,seq,body,body_hash,group_id)"
|
||||
" VALUES(?,?,?,?,?,?,?) ON CONFLICT(id) DO NOTHING");
|
||||
sqlite3_stmt* st = dm_media_prepare(inst, "INSERT INTO dm_media_jobs(id,author,recipient,seq,body,body_hash)"
|
||||
" VALUES(?,?,?,?,?,?) ON CONFLICT(id) DO NOTHING");
|
||||
if (!st) return -1;
|
||||
sqlite3_bind_blob(st, 1, desc, 16, SQLITE_STATIC); sqlite3_bind_int64(st, 2, (sqlite3_int64)author);
|
||||
sqlite3_bind_int64(st, 3, (sqlite3_int64)recipient); sqlite3_bind_int64(st, 4, (sqlite3_int64)seq);
|
||||
sqlite3_bind_blob(st, 5, body, (int)len, SQLITE_STATIC); sqlite3_bind_blob(st, 6, hash, 32, SQLITE_STATIC);
|
||||
sqlite3_bind_int64(st, 7, (sqlite3_int64)group);
|
||||
if (dm_media_step(inst, st)) return -1;
|
||||
int inserted = sqlite3_changes(inst->topo_sqlite_db);
|
||||
st = dm_media_prepare(inst, "SELECT body_hash FROM dm_media_jobs WHERE id=?");
|
||||
@@ -46,7 +49,7 @@ int dm_mailbox_media_register(struct dm_mb_media* media, uint64_t group, uint64_
|
||||
if (!exact) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_mailbox_media: conflicting media UUID/body"); return -1; }
|
||||
if (inserted) DEBUG_INFO(DEBUG_CATEGORY_DM, "dm_mailbox_media: registered author=%llu recipient=%llu seq=%llu group=%llu",
|
||||
(unsigned long long)author, (unsigned long long)recipient, (unsigned long long)seq, (unsigned long long)group);
|
||||
return 0;
|
||||
return dm_media_targets_seed(inst, body, len, recipient);
|
||||
}
|
||||
|
||||
/* Список S используется только для пересылки подписанного подтверждения B. */
|
||||
@@ -61,7 +64,9 @@ void dm_mailbox_media_receipt(struct UTUN_INSTANCE* inst, const uint8_t* receipt
|
||||
}
|
||||
for (int i = 0; i < count; i++) {
|
||||
char sql[192];
|
||||
snprintf(sql, sizeof(sql), "SELECT node_id FROM \"peers_%llu\" WHERE deleted=0", (unsigned long long)groups[i]);
|
||||
if (!topo_group_chat_allowed(inst, groups[i], inst->node_id)) continue;
|
||||
snprintf(sql, sizeof(sql), "SELECT node_id FROM \"peers_%llu\" WHERE record_state=0 AND source=0 AND deleted=0 AND admitted=1",
|
||||
(unsigned long long)groups[i]);
|
||||
sqlite3_stmt* st = dm_media_prepare(inst, sql);
|
||||
if (!st) continue;
|
||||
uint64_t peers[64];
|
||||
@@ -69,14 +74,12 @@ void dm_mailbox_media_receipt(struct UTUN_INSTANCE* inst, const uint8_t* receipt
|
||||
int rc;
|
||||
while ((rc = sqlite3_step(st)) == SQLITE_ROW && n < 64) {
|
||||
uint64_t node = (uint64_t)sqlite3_column_int64(st, 0);
|
||||
if (node != author && node != recipient && dm_media_scope(inst, groups[i], node, "supernode", author, recipient))
|
||||
if (node != author && node != recipient && dm_peer_role(inst, node, "supernode"))
|
||||
peers[n++] = node;
|
||||
}
|
||||
if (rc != SQLITE_ROW && rc != SQLITE_DONE) DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_mailbox_media: supernode scan failed rc=%d", rc);
|
||||
sqlite3_finalize(st);
|
||||
for (unsigned j = 0; j < n; j++)
|
||||
if (peers[j] == inst->node_id || etcp_router_get_path(inst, groups[i], peers[j], ETCP_RT_ID_DM_MEDIA).next_hop_node_id)
|
||||
dm_media_send(inst, groups[i], peers[j], DM_MEDIA_RECEIPT, receipt, DM_MEDIA_RECEIPT_SIZE);
|
||||
for (unsigned j = 0; j < n; j++) dm_media_send(inst, 0, peers[j], DM_MEDIA_RECEIPT, receipt, DM_MEDIA_RECEIPT_SIZE);
|
||||
}
|
||||
u_free(groups);
|
||||
}
|
||||
@@ -84,45 +87,50 @@ void dm_mailbox_media_receipt(struct UTUN_INSTANCE* inst, const uint8_t* receipt
|
||||
/* Подтверждение медиадоставки сохраняется до начала DELETE и никогда не отменяется STORED. */
|
||||
static void dm_job_receipt(struct dm_mb_media* media, uint64_t group, uint64_t source, const uint8_t* r) {
|
||||
struct UTUN_INSTANCE* inst = media->inst;
|
||||
if (dm_media_verify_receipt(inst, r, NULL, 0)) return;
|
||||
if (dm_media_receipt_kind(r) < 0) { DEBUG_WARN(DEBUG_CATEGORY_DM, "DM custody: invalid receipt domain"); return; }
|
||||
uint64_t author, recipient;
|
||||
memcpy(&author, r + 24, 8); memcpy(&recipient, r + 32, 8);
|
||||
if (!dm_media_scope(inst, group, inst->node_id, "supernode", author, recipient)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_mailbox_media: receipt outside S scope source=%llu", (unsigned long long)source); return;
|
||||
}
|
||||
sqlite3_stmt* st = dm_media_prepare(inst, "SELECT body_hash,receipt FROM dm_media_jobs WHERE id=?");
|
||||
sqlite3_stmt* st = dm_media_prepare(inst, "SELECT body,receipt FROM dm_media_jobs WHERE id=?");
|
||||
if (!st) return;
|
||||
sqlite3_bind_blob(st, 1, r + 40, 16, SQLITE_STATIC);
|
||||
int rc = sqlite3_step(st);
|
||||
if (rc == SQLITE_DONE) { sqlite3_finalize(st); return; } /* Нет выданных копий на этом S. */
|
||||
int exact = rc == SQLITE_ROW && sqlite3_column_bytes(st, 0) == 32 && !memcmp(sqlite3_column_blob(st, 0), r + 56, 32);
|
||||
int already = rc == SQLITE_ROW && sqlite3_column_bytes(st, 1) == DM_MEDIA_RECEIPT_SIZE;
|
||||
if (already && sqlite3_column_type(st, 0) == SQLITE_NULL) { sqlite3_finalize(st); return; }
|
||||
int exact = rc == SQLITE_ROW && !dm_media_receipt_save(inst, r, sqlite3_column_blob(st, 0), (size_t)sqlite3_column_bytes(st, 0));
|
||||
sqlite3_finalize(st);
|
||||
if (!exact) { DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_mailbox_media: receipt body hash mismatch rc=%d", rc); return; }
|
||||
st = dm_media_prepare(inst, "UPDATE dm_media_jobs SET receipt=?,retry_at=0 WHERE id=? AND receipt IS NULL");
|
||||
st = dm_media_prepare(inst, "UPDATE dm_media_jobs SET retry_at=0 WHERE id=?");
|
||||
if (!st) return;
|
||||
sqlite3_bind_blob(st, 1, r, DM_MEDIA_RECEIPT_SIZE, SQLITE_STATIC); sqlite3_bind_blob(st, 2, r + 40, 16, SQLITE_STATIC);
|
||||
sqlite3_bind_blob(st, 1, r + 40, 16, SQLITE_STATIC);
|
||||
if (dm_media_step(inst, st)) return;
|
||||
if (!already) {
|
||||
DEBUG_INFO(DEBUG_CATEGORY_DM, "dm_mailbox_media: recipient confirmed file; delete jobs activated source=%llu",
|
||||
(unsigned long long)source);
|
||||
dm_media_send(inst, group, author, DM_MEDIA_RECEIPT, r, DM_MEDIA_RECEIPT_SIZE);
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_DM, "dm_mailbox_media: device fact stored source=%llu kind=%d",
|
||||
(unsigned long long)source, dm_media_receipt_kind(r));
|
||||
if (source != author) dm_media_send(inst, group, author, DM_MEDIA_RECEIPT, r, DM_MEDIA_RECEIPT_SIZE);
|
||||
}
|
||||
}
|
||||
|
||||
/* Освобождение назначения и сброс текущего holder — один durable переход. */
|
||||
static int dm_job_release(struct dm_mb_media* media, const uint8_t id[16], uint64_t holder, int expired) {
|
||||
static int dm_job_release(struct dm_mb_media* media, const uint8_t id[16], uint64_t holder,
|
||||
const uint8_t assignment[16], int expired) {
|
||||
struct UTUN_INSTANCE* inst = media->inst;
|
||||
if (dm_media_exec(inst, "BEGIN IMMEDIATE")) return -1;
|
||||
const char* sql[] = { "UPDATE dm_media_holders SET released=1 WHERE id=? AND node_id=? AND released=0", expired ?
|
||||
"UPDATE dm_media_jobs SET holder=0,stored=0,expired=1,retry_at=0 WHERE id=? AND holder=? AND receipt IS NULL" :
|
||||
"UPDATE dm_media_jobs SET holder=0,stored=0,retry_at=0 WHERE id=? AND holder=? AND receipt IS NULL" };
|
||||
const char* sql[] = { expired ?
|
||||
"UPDATE dm_media_holders SET released=2 WHERE id=? AND node_id=? AND assignment_id=? AND released=0" :
|
||||
"UPDATE dm_media_holders SET released=3 WHERE id=? AND node_id=? AND assignment_id=? AND released=0", expired ?
|
||||
"UPDATE dm_media_jobs SET holder=0,stored=0,expired=1,retry_at=0 WHERE id=?1 AND holder=?2 AND receipt IS NULL"
|
||||
" AND EXISTS(SELECT 1 FROM dm_media_holders h WHERE h.id=?1 AND h.node_id=?2 AND h.assignment_id=?3)" :
|
||||
"UPDATE dm_media_jobs SET holder=0,stored=0,expires=0,delete_on_open=-1,retry_at=?4 WHERE id=?1 AND holder=?2 AND receipt IS NULL"
|
||||
" AND EXISTS(SELECT 1 FROM dm_media_holders h WHERE h.id=?1 AND h.node_id=?2 AND h.assignment_id=?3)" };
|
||||
int changed[2];
|
||||
for (int i = 0; i < 2; i++) {
|
||||
sqlite3_stmt* st = dm_media_prepare(inst, sql[i]);
|
||||
if (!st) goto rollback;
|
||||
int rc = sqlite3_bind_blob(st, 1, id, 16, SQLITE_STATIC);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 2, (sqlite3_int64)holder);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 3, assignment, 16, SQLITE_STATIC);
|
||||
if (rc == SQLITE_OK && i == 1 && !expired) rc = sqlite3_bind_int64(st, 4, (sqlite3_int64)(ntp_time_get_seconds(inst) + 60));
|
||||
if (rc == SQLITE_OK) rc = sqlite3_step(st);
|
||||
int final = sqlite3_finalize(st);
|
||||
if (rc != SQLITE_DONE || final != SQLITE_OK) {
|
||||
@@ -150,42 +158,68 @@ void dm_mailbox_media_recv(struct dm_mb_media* media, uint64_t group, uint64_t s
|
||||
if (!media) { DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_mailbox_media: control after stop"); return; }
|
||||
struct UTUN_INSTANCE* inst = media->inst;
|
||||
if (cmd == DM_MEDIA_RECEIPT && len == DM_MEDIA_RECEIPT_SIZE) { dm_job_receipt(media, group, source, p); return; }
|
||||
sqlite3_stmt* st = dm_media_prepare(inst, "SELECT j.author,j.recipient,j.receipt,j.holder FROM dm_media_jobs j"
|
||||
" JOIN dm_media_holders h ON h.id=j.id WHERE j.id=? AND h.node_id=? AND h.group_id=?");
|
||||
if (!p || !(((cmd == DM_MEDIA_STORED || cmd == DM_MEDIA_RESERVED) && len == 41) ||
|
||||
((cmd == DM_MEDIA_DELETE_ACK || cmd == DM_MEDIA_EXPIRED) && len == 32) || (cmd == DM_MEDIA_REJECT && len == 33))) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_DM, "DM assignment: invalid response cmd=%u bytes=%zu", cmd, len); return;
|
||||
}
|
||||
const uint8_t* assignment = p; p += 16; len -= 16;
|
||||
sqlite3_stmt* st = dm_media_prepare(inst, "SELECT j.receipt,j.holder,h.released,j.delete_on_open,j.expires FROM dm_media_jobs j"
|
||||
" JOIN dm_media_holders h ON h.id=j.id WHERE j.id=? AND h.node_id=? AND h.assignment_id=?");
|
||||
if (!st) return;
|
||||
sqlite3_bind_blob(st, 1, p, 16, SQLITE_STATIC); sqlite3_bind_int64(st, 2, (sqlite3_int64)source);
|
||||
sqlite3_bind_int64(st, 3, (sqlite3_int64)group);
|
||||
int rc = sqlite3_step(st);
|
||||
if (rc == SQLITE_DONE) { sqlite3_finalize(st); return; }
|
||||
int rc = sqlite3_bind_blob(st, 1, p, 16, SQLITE_STATIC);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 2, (sqlite3_int64)source);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 3, assignment, 16, SQLITE_STATIC);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_step(st);
|
||||
uint64_t token[2], file[2]; memcpy(token, assignment, 16); memcpy(file, p, 16);
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_DM, "DM assignment: response assignment=%016llx%016llx file=%016llx%016llx M=%llu group=%llu cmd=%u rc=%d",
|
||||
(unsigned long long)token[0], (unsigned long long)token[1], (unsigned long long)file[0], (unsigned long long)file[1],
|
||||
(unsigned long long)source, (unsigned long long)group, cmd, rc);
|
||||
if (rc == SQLITE_DONE) {
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_DM, "DM assignment: unknown/stale response M=%llu cmd=%u", (unsigned long long)source, cmd);
|
||||
sqlite3_finalize(st); return;
|
||||
}
|
||||
if (rc != SQLITE_ROW) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_mailbox_media: location lookup failed rc=%d", rc); sqlite3_finalize(st); return; }
|
||||
uint64_t author = (uint64_t)sqlite3_column_int64(st, 0), recipient = (uint64_t)sqlite3_column_int64(st, 1);
|
||||
uint64_t holder = (uint64_t)sqlite3_column_int64(st, 3);
|
||||
uint64_t holder = (uint64_t)sqlite3_column_int64(st, 1);
|
||||
int released = sqlite3_column_int(st, 2);
|
||||
int policy = sqlite3_column_int(st, 3), accepted = holder == source && policy >= 0;
|
||||
uint64_t saved_expiry = (uint64_t)sqlite3_column_int64(st, 4);
|
||||
uint8_t receipt[DM_MEDIA_RECEIPT_SIZE];
|
||||
int delivered = sqlite3_column_bytes(st, 2) == sizeof(receipt);
|
||||
if (delivered) memcpy(receipt, sqlite3_column_blob(st, 2), sizeof(receipt));
|
||||
sqlite3_finalize(st);
|
||||
if (cmd == DM_MEDIA_STORED && len == 24) {
|
||||
int delivered = sqlite3_column_bytes(st, 0) == sizeof(receipt);
|
||||
if (delivered) memcpy(receipt, sqlite3_column_blob(st, 0), sizeof(receipt));
|
||||
rc = sqlite3_finalize(st);
|
||||
if (rc != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM assignment: response lookup finalize failed rc=%d", rc); return; }
|
||||
if ((cmd == DM_MEDIA_STORED || cmd == DM_MEDIA_RESERVED) && len == 25 && p[24] <= 1) {
|
||||
uint64_t expiry;
|
||||
memcpy(&expiry, p + 16, 8);
|
||||
if (delivered) { dm_media_send(inst, group, source, DM_MEDIA_DELETE, receipt, sizeof(receipt)); return; }
|
||||
if (holder != source || expiry <= ntp_time_get_seconds(inst) ||
|
||||
!dm_media_scope(inst, group, source, "storage", author, recipient)) {
|
||||
if (delivered) {
|
||||
uint8_t request[16 + DM_MEDIA_RECEIPT_SIZE]; memcpy(request, assignment, 16); memcpy(request + 16, receipt, sizeof(receipt));
|
||||
dm_media_send(inst, group, source, DM_MEDIA_DELETE, request, sizeof(request)); return;
|
||||
}
|
||||
if (released || holder != source || expiry <= ntp_time_get_seconds(inst)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_mailbox_media: stale/unauthorized STORED source=%llu expiry=%llu",
|
||||
(unsigned long long)source, (unsigned long long)expiry); return;
|
||||
}
|
||||
st = dm_media_prepare(inst, "UPDATE dm_media_jobs SET stored=1,expires=?,retry_at=0 WHERE id=? AND receipt IS NULL AND stored=0");
|
||||
if (accepted && (saved_expiry != expiry || policy != p[24])) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_DM, "DM assignment: changed reservation M=%llu expires=%llu/%llu policy=%d/%u",
|
||||
(unsigned long long)source, (unsigned long long)saved_expiry, (unsigned long long)expiry, policy, p[24]); return;
|
||||
}
|
||||
st = dm_media_prepare(inst, "UPDATE dm_media_jobs SET stored=MAX(stored,?3),expires=?1,delete_on_open=?4,retry_at=0"
|
||||
" WHERE id=?2 AND receipt IS NULL AND (delete_on_open=-1 OR (delete_on_open=?4 AND expires=?1))"
|
||||
" AND (delete_on_open=-1 OR stored<?3)");
|
||||
if (!st) return;
|
||||
sqlite3_bind_int64(st, 1, (sqlite3_int64)expiry); sqlite3_bind_blob(st, 2, p, 16, SQLITE_STATIC);
|
||||
sqlite3_bind_int(st, 3, cmd == DM_MEDIA_STORED); sqlite3_bind_int(st, 4, p[24]);
|
||||
if (dm_media_step(inst, st)) return;
|
||||
if (sqlite3_changes(inst->topo_sqlite_db))
|
||||
DEBUG_INFO(DEBUG_CATEGORY_DM, "dm_mailbox_media: custody committed M=%llu expires=%llu",
|
||||
(unsigned long long)source, (unsigned long long)expiry);
|
||||
} else if ((cmd == DM_MEDIA_DELETE_ACK || cmd == DM_MEDIA_EXPIRED) && len == 16) {
|
||||
if (cmd == DM_MEDIA_EXPIRED && !delivered) { dm_job_release(media, p, source, 1); return; }
|
||||
st = dm_media_prepare(inst, "UPDATE dm_media_holders SET released=1 WHERE id=? AND node_id=? AND released=0");
|
||||
if (cmd == DM_MEDIA_EXPIRED && !delivered) { dm_job_release(media, p, source, assignment, 1); return; }
|
||||
st = dm_media_prepare(inst, "UPDATE dm_media_holders SET released=1 WHERE id=? AND node_id=? AND assignment_id=? AND released=0");
|
||||
if (!st) return;
|
||||
rc = sqlite3_bind_blob(st, 1, p, 16, SQLITE_STATIC);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 2, (sqlite3_int64)source);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 3, assignment, 16, SQLITE_STATIC);
|
||||
if (rc != SQLITE_OK) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_mailbox_media: ACK bind failed M=%llu cmd=%u rc=%d",
|
||||
(unsigned long long)source, cmd, rc);
|
||||
@@ -193,56 +227,89 @@ void dm_mailbox_media_recv(struct dm_mb_media* media, uint64_t group, uint64_t s
|
||||
}
|
||||
if (!dm_media_step(inst, st) && sqlite3_changes(inst->topo_sqlite_db))
|
||||
DEBUG_INFO(DEBUG_CATEGORY_DM, "dm_mailbox_media: assignment released M=%llu cmd=%u", (unsigned long long)source, cmd);
|
||||
} else if (cmd == DM_MEDIA_REJECT && len == 17 && !delivered) {
|
||||
} else if (cmd == DM_MEDIA_REJECT && len == 17 && p[16] == 1 && !delivered) {
|
||||
if (accepted) { DEBUG_WARN(DEBUG_CATEGORY_DM, "DM assignment: REJECT after confirmed reservation M=%llu",
|
||||
(unsigned long long)source); return; }
|
||||
DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_mailbox_media: storage refused M=%llu reason=%u", (unsigned long long)source, p[16]);
|
||||
dm_job_release(media, p, source, 0);
|
||||
if (!released) dm_job_release(media, p, source, assignment, 0);
|
||||
} else DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_mailbox_media: unexpected control cmd=%u bytes=%zu", cmd, len);
|
||||
}
|
||||
|
||||
/* Выбрать один достижимый storage, исключив A/B и прежние отказы/истёкшие назначения. */
|
||||
static uint64_t dm_job_storage(struct dm_mb_media* media, uint64_t group, const uint8_t id[16], uint64_t a, uint64_t b) {
|
||||
/* Сначала новый M, затем новая попытка после отказа по квоте. Истёкшие копии не возрождаем. */
|
||||
static uint64_t dm_job_storage(struct dm_mb_media* media, const uint8_t id[16]) {
|
||||
struct UTUN_INSTANCE* inst = media->inst;
|
||||
char sql[256];
|
||||
snprintf(sql, sizeof(sql), "SELECT node_id,adm_tags FROM \"peers_%llu\" WHERE deleted=0 AND node_id NOT IN"
|
||||
" (SELECT node_id FROM dm_media_holders WHERE id=?) ORDER BY node_id", (unsigned long long)group);
|
||||
sqlite3_stmt* st = dm_media_prepare(inst, sql);
|
||||
if (!st) return 0;
|
||||
sqlite3_bind_blob(st, 1, id, 16, SQLITE_STATIC);
|
||||
uint64_t found = 0;
|
||||
int rc;
|
||||
while ((rc = sqlite3_step(st)) == SQLITE_ROW) {
|
||||
uint64_t node = (uint64_t)sqlite3_column_int64(st, 0);
|
||||
const char* tags = (const char*)sqlite3_column_text(st, 1);
|
||||
char role[16];
|
||||
if (node == a || node == b || !tags || json_flat_get(tags, "storage", role, sizeof(role)) || strcmp(role, "yes")) continue;
|
||||
if (node == inst->node_id || etcp_router_get_path(inst, group, node, ETCP_RT_ID_DM_MEDIA).next_hop_node_id) { found = node; break; }
|
||||
uint64_t* groups = NULL;
|
||||
int count = 0;
|
||||
if (topo_node_sqlite_get_member_channels(inst->topo_sqlite_db, inst->node_id, &groups, &count)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM storage: membership lookup failed"); return 0;
|
||||
}
|
||||
if (rc != SQLITE_ROW && rc != SQLITE_DONE) DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_mailbox_media: storage scan failed rc=%d", rc);
|
||||
sqlite3_finalize(st);
|
||||
uint64_t found = 0;
|
||||
for (int retry = 0; retry < 2 && !found; retry++) for (int i = 0; i < count && !found; i++) {
|
||||
if (!topo_group_chat_allowed(inst, groups[i], inst->node_id)) continue;
|
||||
char sql[384];
|
||||
snprintf(sql, sizeof(sql), "SELECT p.node_id,p.adm_tags FROM \"peers_%llu\" p"
|
||||
" LEFT JOIN dm_media_holders h ON h.node_id=p.node_id AND h.id=?1"
|
||||
" WHERE p.record_state=0 AND p.source=0 AND p.deleted=0 AND p.admitted=1 AND COALESCE(h.released,-1)=?2 ORDER BY p.node_id",
|
||||
(unsigned long long)groups[i]);
|
||||
sqlite3_stmt* st = dm_media_prepare(inst, sql);
|
||||
if (!st) goto done;
|
||||
int rc = sqlite3_bind_blob(st, 1, id, 16, SQLITE_STATIC);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int(st, 2, retry ? 3 : -1);
|
||||
while (rc == SQLITE_OK && (rc = sqlite3_step(st)) == SQLITE_ROW) {
|
||||
uint64_t node = (uint64_t)sqlite3_column_int64(st, 0), route = 0;
|
||||
const char* tags = (const char*)sqlite3_column_text(st, 1);
|
||||
char role[16]; rc = SQLITE_OK;
|
||||
if (!tags || json_flat_get(tags, "storage", role, sizeof(role)) || strcmp(role, "yes")) continue;
|
||||
if (!topo_group_chat_allowed(inst, groups[i], node)) continue;
|
||||
if (node == inst->node_id || dm_route_group(inst, node, 0, &route)) { found = node; break; }
|
||||
}
|
||||
int final = sqlite3_finalize(st);
|
||||
if ((rc != SQLITE_OK && rc != SQLITE_DONE) || final != SQLITE_OK) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM storage: candidate scan failed rc=%d final=%d", rc, final); found = 0; goto done;
|
||||
}
|
||||
}
|
||||
done:
|
||||
u_free(groups);
|
||||
return found;
|
||||
}
|
||||
|
||||
/* Удаление неосвобождённых назначений повторяется до DELETE_ACK; истёкшие завершаются по TTL. */
|
||||
static void dm_job_cleanup(struct dm_mb_media* media, const uint8_t id[16], const uint8_t* receipt) {
|
||||
static void dm_job_cleanup(struct dm_mb_media* media, const uint8_t id[16], int terminal) {
|
||||
struct UTUN_INSTANCE* inst = media->inst;
|
||||
sqlite3_stmt* st = dm_media_prepare(inst, "SELECT node_id,group_id FROM dm_media_holders WHERE id=? AND released=0 LIMIT 16");
|
||||
sqlite3_stmt* st = dm_media_prepare(inst, "SELECT node_id,assignment_id FROM dm_media_holders WHERE id=? AND released=0 LIMIT 16");
|
||||
if (!st) return;
|
||||
int rc = sqlite3_bind_blob(st, 1, id, 16, SQLITE_STATIC);
|
||||
if (rc != SQLITE_OK) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_mailbox_media: cleanup scan bind failed rc=%d; body retained", rc);
|
||||
sqlite3_finalize(st); return;
|
||||
}
|
||||
uint64_t peers[16], groups[16];
|
||||
uint64_t peers[16];
|
||||
uint8_t assignments[16][16];
|
||||
unsigned count = 0;
|
||||
while ((rc = sqlite3_step(st)) == SQLITE_ROW && count < 16) {
|
||||
peers[count] = (uint64_t)sqlite3_column_int64(st, 0); groups[count++] = (uint64_t)sqlite3_column_int64(st, 1);
|
||||
if (sqlite3_column_bytes(st, 1) != 16) { rc = SQLITE_CORRUPT; break; }
|
||||
peers[count] = (uint64_t)sqlite3_column_int64(st, 0); memcpy(assignments[count++], sqlite3_column_blob(st, 1), 16);
|
||||
}
|
||||
int final = sqlite3_finalize(st);
|
||||
if ((rc != SQLITE_ROW && rc != SQLITE_DONE) || final != SQLITE_OK) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_mailbox_media: cleanup scan failed rc=%d finalize=%d; body retained", rc, final); return;
|
||||
}
|
||||
for (unsigned i = 0; i < count; i++) dm_media_send(inst, groups[i], peers[i], DM_MEDIA_DELETE, receipt, DM_MEDIA_RECEIPT_SIZE);
|
||||
if (!count) {
|
||||
st = dm_media_prepare(inst, "SELECT received FROM dm_file_targets WHERE id=? AND received IS NOT NULL"
|
||||
" UNION ALL SELECT taken FROM dm_file_targets WHERE id=? AND taken IS NOT NULL");
|
||||
if (!st) return;
|
||||
sqlite3_bind_blob(st, 1, id, 16, SQLITE_STATIC); sqlite3_bind_blob(st, 2, id, 16, SQLITE_STATIC);
|
||||
uint8_t facts[2 * DM_TARGET_MAX][DM_MEDIA_RECEIPT_SIZE]; unsigned fact_count = 0;
|
||||
while ((rc = sqlite3_step(st)) == SQLITE_ROW && fact_count < 2 * DM_TARGET_MAX) {
|
||||
if (sqlite3_column_bytes(st, 0) != DM_MEDIA_RECEIPT_SIZE) { rc = SQLITE_CORRUPT; break; }
|
||||
memcpy(facts[fact_count++], sqlite3_column_blob(st, 0), DM_MEDIA_RECEIPT_SIZE);
|
||||
}
|
||||
sqlite3_finalize(st);
|
||||
if (rc != SQLITE_DONE) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM custody: corrupt/overflowing receipt set rc=%d", rc); return; }
|
||||
for (unsigned i = 0; i < count; i++) for (unsigned j = 0; j < fact_count; j++) {
|
||||
uint8_t request[16 + DM_MEDIA_RECEIPT_SIZE]; memcpy(request, assignments[i], 16); memcpy(request + 16, facts[j], sizeof(facts[j]));
|
||||
dm_media_send(inst, 0, peers[i], DM_MEDIA_DELETE, request, sizeof(request));
|
||||
}
|
||||
if (!count && terminal) {
|
||||
st = dm_media_prepare(inst, "UPDATE dm_media_jobs SET body=NULL WHERE id=? AND receipt IS NOT NULL AND body IS NOT NULL"
|
||||
" AND NOT EXISTS(SELECT 1 FROM dm_media_holders h WHERE h.id=dm_media_jobs.id AND h.released=0)");
|
||||
if (!st) return;
|
||||
@@ -256,6 +323,36 @@ static void dm_job_cleanup(struct dm_mb_media* media, const uint8_t id[16], cons
|
||||
}
|
||||
}
|
||||
|
||||
/* Не получившие файл устройства узнают об удалённой копии; отказ в резерве не означает удаления. */
|
||||
static void dm_job_source_notices(struct UTUN_INSTANCE* inst, const uint8_t id[16]) {
|
||||
sqlite3_stmt* st = dm_media_prepare(inst, "SELECT h.node_id,j.body_hash,t.node FROM dm_media_holders h"
|
||||
" JOIN dm_media_jobs j ON j.id=h.id JOIN dm_file_targets t ON t.id=h.id"
|
||||
" WHERE h.id=? AND h.released IN (1,2) AND h.node_id<>j.author AND t.received IS NULL");
|
||||
if (!st) return;
|
||||
struct source_notice { uint64_t peer; uint8_t body[56]; } *notices = NULL;
|
||||
size_t count = 0, capacity = 0;
|
||||
int rc = sqlite3_bind_blob(st, 1, id, 16, SQLITE_STATIC);
|
||||
if (rc == SQLITE_OK) while ((rc = sqlite3_step(st)) == SQLITE_ROW) {
|
||||
if (sqlite3_column_bytes(st, 1) != 32) { rc = SQLITE_CORRUPT; break; }
|
||||
if (count == capacity) {
|
||||
size_t next = capacity ? capacity * 2 : 8;
|
||||
struct source_notice* grown = u_realloc(notices, next * sizeof(*notices));
|
||||
if (!grown) { rc = SQLITE_NOMEM; break; }
|
||||
notices = grown; capacity = next;
|
||||
}
|
||||
uint64_t holder = (uint64_t)sqlite3_column_int64(st, 0);
|
||||
notices[count].peer = (uint64_t)sqlite3_column_int64(st, 2);
|
||||
memcpy(notices[count].body, id, 16); memcpy(notices[count].body + 16, sqlite3_column_blob(st, 1), 32);
|
||||
memcpy(notices[count++].body + 48, &holder, 8);
|
||||
}
|
||||
int final = sqlite3_finalize(st);
|
||||
if (rc != SQLITE_DONE || final != SQLITE_OK) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM sources: terminal assignment snapshot failed rc=%d final=%d", rc, final);
|
||||
} else for (size_t i = 0; i < count; i++)
|
||||
dm_media_send(inst, 0, notices[i].peer, DM_MEDIA_SOURCE_GONE, notices[i].body, sizeof(notices[i].body));
|
||||
u_free(notices);
|
||||
}
|
||||
|
||||
/* Snapshot statement закрывается до control send, включая локальное совмещение S/M. */
|
||||
static void dm_mailbox_media_tick(void* arg) {
|
||||
struct dm_mb_media* media = arg;
|
||||
@@ -263,8 +360,9 @@ static void dm_mailbox_media_tick(void* arg) {
|
||||
media->timer = NULL;
|
||||
uint64_t now = ntp_time_get_seconds(inst);
|
||||
for (int i = 0; i < 8; i++) {
|
||||
sqlite3_stmt* st = dm_media_prepare(inst, "SELECT id,author,recipient,body,group_id,holder,stored,expires,receipt,expired"
|
||||
" FROM dm_media_jobs WHERE retry_at<=? ORDER BY retry_at,seq LIMIT 1");
|
||||
sqlite3_stmt* st = dm_media_prepare(inst, "SELECT j.id,j.author,j.recipient,j.body,j.holder,j.stored,j.expires,j.receipt,"
|
||||
"j.expired,j.delete_on_open,h.assignment_id,h.released FROM dm_media_jobs j LEFT JOIN dm_media_holders h"
|
||||
" ON h.id=j.id AND h.node_id=j.holder WHERE j.retry_at<=? ORDER BY j.retry_at,j.seq LIMIT 1");
|
||||
if (!st) break;
|
||||
sqlite3_bind_int64(st, 1, (sqlite3_int64)now);
|
||||
int rc = sqlite3_step(st);
|
||||
@@ -277,24 +375,43 @@ static void dm_mailbox_media_tick(void* arg) {
|
||||
uint64_t author = (uint64_t)sqlite3_column_int64(st, 1), recipient = (uint64_t)sqlite3_column_int64(st, 2);
|
||||
size_t len = (size_t)sqlite3_column_bytes(st, 3);
|
||||
if (len <= sizeof(body) && len) memcpy(body, sqlite3_column_blob(st, 3), len);
|
||||
uint64_t group = (uint64_t)sqlite3_column_int64(st, 4), holder = (uint64_t)sqlite3_column_int64(st, 5);
|
||||
int stored = sqlite3_column_int(st, 6), expired = sqlite3_column_int(st, 9);
|
||||
uint64_t expires = (uint64_t)sqlite3_column_int64(st, 7);
|
||||
int delivered = sqlite3_column_bytes(st, 8) == sizeof(receipt);
|
||||
if (delivered) memcpy(receipt, sqlite3_column_blob(st, 8), sizeof(receipt));
|
||||
uint64_t holder = (uint64_t)sqlite3_column_int64(st, 4);
|
||||
uint8_t assignment[16];
|
||||
if (holder && sqlite3_column_bytes(st, 10) != 16) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM assignment: missing/corrupt ID M=%llu", (unsigned long long)holder); sqlite3_finalize(st); break;
|
||||
}
|
||||
if (holder) memcpy(assignment, sqlite3_column_blob(st, 10), 16);
|
||||
int released = sqlite3_column_int(st, 11);
|
||||
int stored = sqlite3_column_int(st, 5), expired = sqlite3_column_int(st, 8);
|
||||
int policy = sqlite3_column_int(st, 9);
|
||||
uint64_t expires = (uint64_t)sqlite3_column_int64(st, 6);
|
||||
int delivered = sqlite3_column_bytes(st, 7) == sizeof(receipt);
|
||||
if (delivered) memcpy(receipt, sqlite3_column_blob(st, 7), sizeof(receipt));
|
||||
sqlite3_finalize(st);
|
||||
st = dm_media_prepare(inst, "UPDATE dm_media_jobs SET retry_at=? WHERE id=?");
|
||||
if (!st) break;
|
||||
sqlite3_bind_int64(st, 1, (sqlite3_int64)(now + 5)); sqlite3_bind_blob(st, 2, id, 16, SQLITE_STATIC);
|
||||
if (dm_media_step(inst, st)) break;
|
||||
dm_job_source_notices(inst, id);
|
||||
if (!delivered && policy >= 0 && dm_media_retention_done(inst, id, policy) == 1) {
|
||||
st = dm_media_prepare(inst, policy ?
|
||||
"UPDATE dm_media_jobs SET receipt=(SELECT taken FROM dm_file_targets WHERE id=? AND side=1 AND taken IS NOT NULL LIMIT 1)"
|
||||
",retry_at=0 WHERE id=?" :
|
||||
"UPDATE dm_media_jobs SET receipt=(SELECT received FROM dm_file_targets WHERE id=? AND side=1 AND received IS NOT NULL LIMIT 1)"
|
||||
",retry_at=0 WHERE id=?");
|
||||
if (!st) break;
|
||||
sqlite3_bind_blob(st, 1, id, 16, SQLITE_STATIC); sqlite3_bind_blob(st, 2, id, 16, SQLITE_STATIC);
|
||||
if (dm_media_step(inst, st)) break;
|
||||
DEBUG_INFO(DEBUG_CATEGORY_DM, "DM custody: retention complete policy=%d", policy); continue;
|
||||
}
|
||||
if (holder) dm_job_cleanup(media, id, delivered);
|
||||
if (delivered) {
|
||||
dm_job_cleanup(media, id, receipt);
|
||||
uint64_t gid = group;
|
||||
if (dm_route_group(inst, author, group, &gid)) dm_media_send(inst, gid, author, DM_MEDIA_RECEIPT, receipt, sizeof(receipt));
|
||||
if (!holder) dm_job_cleanup(media, id, 1);
|
||||
dm_media_send(inst, 0, author, DM_MEDIA_RECEIPT, receipt, sizeof(receipt));
|
||||
continue;
|
||||
}
|
||||
if (holder && stored && expires <= now) {
|
||||
if (!dm_job_release(media, id, holder, 1))
|
||||
if (holder && expires && expires <= now) {
|
||||
if (!dm_job_release(media, id, holder, assignment, 1))
|
||||
DEBUG_INFO(DEBUG_CATEGORY_DM, "dm_mailbox_media: custody deadline reconciled M=%llu expires=%llu now=%llu",
|
||||
(unsigned long long)holder, (unsigned long long)expires, (unsigned long long)now);
|
||||
continue; /* Новый снимок после commit; при ошибке задача повторится через retry_at. */
|
||||
@@ -302,36 +419,58 @@ static void dm_mailbox_media_tick(void* arg) {
|
||||
if (!len || len > sizeof(body) || !dm_message_media(body, len)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_mailbox_media: corrupt saved descriptor"); continue;
|
||||
}
|
||||
if (!dm_media_scope(inst, group, inst->node_id, "supernode", author, recipient)) continue;
|
||||
uint64_t gid = group;
|
||||
if (dm_route_group(inst, recipient, group, &gid)) {
|
||||
st = dm_media_prepare(inst, "SELECT node FROM dm_file_targets WHERE id=? AND received IS NULL");
|
||||
if (!st) break;
|
||||
sqlite3_bind_blob(st, 1, id, 16, SQLITE_STATIC);
|
||||
uint64_t targets[DM_TARGET_MAX]; unsigned n = 0;
|
||||
while ((rc = sqlite3_step(st)) == SQLITE_ROW && n < DM_TARGET_MAX) targets[n++] = (uint64_t)sqlite3_column_int64(st, 0);
|
||||
sqlite3_finalize(st);
|
||||
if (rc != SQLITE_DONE) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM custody: targets scan failed rc=%d", rc); continue; }
|
||||
for (unsigned t = 0; t < n; t++) {
|
||||
uint64_t gid = 0;
|
||||
if (!dm_route_group(inst, targets[t], 0, &gid)) continue;
|
||||
uint8_t offer[24 + DM_MEDIA_BODY_MAX];
|
||||
uint64_t location = stored && expires > now ? holder : author;
|
||||
uint64_t expiry = stored ? expires : 0;
|
||||
memcpy(offer, &recipient, 8); memcpy(offer + 8, &location, 8); memcpy(offer + 16, &expiry, 8);
|
||||
uint64_t location = stored && !released && expires > now ? holder : author;
|
||||
uint64_t expiry = stored && !released ? expires : 0;
|
||||
memcpy(offer, targets + t, 8); memcpy(offer + 8, &location, 8); memcpy(offer + 16, &expiry, 8);
|
||||
memcpy(offer + 24, body, len);
|
||||
dm_media_send(inst, gid, recipient, DM_MEDIA_OFFER, offer, len + 24);
|
||||
if (expired || (stored && expires <= now)) dm_media_send(inst, gid, recipient, DM_MEDIA_EXPIRED, id, 16);
|
||||
} else {
|
||||
dm_media_send(inst, gid, targets[t], DM_MEDIA_OFFER, offer, len + 24);
|
||||
}
|
||||
if (!expired) {
|
||||
if (!holder) {
|
||||
holder = dm_job_storage(media, group, id, author, recipient);
|
||||
if (!dm_peer_role(inst, inst->node_id, "supernode")) continue;
|
||||
holder = dm_job_storage(media, id);
|
||||
if (!holder) continue;
|
||||
if (RAND_bytes(assignment, sizeof(assignment)) != 1) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM assignment: random ID generation failed"); continue;
|
||||
}
|
||||
if (dm_media_exec(inst, "BEGIN IMMEDIATE")) continue;
|
||||
st = dm_media_prepare(inst, "INSERT INTO dm_media_holders(id,node_id,group_id) VALUES(?,?,?)");
|
||||
st = dm_media_prepare(inst, "INSERT INTO dm_media_holders(id,node_id,assignment_id) VALUES(?,?,?)"
|
||||
" ON CONFLICT(id,node_id) DO UPDATE SET assignment_id=excluded.assignment_id,released=0 WHERE released=3");
|
||||
if (!st) { dm_media_exec(inst, "ROLLBACK"); continue; }
|
||||
sqlite3_bind_blob(st, 1, id, 16, SQLITE_STATIC); sqlite3_bind_int64(st, 2, (sqlite3_int64)holder);
|
||||
sqlite3_bind_int64(st, 3, (sqlite3_int64)group);
|
||||
rc = sqlite3_bind_blob(st, 1, id, 16, SQLITE_STATIC);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 2, (sqlite3_int64)holder);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 3, assignment, 16, SQLITE_STATIC);
|
||||
if (rc != SQLITE_OK) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM assignment: bind failed rc=%d", rc);
|
||||
sqlite3_finalize(st); dm_media_exec(inst, "ROLLBACK"); continue;
|
||||
}
|
||||
if (dm_media_step(inst, st)) { dm_media_exec(inst, "ROLLBACK"); continue; }
|
||||
if (sqlite3_changes(inst->topo_sqlite_db) != 1) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM assignment: cannot replace unresolved assignment M=%llu", (unsigned long long)holder);
|
||||
dm_media_exec(inst, "ROLLBACK"); continue;
|
||||
}
|
||||
st = dm_media_prepare(inst, "UPDATE dm_media_jobs SET holder=?,stored=0 WHERE id=? AND receipt IS NULL");
|
||||
if (!st) { dm_media_exec(inst, "ROLLBACK"); continue; }
|
||||
sqlite3_bind_int64(st, 1, (sqlite3_int64)holder); sqlite3_bind_blob(st, 2, id, 16, SQLITE_STATIC);
|
||||
if (dm_media_step(inst, st) || dm_media_exec(inst, "COMMIT")) { dm_media_exec(inst, "ROLLBACK"); continue; }
|
||||
DEBUG_INFO(DEBUG_CATEGORY_DM, "dm_mailbox_media: assigned storage M=%llu", (unsigned long long)holder);
|
||||
}
|
||||
if (!stored) {
|
||||
uint8_t put[8 + DM_MEDIA_BODY_MAX]; memcpy(put, &recipient, 8); memcpy(put + 8, body, len);
|
||||
dm_media_send(inst, group, holder, DM_MEDIA_STORE, put, len + 8);
|
||||
uint64_t token[2], file[2]; memcpy(token, assignment, 16); memcpy(file, id, 16);
|
||||
DEBUG_INFO(DEBUG_CATEGORY_DM, "DM assignment: committed assignment=%016llx%016llx file=%016llx%016llx M=%llu",
|
||||
(unsigned long long)token[0], (unsigned long long)token[1], (unsigned long long)file[0],
|
||||
(unsigned long long)file[1], (unsigned long long)holder);
|
||||
}
|
||||
uint8_t put[24 + DM_MEDIA_BODY_MAX]; memcpy(put, assignment, 16); memcpy(put + 16, &recipient, 8); memcpy(put + 24, body, len);
|
||||
dm_media_send(inst, 0, holder, DM_MEDIA_STORE, put, len + 24);
|
||||
}
|
||||
}
|
||||
media->timer = uasync_set_timeout(inst->ua, 10000, media, dm_mailbox_media_tick, "dm_mailbox_media");
|
||||
@@ -339,13 +478,8 @@ static void dm_mailbox_media_tick(void* arg) {
|
||||
}
|
||||
|
||||
struct dm_mb_media* dm_mailbox_media_create(struct UTUN_INSTANCE* inst) {
|
||||
if (dm_media_exec(inst, "CREATE TABLE IF NOT EXISTS dm_media_jobs(id BLOB PRIMARY KEY,author INTEGER NOT NULL,"
|
||||
"recipient INTEGER NOT NULL,seq INTEGER NOT NULL,body BLOB,body_hash BLOB NOT NULL,group_id INTEGER NOT NULL,"
|
||||
"holder INTEGER NOT NULL DEFAULT 0,stored INTEGER NOT NULL DEFAULT 0,expires INTEGER NOT NULL DEFAULT 0,"
|
||||
"expired INTEGER NOT NULL DEFAULT 0,receipt BLOB,retry_at INTEGER NOT NULL DEFAULT 0)") ||
|
||||
dm_media_exec(inst, "CREATE TABLE IF NOT EXISTS dm_media_holders(id BLOB NOT NULL,node_id INTEGER NOT NULL,"
|
||||
"group_id INTEGER NOT NULL,released INTEGER NOT NULL DEFAULT 0,PRIMARY KEY(id,node_id))") ||
|
||||
dm_media_exec(inst, "UPDATE dm_media_jobs SET retry_at=0")) return NULL;
|
||||
if (dm_media_schema_init(inst)) return NULL;
|
||||
if (dm_media_exec(inst, "UPDATE dm_media_jobs SET retry_at=0")) return NULL;
|
||||
struct dm_mb_media* media = u_calloc(1, sizeof(*media));
|
||||
if (!media) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_mailbox_media: owner allocation failed"); return NULL; }
|
||||
media->inst = inst;
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
* Жизненный цикл принадлежит dm_mailbox, control принимает dm_media. Все API в uasync.
|
||||
* Координатор хранится в dm_media_jobs/dm_media_holders SQLite ядра; таймер — только триггер повторов.
|
||||
* Таймер 1с обрабатывает до 8 задач, retry_at через 5с; выбранный M сохраняется до STORE.
|
||||
* Выбор M: storage=yes в общей группе A/B, достижимый узел по node_id, кроме A/B и прежних назначений.
|
||||
* Выбор M: storage=yes в допущенной общей группе S/M, маршрут до M выбирается независимо.
|
||||
* assignment_id сохраняется до STORE и не меняется при смене маршрута/рестарте.
|
||||
* released: 0=активно, 1=подтверждено удаление, 2=истёк срок, 3=окончательный отказ по квоте.
|
||||
* После отказа повтор через 60с: сначала новый M, затем тот же M с новым assignment_id.
|
||||
* Заменяется только разрешённый отказ; копия с истёкшим сроком повторно не назначается.
|
||||
* S может одновременно быть M: local control проходит тот же обработчик без router-соединения к себе.
|
||||
* SQLite и группы принадлежат ядру; instance должен пережить owner. Входные тела заимствованы до возврата.
|
||||
* Из C++ этот внутренний C API включать внутри extern "C".
|
||||
@@ -27,14 +31,14 @@ void dm_mailbox_media_destroy(struct dm_mb_media* media);
|
||||
int dm_mailbox_media_register(struct dm_mb_media* media, uint64_t group, uint64_t recipient,
|
||||
const uint8_t* msg, size_t len);
|
||||
/* После проверки router-подписи и размера команды в dm_media: group/source — доверенные поля router.
|
||||
* body заимствован на время вызова; RECEIPT=152, STORED=24, DELETE_ACK/EXPIRED=16, REJECT=17 байт.
|
||||
* RECEIPT имеет формат dm_media.h; STORED=[UUID:16][expires:8], ACK/EXPIRED=[UUID:16], REJECT=[UUID:16][reason:1].
|
||||
* body заимствован на время вызова; RECEIPT=152, STORED/RESERVED=41, DELETE_ACK/EXPIRED=32, REJECT=33 байта.
|
||||
* Форматы — dm_media.h; каждый ответ M содержит сохранённые assignment_id и UUID.
|
||||
* STORED/REJECT/ACK относятся к сохранённому назначению M; RECEIPT проверяется отдельно подписью B.
|
||||
* EXPIRED/REJECT атомарно освобождают назначение и сбрасывают текущий holder; ошибка откатывает оба изменения.
|
||||
* Таймер завершает stored-назначение по expires даже при offline B/потерянном EXPIRED, также после restart.
|
||||
* После квитанции повторяется DELETE до 16 неосвобождённым M за проход cleanup.
|
||||
* Тело удаляется только при отсутствии неосвобождённых назначений; ошибка сканирования откладывает очистку.
|
||||
* Для неподтверждённого STORE повтор запрашивает результат из custody/tombstone M; квитанция B не отменяется. */
|
||||
* STORE повторно запрашивает результат из custody/tombstone M, также после STORED; квитанция B не отменяется. */
|
||||
void dm_mailbox_media_recv(struct dm_mb_media* media, uint64_t group, uint64_t source,
|
||||
uint8_t cmd, const uint8_t* body, size_t len);
|
||||
/* Проверить 152-байтовую квитанцию и попытаться отправить доступным S общей группы.
|
||||
|
||||
@@ -20,15 +20,26 @@ struct UTUN_INSTANCE;
|
||||
|
||||
#define DM_MEDIA_DESC_SIZE 56 /* UUID:16, plaintext_size:8 native-endian, SHA256(ciphertext):32. */
|
||||
#define DM_MEDIA_RECEIPT_SIZE 152
|
||||
#define DM_MEDIA_ASSIGNMENT_SIZE 16 /* Постоянный ID запроса S->M; повтор/смена маршрута сохраняют его. */
|
||||
#define DM_MEDIA_MAX_SIZE (1024ULL * 1024 * 1024)
|
||||
#define DM_MEDIA_KEYS_HEADER_SIZE 232 /* "DMKEY001" и DM_FILE_KEYS_SIZE байт. */
|
||||
|
||||
/* Расшифрованная metadata: magic + две упаковки ключа + attachment_info.
|
||||
* keys получает заимствованный блок ключей либо NULL для сохранённого DEVICE-файла
|
||||
* прежнего формата. 0/-1; info/keys обязательны, живут вместе с data. */
|
||||
int dm_media_metadata_decode(const uint8_t* data, size_t size, struct attachment_info* info, const uint8_t** keys);
|
||||
|
||||
/* ETCP_RT_ID_DM_MEDIA=0x38: [cmd:1][body], router-подпись обязательна; числа native-endian.
|
||||
* OFFER: [recipient:8][holder:8][expires:8][каноническое file-сообщение]. A предлагает себя с expires=0.
|
||||
* STORE: [recipient:8][file-сообщение] от S к M; STORED: [UUID:16][expires:8].
|
||||
* RECEIPT/DELETE: ["DMFILE01":8][conv:8][seq:8][author:8][recipient:8][UUID:16][SHA256(message):32][sig_B:64].
|
||||
* B подписывает первые 88 байт; DELETE переносит ту же квитанцию, не создаёт новую.
|
||||
* DELETE_ACK/EXPIRED: [UUID:16]; REJECT: [UUID:16][reason:1], reason=1 — недостаток квоты.
|
||||
* EXPIRED завершает промежуточное хранение, а не подтверждает доставку B. */
|
||||
* STORE: [assignment:16][recipient:8][file-сообщение] от S к M.
|
||||
* STORED/RESERVED: [assignment:16][UUID:16][expires:8][delete_on_open:1].
|
||||
* RECEIPT: [domain:8][conv:8][seq:8][author:8][device:8][UUID:16][SHA256(message):32][sig:64].
|
||||
* DELETE: [assignment:16][RECEIPT:152]; назначение не заменяет подпись факта получателя.
|
||||
* FILE_RECEIVED: DMFILE01/DMFILEG1; TAKEN: DMTAKE01/DMTAKEG1 для DEVICE/GROUPS.
|
||||
* Устройство подписывает первые88 байт; DELETE переносит проверенный факт, M применяет свою сохранённую политику.
|
||||
* DELETE_ACK/EXPIRED: [assignment:16][UUID:16]; REJECT: [assignment:16][UUID:16][reason:1], reason=1 — квота.
|
||||
* SOURCE_GONE: [UUID:16][SHA256(message):32][holder:8] от S; прекращает использование только этого источника.
|
||||
* EXPIRED относится только к назначению S/M. Получателю не объявляется удаление личного файла. */
|
||||
#define DM_MEDIA_OFFER 1
|
||||
#define DM_MEDIA_STORE 2
|
||||
#define DM_MEDIA_STORED 3
|
||||
@@ -37,6 +48,10 @@ struct UTUN_INSTANCE;
|
||||
#define DM_MEDIA_DELETE_ACK 6
|
||||
#define DM_MEDIA_EXPIRED 7
|
||||
#define DM_MEDIA_REJECT 8
|
||||
#define DM_MEDIA_RESERVED 9 /* assignment:16, UUID:16, expires:8, delete_on_open:1; ciphertext ещё не подтверждён. */
|
||||
#define DM_MEDIA_HAVE 10 /* UUID:16, SHA256:32, probe_id:8, cipher_size:8. */
|
||||
#define DM_MEDIA_HAVE_ACK 11 /* Точное эхо HAVE; RTT измеряет запросившее устройство. */
|
||||
#define DM_MEDIA_SOURCE_GONE 12
|
||||
|
||||
/* После dm_core_init с SQLite/config/router/ua и media_async ядра: таблицы, каталоги, file_transfer, bind и таймер.
|
||||
* 0=успех, -1=ошибка, в том числе повторный init живого owner. При отказе созданные таблицы/каталоги остаются.
|
||||
@@ -49,6 +64,9 @@ void dm_media_quiesce(struct UTUN_INSTANCE* inst);
|
||||
/* После drain/destroy media_async, до групп/core/mailbox: освободить owner, сохранить durable данные.
|
||||
* При живых jobs/cleanup_active логирует ошибку и оставляет owner для повторного destroy. NULL допустим. */
|
||||
void dm_media_destroy(struct UTUN_INSTANCE* inst);
|
||||
/* После изменения допуска группы отменить её ожидающие HAVE-пробы; поздний ответ их не оживляет.
|
||||
* Файлы/назначения остаются; следующий поиск выберет свежий маршрут. FILE_TRANSFER отменяется своим owner. */
|
||||
void dm_media_revalidate_members(struct UTUN_INSTANCE* inst, uint64_t group);
|
||||
|
||||
/* Передать подготовку копии и enqueue file-сообщения в media_async.
|
||||
* Строки conv/path и info копируются при вызове; сам файл должен быть доступен worker до завершения.
|
||||
@@ -73,6 +91,20 @@ struct dm_file_req {
|
||||
* Проверяет generation до submit; непринятый remove_source удаляет через worker. */
|
||||
void dm_send_file_trampoline(void* arg);
|
||||
|
||||
/* Действие пользователя над сохранённым file-сообщением (conv,author,seq).
|
||||
* opened=0 разрешает ручную загрузку; opened=1 фиксирует успешное открытие уже
|
||||
* готового файла. Вызывать opened только после реального действия UI, не из preview.
|
||||
* После выхода готовый файл открывается локально без TAKEN. 0/-1; только uasync. */
|
||||
int dm_media_user_action(struct UTUN_INSTANCE* inst, const char* conv, uint64_t author, uint64_t seq, int opened);
|
||||
struct dm_media_action_req {
|
||||
struct UTUN_INSTANCE* inst; /* Ядро живо до выполнения uasync callback. */
|
||||
char conv[64]; /* Десятичный ID беседы. */
|
||||
uint64_t author, seq, generation; /* Точная запись; generation защищает удаление истории. */
|
||||
int opened; /* 0=скачать,1=пользователь успешно открыл. */
|
||||
};
|
||||
/* Один u_calloc-блок; проверяет generation и освобождает запрос. */
|
||||
void dm_media_action_trampoline(void* arg);
|
||||
|
||||
/* Внутри транзакции dm_core после проверки канонического msg/len; dir: 0=входящее, 1=исходящее.
|
||||
* 0=записано/точный повтор UUID и тела, -1=ошибка/конфликт; rollback делает вызывающий.
|
||||
* Для не-file — пустая операция. Исходящий file требует уже подготовленного .file нужного размера. */
|
||||
@@ -97,6 +129,12 @@ int dm_media_forget_prepare(struct UTUN_INSTANCE* inst, const char* conv, uint64
|
||||
/* Только после commit: отменить transfer удалённых UUID и начать асинхронную уборку старых поколений.
|
||||
* Callback отмены transfer синхронен; ожидания workers здесь нет, busy защищает их пути. */
|
||||
void dm_media_forget_committed(struct UTUN_INSTANCE* inst, const char* conv);
|
||||
/* В транзакции выхода пометить незавершённые файлы closed-бесед для durable уборки.
|
||||
* Готовые plaintext/ciphertext остаются. 0/-1, без media — пустая операция. */
|
||||
int dm_media_close_prepare(struct UTUN_INSTANCE* inst);
|
||||
/* После commit выхода отменить передачи closed-бесед. Workers завершатся с отказом
|
||||
* публикации в историю; уборка ждёт их holds. Готовые локальные вложения сохраняются. */
|
||||
void dm_media_close_committed(struct UTUN_INSTANCE* inst);
|
||||
/* Зарегистрировать принадлежащий приложению path и увеличить busy до запуска worker/transfer.
|
||||
* conv/generation должны быть текущими и видимыми; 0=захват, -1=ошибка/чужое поколение пути. */
|
||||
int dm_media_file_hold(struct UTUN_INSTANCE* inst, const char* conv, uint64_t generation, const char* path);
|
||||
@@ -104,16 +142,13 @@ int dm_media_file_hold(struct UTUN_INSTANCE* inst, const char* conv, uint64_t ge
|
||||
* Уборка старого поколения ждёт освобождения всех holds; busy=-1 означает захват уборщиком. */
|
||||
void dm_media_file_release(struct UTUN_INSTANCE* inst, const char* path);
|
||||
|
||||
/* Отправить cmd/body/len через подписанный router в group к peer; нужен живой dm_media.
|
||||
/* Отправить cmd/body/len через подписанный router к peer; group — только предпочтительный маршрут (0=любой).
|
||||
* Выбирается текущая допущенная общая группа self/peer независимо от хранения и объявления роли.
|
||||
* 0=пакет принят транспортом, ненулевое значение=ошибка/backpressure. Durable очередь не создаёт.
|
||||
* Локальный peer обрабатывается синхронно: 0 означает вызов обработчика, не его durable успех.
|
||||
* body заимствован до возврата; вызывающий соблюдает формат команды, size<=DM_MEDIA_BODY_MAX+24. */
|
||||
int dm_media_send(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer,
|
||||
uint8_t cmd, const uint8_t* body, size_t len);
|
||||
/* 1, если node/author/recipient — живые мемберы group и node имеет role=yes в adm_tags.
|
||||
* role=NULL проверяет только членство; 0 при отсутствии полномочий/ошибке SQL. Не проверяет сетевой путь. */
|
||||
int dm_media_scope(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t node,
|
||||
const char* role, uint64_t author, uint64_t recipient);
|
||||
/* Опубликовать CHAT_EVT_DM_MSG_RECEIVED [conv_len:1][conv:decimal][local_node:8] для обновления снимка UI. */
|
||||
void dm_media_notify(struct UTUN_INSTANCE* inst, uint64_t conv);
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
#include <sqlite3.h>
|
||||
|
||||
/* Верхняя граница канонического тела с ct<=255, CCM tag, descriptor и подписью; без cmd/router-заголовка. */
|
||||
#define DM_MEDIA_BODY_MAX (DM_MSG_FIXED_HDR + 255 + 2 + DM_DATA_MAX + 16 + DM_MEDIA_DESC_SIZE + DM_MSG_SIG_SIZE)
|
||||
#define DM_MEDIA_BODY_MAX DM_BODY_MAX
|
||||
|
||||
/* SQLite ядра, только uasync: prepare возвращает statement либо NULL с логом ошибки.
|
||||
* inst/db/sql обязательны; вызывающий проверяет bind/step для чтения и всегда освобождает statement.
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
#include "dm_media_receipt.h"
|
||||
#include "dm_media_priv.h"
|
||||
#include "dm_crypto.h"
|
||||
#include "../utun_instance.h"
|
||||
#include "../transport_layer/secure_channel.h"
|
||||
#include "../../lib/debug_config.h"
|
||||
#include <string.h>
|
||||
#include <openssl/sha.h>
|
||||
|
||||
static const char* receipt_domains[2][2] = {{"DMFILE01", "DMFILEG1"}, {"DMTAKE01", "DMTAKEG1"}};
|
||||
|
||||
int dm_media_receipt_kind(const uint8_t* r) {
|
||||
if (r) for (int kind = 0; kind < 2; kind++) for (int group = 0; group < 2; group++)
|
||||
if (!memcmp(r, receipt_domains[kind][group], 8)) return kind;
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Полное тело даёт удостоверенный ключ устройства; отдельный receipt — ключ из nodes. */
|
||||
int dm_media_verify_receipt(struct UTUN_INSTANCE* inst, const uint8_t* r, const uint8_t* body, size_t len) {
|
||||
int kind = dm_media_receipt_kind(r);
|
||||
if (!inst || kind < 0) goto invalid;
|
||||
int groups = !memcmp(r, receipt_domains[kind][1], 8);
|
||||
uint64_t conv, author, node, seq;
|
||||
memcpy(&conv, r + 8, 8); memcpy(&seq, r + 16, 8); memcpy(&author, r + 24, 8); memcpy(&node, r + 32, 8);
|
||||
if (!author || !node || node == author || !seq || seq > INT64_MAX || !conv || conv > INT64_MAX ||
|
||||
(!groups && conv != dm_derive_conv_id(author, node))) goto invalid;
|
||||
uint8_t ed[32];
|
||||
if (body) {
|
||||
struct dm_message_view view; uint8_t hash[32];
|
||||
if (dm_message_parse(body, len, &view) || view.size != len || !view.media || groups != (view.mode == DM_MODE_GROUPS) ||
|
||||
memcmp(r + 8, body, 16) || memcmp(r + 24, body + 24, 8) || memcmp(r + 40, view.media, 16)) goto invalid;
|
||||
SHA256(body, len, hash); if (memcmp(r + 56, hash, 32)) goto invalid;
|
||||
if (groups) {
|
||||
if (dm_message_verify_targets(&view)) goto invalid;
|
||||
int side = dm_message_target(&view, node, NULL, ed);
|
||||
if (side < 0 || (kind && side != 1)) goto invalid;
|
||||
}
|
||||
}
|
||||
if (!body || !groups) {
|
||||
sqlite3_stmt* st = dm_media_prepare(inst, "SELECT ed25519_pubkey FROM nodes WHERE node_id=?");
|
||||
if (!st) return -1;
|
||||
sqlite3_bind_int64(st, 1, (sqlite3_int64)node);
|
||||
int rc = sqlite3_step(st), found = rc == SQLITE_ROW && sqlite3_column_bytes(st, 0) == 32;
|
||||
if (found) memcpy(ed, sqlite3_column_blob(st, 0), 32);
|
||||
sqlite3_finalize(st); if (!found) goto invalid;
|
||||
}
|
||||
if (sc_ed25519_verify(ed, r, 88, r + 88) != SC_OK) goto invalid;
|
||||
return 0;
|
||||
invalid:
|
||||
DEBUG_WARN(DEBUG_CATEGORY_DM, "DM file receipt: invalid identity/signature/context kind=%d body_bytes=%zu", kind, len);
|
||||
return -1;
|
||||
}
|
||||
|
||||
int dm_media_receipt_make(struct UTUN_INSTANCE* inst, const uint8_t* body, size_t len, int taken, uint8_t* out) {
|
||||
struct dm_message_view view;
|
||||
if (!out || taken < 0 || taken > 1 || dm_verify_message(inst, inst->node_id, body, len) ||
|
||||
dm_message_parse(body, len, &view) || !view.media) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM file receipt: invalid local fact"); return -1;
|
||||
}
|
||||
if (taken && view.mode == DM_MODE_GROUPS && dm_message_target(&view, inst->node_id, NULL, NULL) != 1) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_DM, "DM file receipt: sender-side opening is local only"); return -1;
|
||||
}
|
||||
uint8_t r[DM_MEDIA_RECEIPT_SIZE];
|
||||
memcpy(r, receipt_domains[taken][view.mode == DM_MODE_GROUPS], 8); memcpy(r + 8, body, 16);
|
||||
memcpy(r + 24, body + 24, 8); memcpy(r + 32, &inst->node_id, 8); memcpy(r + 40, view.media, 16);
|
||||
SHA256(body, len, r + 56);
|
||||
if (sc_ed25519_sign(inst->my_ed25519_privkey, r, 88, r + 88) != SC_OK) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM file receipt: signing failed kind=%d", taken); return -1;
|
||||
}
|
||||
memcpy(out, r, sizeof(r)); return 0;
|
||||
}
|
||||
|
||||
int dm_media_targets_seed(struct UTUN_INSTANCE* inst, const uint8_t* body, size_t len, uint64_t recipient) {
|
||||
struct dm_message_view view;
|
||||
if (dm_message_parse(body, len, &view) || view.size != len || !view.media || !recipient) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM file targets: invalid body"); return -1;
|
||||
}
|
||||
unsigned count = view.mode == DM_MODE_GROUPS ? view.target_count : 1;
|
||||
for (unsigned i = 0; i < count; i++) {
|
||||
struct dm_target t = {.node = recipient, .side = 1};
|
||||
if (view.mode == DM_MODE_GROUPS && dm_message_target_at(&view, i, &t)) return -1;
|
||||
if (t.node == view.author) continue;
|
||||
sqlite3_stmt* st = dm_media_prepare(inst,
|
||||
"INSERT INTO dm_file_targets(id,node,side) VALUES(?,?,?) ON CONFLICT(id,node) DO NOTHING");
|
||||
if (!st) return -1;
|
||||
sqlite3_bind_blob(st, 1, view.media, 16, SQLITE_STATIC); sqlite3_bind_int64(st, 2, (sqlite3_int64)t.node);
|
||||
sqlite3_bind_int(st, 3, t.side); if (dm_media_step(inst, st)) return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int dm_media_receipt_save(struct UTUN_INSTANCE* inst, const uint8_t* r, const uint8_t* body, size_t len) {
|
||||
if (dm_media_verify_receipt(inst, r, body, len)) return -1;
|
||||
int taken = dm_media_receipt_kind(r); uint64_t node; memcpy(&node, r + 32, 8);
|
||||
sqlite3_stmt* st = dm_media_prepare(inst, taken ?
|
||||
"UPDATE dm_file_targets SET taken=? WHERE id=? AND node=? AND side=1" :
|
||||
"UPDATE dm_file_targets SET received=? WHERE id=? AND node=?");
|
||||
if (!st) return -1;
|
||||
sqlite3_bind_blob(st, 1, r, DM_MEDIA_RECEIPT_SIZE, SQLITE_STATIC); sqlite3_bind_blob(st, 2, r + 40, 16, SQLITE_STATIC);
|
||||
sqlite3_bind_int64(st, 3, (sqlite3_int64)node);
|
||||
if (dm_media_step(inst, st)) return -1;
|
||||
if (sqlite3_changes(inst->topo_sqlite_db) != 1) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_DM, "DM file receipt: device outside delivery snapshot node=%llu", (unsigned long long)node); return -1;
|
||||
}
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_DM, "DM file receipt: saved node=%llu fact=%s", (unsigned long long)node, taken ? "opened" : "downloaded");
|
||||
return 0;
|
||||
}
|
||||
|
||||
int dm_media_retention_done(struct UTUN_INSTANCE* inst, const uint8_t id[16], int policy) {
|
||||
sqlite3_stmt* st = dm_media_prepare(inst,
|
||||
"SELECT count(*),sum(received IS NULL),sum(taken IS NOT NULL) FROM dm_file_targets WHERE id=? AND side=1");
|
||||
if (!st) return -1;
|
||||
sqlite3_bind_blob(st, 1, id, 16, SQLITE_STATIC);
|
||||
int rc = sqlite3_step(st), result = -1;
|
||||
if (rc == SQLITE_ROW && sqlite3_column_int(st, 0) > 0 && (policy == 0 || policy == 1))
|
||||
result = policy ? sqlite3_column_int(st, 2) > 0 : sqlite3_column_int(st, 1) == 0;
|
||||
int final = sqlite3_finalize(st);
|
||||
if (final != SQLITE_OK) result = -1;
|
||||
if (result < 0) DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM retention: missing snapshot/invalid policy/SQL rc=%d policy=%d", rc, policy);
|
||||
return result;
|
||||
}
|
||||
|
||||
int dm_media_targets_restore(struct UTUN_INSTANCE* inst) {
|
||||
if (dm_media_exec(inst, "BEGIN IMMEDIATE")) return -1;
|
||||
sqlite3_stmt* st = dm_media_prepare(inst, "SELECT body,recipient,receipt FROM dm_files UNION ALL"
|
||||
" SELECT body,recipient,receipt FROM dm_custody UNION ALL SELECT body,recipient,receipt FROM dm_media_jobs WHERE body IS NOT NULL");
|
||||
if (!st) { dm_media_exec(inst, "ROLLBACK"); return -1; }
|
||||
int rc, failed = 0;
|
||||
while ((rc = sqlite3_step(st)) == SQLITE_ROW) {
|
||||
const uint8_t* body = sqlite3_column_blob(st, 0); size_t len = (size_t)sqlite3_column_bytes(st, 0);
|
||||
if (dm_media_targets_seed(inst, body, len, (uint64_t)sqlite3_column_int64(st, 1))) { failed = 1; break; }
|
||||
if (sqlite3_column_bytes(st, 2) == DM_MEDIA_RECEIPT_SIZE &&
|
||||
dm_media_receipt_save(inst, sqlite3_column_blob(st, 2), body, len)) { failed = 1; break; }
|
||||
}
|
||||
int final = sqlite3_finalize(st);
|
||||
if (failed || rc != SQLITE_DONE || final != SQLITE_OK || dm_media_exec(inst, "COMMIT")) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM file targets: restore failed rc=%d final=%d", rc, final);
|
||||
dm_media_exec(inst, "ROLLBACK"); return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/* Подписанные факты FILE_RECEIVED/TAKEN и неизменный список устройств вложения.
|
||||
* Общие правила endpoint/S/M, только uasync и SQLite ядра. Файлы/сеть не меняет. */
|
||||
#ifndef UTUN_DM_MEDIA_RECEIPT_H
|
||||
#define UTUN_DM_MEDIA_RECEIPT_H
|
||||
#include "dm_media.h"
|
||||
|
||||
/* 0=FILE_RECEIVED, 1=TAKEN, -1=неизвестный домен; r содержит152 доступных байта. */
|
||||
int dm_media_receipt_kind(const uint8_t* r);
|
||||
/* Подписать факт локальным устройством для точного file-тела. Автор себе receipt
|
||||
* не выдаёт; TAKEN разрешён только стороне получателя. 0/-1, out152 байта. */
|
||||
int dm_media_receipt_make(struct UTUN_INSTANCE* inst, const uint8_t* body, size_t len, int taken, uint8_t* out);
|
||||
/* Внести неизменный snapshot без автора, который уже имеет оригинал. DEVICE
|
||||
* использует recipient, GROUPS — подписанный список. 0/-1; чужую транзакцию не закрывает. */
|
||||
int dm_media_targets_seed(struct UTUN_INSTANCE* inst, const uint8_t* body, size_t len, uint64_t recipient);
|
||||
/* Проверить точный body/подпись/сторону, записать факт в существующий snapshot.
|
||||
* 0/-1; caller сначала seed, а создание артефакта+seed связывает транзакцией. */
|
||||
int dm_media_receipt_save(struct UTUN_INSTANCE* inst, const uint8_t* r, const uint8_t* body, size_t len);
|
||||
/* 1=политика выполнена, 0=ещё нет, -1=SQL/пустой snapshot. policy=0 ждёт все
|
||||
* FILE_RECEIVED стороны1; policy=1 ждёт хотя бы один TAKEN стороны1. */
|
||||
int dm_media_retention_done(struct UTUN_INSTANCE* inst, const uint8_t id[16], int policy);
|
||||
/* При старте восстановить snapshots/FILE_RECEIVED сохранённых DEVICE-вложений.
|
||||
* Одна транзакция, точные тела/квитанции не меняет. 0/-1. */
|
||||
int dm_media_targets_restore(struct UTUN_INSTANCE* inst);
|
||||
#endif
|
||||
@@ -0,0 +1,155 @@
|
||||
#include "dm_protocol.h"
|
||||
#include "../utun_instance.h"
|
||||
#include "../routing_layer/devgroup.h"
|
||||
#include "../transport_layer/secure_channel.h"
|
||||
#include "../../lib/debug_config.h"
|
||||
#include <sqlite3.h>
|
||||
#include <string.h>
|
||||
|
||||
static int protocol_error(const char* stage) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_DM, "DM protocol: rejected stage=%s", stage);
|
||||
return -1;
|
||||
}
|
||||
|
||||
static uint64_t protocol_le64(const uint8_t* p) {
|
||||
uint64_t value = 0;
|
||||
for (unsigned i = 0; i < 8; i++) value |= (uint64_t)p[i] << (8 * i);
|
||||
return value;
|
||||
}
|
||||
|
||||
int dm_message_parse(const uint8_t* body, size_t available, struct dm_message_view* out) {
|
||||
if (!body || !out || available < 33) return protocol_error("header length");
|
||||
struct dm_message_view view = {0}; size_t pos = 33;
|
||||
memcpy(&view.conv, body, 8); memcpy(&view.seq, body + 8, 8);
|
||||
memcpy(&view.timestamp, body + 16, 8); memcpy(&view.author, body + 24, 8);
|
||||
view.content_type_size = body[32];
|
||||
if (available - pos < (size_t)view.content_type_size + 2) return protocol_error("content type length");
|
||||
view.content_type = body + pos; pos += view.content_type_size;
|
||||
memcpy(&view.ciphertext_size, body + pos, 2); pos += 2;
|
||||
if (view.ciphertext_size & DM_CONTEXT_FLAG) {
|
||||
view.mode = DM_MODE_GROUPS;
|
||||
view.ciphertext_size &= ~DM_CONTEXT_FLAG;
|
||||
if (available - pos < 1 + 2 * DM_PARTY_WIRE_SIZE + 2 || body[pos++] != 1) return protocol_error("context version/size");
|
||||
if (dm_party_decode(body + pos, view.parties) || dm_party_decode(body + pos + DM_PARTY_WIRE_SIZE, view.parties + 1))
|
||||
return protocol_error("context parties");
|
||||
pos += 2 * DM_PARTY_WIRE_SIZE;
|
||||
view.target_count = (uint16_t)(body[pos] | ((uint16_t)body[pos + 1] << 8)); pos += 2;
|
||||
if (view.target_count < 2 || view.target_count > DM_TARGET_MAX) return protocol_error("target count");
|
||||
size_t targets_size = (size_t)view.target_count * DM_TARGET_PROOF_SIZE;
|
||||
if (available - pos < targets_size) return protocol_error("target list length");
|
||||
view.targets = body + pos; pos += targets_size;
|
||||
}
|
||||
if (view.ciphertext_size < 16 || view.ciphertext_size > 1024 + 16 || available - pos < view.ciphertext_size)
|
||||
return protocol_error("ciphertext length");
|
||||
view.ciphertext = body + pos; pos += view.ciphertext_size;
|
||||
if (view.content_type_size == 4 && !memcmp(view.content_type, "file", 4)) {
|
||||
if (available - pos < 56) return protocol_error("media descriptor length");
|
||||
view.media = body + pos; pos += 56;
|
||||
}
|
||||
if (available - pos < 64) return protocol_error("signature length");
|
||||
view.signature = body + pos; view.size = pos + 64;
|
||||
*out = view;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int dm_message_target(const struct dm_message_view* view, uint64_t node, uint8_t x[32], uint8_t ed[32]) {
|
||||
if (!view || view->mode != DM_MODE_GROUPS || !view->targets) return -1;
|
||||
for (unsigned i = 0; i < view->target_count; i++) {
|
||||
const uint8_t* target = view->targets + i * DM_TARGET_PROOF_SIZE;
|
||||
if (protocol_le64(target) != node) continue;
|
||||
if (x) memcpy(x, target + 9, 32);
|
||||
if (ed) memcpy(ed, target + 41, 32);
|
||||
return target[8] <= 1 ? target[8] : -1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
int dm_message_target_at(const struct dm_message_view* view, unsigned index, struct dm_target* out) {
|
||||
if (!view || !out || view->mode != DM_MODE_GROUPS || !view->targets || index >= view->target_count)
|
||||
return protocol_error("target index");
|
||||
const uint8_t* p = view->targets + index * DM_TARGET_PROOF_SIZE;
|
||||
if (p[8] > 1) return protocol_error("target side");
|
||||
out->node = protocol_le64(p); out->side = p[8];
|
||||
return 0;
|
||||
}
|
||||
|
||||
int dm_message_verify_targets(const struct dm_message_view* view) {
|
||||
if (!view) return protocol_error("missing view");
|
||||
if (view->mode == DM_MODE_DEVICE) return 0;
|
||||
if (view->mode != DM_MODE_GROUPS || !view->targets || view->target_count < 2 || view->target_count > DM_TARGET_MAX ||
|
||||
view->conv != dm_identity_conv_id(DM_MODE_GROUPS, view->parties)) return protocol_error("conversation identity");
|
||||
unsigned sides[2] = {0}; uint64_t previous = 0; int author_found = 0;
|
||||
for (unsigned i = 0; i < view->target_count; i++) {
|
||||
const uint8_t* target = view->targets + i * DM_TARGET_PROOF_SIZE;
|
||||
uint64_t node = protocol_le64(target); unsigned side = target[8];
|
||||
if (!node || node <= previous || side > 1 || node != sc_derive_node_id_from_pubkey(target + 9))
|
||||
return protocol_error("target order/identity");
|
||||
previous = node; sides[side]++;
|
||||
const struct dm_party* party = view->parties + side;
|
||||
if (party->kind == DM_PARTY_DEVICE) {
|
||||
static const uint8_t zero[64] = {0};
|
||||
if (node != party->node || memcmp(party->x, target + 9, 32) || memcmp(party->ed, target + 41, 32) ||
|
||||
memcmp(target + 73, zero, 64)) return protocol_error("device party certificate");
|
||||
} else {
|
||||
struct devgroup_record record = {0};
|
||||
memcpy(record.ed, party->ed, 32); memcpy(record.x, party->x, 32); memcpy(record.member_sig, target + 73, 64);
|
||||
if (devgroup_verify_binding(&record, node, target + 9, target + 41)) return protocol_error("group certificate signature");
|
||||
}
|
||||
if (node == view->author) {
|
||||
if (side) return protocol_error("author on recipient side");
|
||||
author_found = 1;
|
||||
}
|
||||
}
|
||||
return author_found && sides[0] && sides[1] ? 0 : protocol_error("missing author/party");
|
||||
}
|
||||
|
||||
int dm_context_encode(struct UTUN_INSTANCE* inst, const struct dm_party parties[2],
|
||||
uint8_t* out, size_t capacity, size_t* length) {
|
||||
if (!out || !length) return protocol_error("encode arguments");
|
||||
struct dm_target targets[DM_TARGET_MAX]; size_t count = 0;
|
||||
if (dm_identity_targets(inst, parties, targets, DM_TARGET_MAX, &count)) return -1;
|
||||
size_t size = 1 + 2 * DM_PARTY_WIRE_SIZE + 2 + count * DM_TARGET_PROOF_SIZE;
|
||||
if (capacity < size) return protocol_error("encode capacity");
|
||||
uint8_t data[DM_CONTEXT_MAX]; size_t pos = 1;
|
||||
data[0] = 1;
|
||||
if (dm_party_encode(parties, data + pos) || dm_party_encode(parties + 1, data + pos + DM_PARTY_WIRE_SIZE)) return -1;
|
||||
pos += 2 * DM_PARTY_WIRE_SIZE; data[pos++] = (uint8_t)count; data[pos++] = (uint8_t)(count >> 8);
|
||||
struct dm_message_view view = {.mode = DM_MODE_GROUPS, .target_count = (uint16_t)count,
|
||||
.targets = data + pos, .author = inst->node_id, .conv = dm_identity_conv_id(DM_MODE_GROUPS, parties)};
|
||||
memcpy(view.parties, parties, sizeof(view.parties));
|
||||
sqlite3_stmt* st = NULL;
|
||||
int rc = sqlite3_prepare_v2(inst->topo_sqlite_db,
|
||||
"SELECT x25519_pubkey,ed25519_pubkey FROM nodes WHERE node_id=?", -1, &st, NULL);
|
||||
if (rc != SQLITE_OK) goto sql_error;
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
uint8_t* target = data + pos; const struct dm_party* party = parties + targets[i].side;
|
||||
for (unsigned n = 0; n < 8; n++) target[n] = (uint8_t)(targets[i].node >> (8 * n));
|
||||
target[8] = targets[i].side;
|
||||
rc = sqlite3_reset(st);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)targets[i].node);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_step(st);
|
||||
if (rc != SQLITE_ROW || sqlite3_column_bytes(st, 0) != 32 || sqlite3_column_bytes(st, 1) != 32) goto sql_error;
|
||||
memcpy(target + 9, sqlite3_column_blob(st, 0), 32); memcpy(target + 41, sqlite3_column_blob(st, 1), 32);
|
||||
memset(target + 73, 0, 64);
|
||||
if (party->kind == DM_PARTY_GROUP) {
|
||||
struct devgroup_record member;
|
||||
if (devgroup_read_member(inst->topo_sqlite_db, targets[i].node, &member) != 1 ||
|
||||
memcmp(member.ed, party->ed, 32) || memcmp(member.x, party->x, 32)) {
|
||||
sqlite3_finalize(st); return protocol_error("encode membership changed");
|
||||
}
|
||||
memcpy(target + 73, member.member_sig, 64);
|
||||
}
|
||||
pos += DM_TARGET_PROOF_SIZE;
|
||||
}
|
||||
rc = sqlite3_finalize(st); st = NULL;
|
||||
if (rc != SQLITE_OK) goto sql_error;
|
||||
if (dm_message_verify_targets(&view)) return -1;
|
||||
memcpy(out, data, size); *length = size;
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_DM, "DM protocol: encoded context conv=%llu targets=%zu bytes=%zu",
|
||||
(unsigned long long)view.conv, count, size);
|
||||
return 0;
|
||||
sql_error:
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM protocol: encode node query failed rc=%d sql=%s", rc, sqlite3_errmsg(inst->topo_sqlite_db));
|
||||
sqlite3_finalize(st);
|
||||
return -1;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/* dm_protocol — единый разбор тела DEVICE/GROUPS и сертификатов адресатов.
|
||||
* DEVICE сохраняет существующий формат, включая пустой content type. GROUPS
|
||||
* ставит старший бит data_len и помещает контекст перед ciphertext.
|
||||
* Тело подписывается целиком, включая снимок; parser не проверяет подпись автора.
|
||||
* Указатели view заимствуют входной буфер. SQL-функция encode вызывается в uasync. */
|
||||
#ifndef UTUN_DM_PROTOCOL_H
|
||||
#define UTUN_DM_PROTOCOL_H
|
||||
|
||||
#include "dm_identity.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define DM_TARGET_PROOF_SIZE 137 /* node:LE64,side:u8,x:32,ed:32,member_sig:64. */
|
||||
#define DM_CONTEXT_FLAG 0x8000u /* Старший бит uint16 data_len; фактическая длина не более1040. */
|
||||
#define DM_CONTEXT_MAX (1 + 2 * DM_PARTY_WIRE_SIZE + 2 + DM_TARGET_MAX * DM_TARGET_PROOF_SIZE)
|
||||
#define DM_BODY_MAX (33 + 255 + 2 + DM_CONTEXT_MAX + 1024 + 16 + 56 + 64)
|
||||
|
||||
struct dm_message_view {
|
||||
uint64_t conv, seq, timestamp, author; /* Общий префикс, native-endian. */
|
||||
enum dm_mode mode;
|
||||
struct dm_party parties[2]; /* Только GROUPS: сторона автора, другая сторона. */
|
||||
uint16_t target_count; /* GROUPS: снимок включает автора. DEVICE: 0. */
|
||||
const uint8_t* targets; /* target_count сертификатов по DM_TARGET_PROOF_SIZE. */
|
||||
const uint8_t* content_type; /* Не NUL-строка, ровно content_type_size байт. */
|
||||
uint8_t content_type_size;
|
||||
const uint8_t* ciphertext; /* ciphertext_size байт, включая CCM tag. */
|
||||
uint16_t ciphertext_size;
|
||||
const uint8_t* media; /* UUID/size/hash:56 либо NULL. */
|
||||
const uint8_t* signature; /* Подпись автора:64. */
|
||||
size_t size; /* Длина тела; parser допускает хвост после него. */
|
||||
};
|
||||
|
||||
/* 0/-1: проверить границы/форму тела и вернуть view. Выход не меняется при ошибке.
|
||||
* Проверка точной длины, подписей и адресата — обязанность принимающего модуля. */
|
||||
int dm_message_parse(const uint8_t* body, size_t available, struct dm_message_view* out);
|
||||
|
||||
/* 0/-1: GROUPS проверяет ID беседы, упорядоченный уникальный snapshot, обе стороны,
|
||||
* наличие автора на стороне 0 и подписи удостоверений. DEVICE — пустая операция:
|
||||
* ID/подпись конкретной пары проверяет dm_core. Ключи самих устройств связаны с node_id. */
|
||||
int dm_message_verify_targets(const struct dm_message_view* view);
|
||||
|
||||
/* GROUPS: найти node в снимке, 0/1 — сторона, -1 — отсутствует. При успехе
|
||||
* x/ed (если не NULL) получают по 32 байта. Для DEVICE всегда -1. */
|
||||
int dm_message_target(const struct dm_message_view* view, uint64_t node, uint8_t x[32], uint8_t ed[32]);
|
||||
/* 0/-1: прочитать node/side по индексу GROUPS-snapshot, без проверки подписи. */
|
||||
int dm_message_target_at(const struct dm_message_view* view, unsigned index, struct dm_target* out);
|
||||
|
||||
/* 0/-1: сформировать контекст GROUPS (между data_len с флагом и ciphertext).
|
||||
* VERSION=1:u8, parties:2*73,count:LE16,sorted certificates:count*137.
|
||||
* Сторона 0 должна включать текущего отправителя. out меняется только при успехе;
|
||||
* DM_CONTEXT_MAX достаточно. Источник включается, неизвестные устройства не ищутся. */
|
||||
int dm_context_encode(struct UTUN_INSTANCE* inst, const struct dm_party parties[2],
|
||||
uint8_t* out, size_t capacity, size_t* length);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -0,0 +1,229 @@
|
||||
#include "dm_schema.h"
|
||||
#include "../utun_instance.h"
|
||||
#include "../../lib/debug_config.h"
|
||||
#include <sqlite3.h>
|
||||
#include <stdio.h>
|
||||
|
||||
static int schema_error(sqlite3* db, const char* stage) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM schema: %s sql=%s", stage, db ? sqlite3_errmsg(db) : "unavailable");
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int schema_exec(sqlite3* db, const char* sql) {
|
||||
if (sqlite3_exec(db, sql, NULL, NULL, NULL) != SQLITE_OK) return schema_error(db, sql);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Не подменять ошибку чтения схемы отсутствием колонки. */
|
||||
static int schema_has_column(sqlite3* db, const char* table, const char* column) {
|
||||
sqlite3_stmt* st = NULL;
|
||||
int rc = sqlite3_prepare_v2(db, "SELECT 1 FROM pragma_table_info(?) WHERE name=?", -1, &st, NULL);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_text(st, 1, table, -1, SQLITE_STATIC);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_text(st, 2, column, -1, SQLITE_STATIC);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_step(st);
|
||||
int found = rc == SQLITE_ROW;
|
||||
int final = sqlite3_finalize(st);
|
||||
return (rc == SQLITE_ROW || rc == SQLITE_DONE) && final == SQLITE_OK ? found : schema_error(db, "column lookup");
|
||||
}
|
||||
|
||||
int dm_media_schema_init(struct UTUN_INSTANCE* inst) {
|
||||
sqlite3* db = inst ? inst->topo_sqlite_db : NULL;
|
||||
if (!db) return schema_error(db, "media database absent");
|
||||
if (schema_exec(db, "SAVEPOINT dm_media_schema")) return -1;
|
||||
const char* files = "CREATE TABLE IF NOT EXISTS dm_files(id BLOB PRIMARY KEY,conv_id TEXT NOT NULL,dir INTEGER NOT NULL,"
|
||||
"seq INTEGER NOT NULL,author INTEGER NOT NULL,recipient INTEGER NOT NULL,plain_size INTEGER NOT NULL,"
|
||||
"cipher_hash BLOB NOT NULL,body BLOB NOT NULL,local_path TEXT NOT NULL,"
|
||||
"ready INTEGER NOT NULL DEFAULT 0,holder INTEGER NOT NULL,expires INTEGER NOT NULL DEFAULT 0,receipt BLOB,"
|
||||
"downloading INTEGER NOT NULL DEFAULT 0,retry_at INTEGER NOT NULL DEFAULT 0,manual INTEGER NOT NULL DEFAULT 0,"
|
||||
"first_attempt INTEGER NOT NULL DEFAULT 0,UNIQUE(conv_id,author,seq))";
|
||||
if (schema_exec(db, files)) goto fail;
|
||||
int found = schema_has_column(db, "dm_files", "manual");
|
||||
if (found < 0) goto fail;
|
||||
if (!found) {
|
||||
if (schema_exec(db, "ALTER TABLE dm_files RENAME TO dm_files_previous") || schema_exec(db, files) ||
|
||||
schema_exec(db, "INSERT INTO dm_files(id,conv_id,dir,seq,author,recipient,plain_size,cipher_hash,body,"
|
||||
"local_path,ready,holder,expires,receipt,downloading,retry_at) SELECT id,conv_id,dir,seq,author,recipient,"
|
||||
"plain_size,cipher_hash,body,local_path,ready,holder,expires,receipt,downloading,retry_at FROM dm_files_previous") ||
|
||||
schema_exec(db, "DROP TABLE dm_files_previous")) goto fail;
|
||||
}
|
||||
if (schema_exec(db, "CREATE TABLE IF NOT EXISTS dm_file_targets(id BLOB NOT NULL,node INTEGER NOT NULL,side INTEGER NOT NULL,"
|
||||
"received BLOB,taken BLOB,PRIMARY KEY(id,node));"
|
||||
"CREATE TABLE IF NOT EXISTS dm_file_sources(id BLOB NOT NULL,node INTEGER NOT NULL,"
|
||||
"expires INTEGER NOT NULL DEFAULT 0,PRIMARY KEY(id,node));"
|
||||
"CREATE TABLE IF NOT EXISTS dm_custody(id BLOB PRIMARY KEY,author INTEGER NOT NULL,recipient INTEGER NOT NULL,"
|
||||
"plain_size INTEGER NOT NULL,cipher_size INTEGER NOT NULL,body BLOB NOT NULL,body_hash BLOB NOT NULL,"
|
||||
"expires INTEGER NOT NULL,ready INTEGER NOT NULL DEFAULT 0,"
|
||||
"receipt BLOB,retry_at INTEGER NOT NULL DEFAULT 0);"
|
||||
"CREATE TABLE IF NOT EXISTS dm_custody_done(id BLOB PRIMARY KEY,body_hash BLOB NOT NULL,receipt BLOB);"
|
||||
"CREATE TABLE IF NOT EXISTS dm_custody_requests(supernode INTEGER NOT NULL,assignment_id BLOB NOT NULL,"
|
||||
"id BLOB NOT NULL,body_hash BLOB NOT NULL,rejected INTEGER NOT NULL DEFAULT 0,PRIMARY KEY(supernode,assignment_id));"
|
||||
"CREATE TABLE IF NOT EXISTS dm_media_jobs(id BLOB PRIMARY KEY,author INTEGER NOT NULL,recipient INTEGER NOT NULL,"
|
||||
"seq INTEGER NOT NULL,body BLOB,body_hash BLOB NOT NULL,holder INTEGER NOT NULL DEFAULT 0,"
|
||||
"stored INTEGER NOT NULL DEFAULT 0,expires INTEGER NOT NULL DEFAULT 0,expired INTEGER NOT NULL DEFAULT 0,"
|
||||
"receipt BLOB,retry_at INTEGER NOT NULL DEFAULT 0);"
|
||||
"CREATE TABLE IF NOT EXISTS dm_media_holders(id BLOB NOT NULL,node_id INTEGER NOT NULL,"
|
||||
"assignment_id BLOB NOT NULL,released INTEGER NOT NULL DEFAULT 0,PRIMARY KEY(id,node_id))")) goto fail;
|
||||
found = schema_has_column(db, "dm_media_holders", "assignment_id");
|
||||
if (found < 0) goto fail;
|
||||
if (!found && (schema_exec(db, "ALTER TABLE dm_media_holders ADD COLUMN assignment_id BLOB") ||
|
||||
schema_exec(db, "UPDATE dm_media_holders SET assignment_id=randomblob(16)"))) goto fail;
|
||||
if (schema_exec(db, "CREATE UNIQUE INDEX IF NOT EXISTS dm_media_assignment_id ON dm_media_holders(assignment_id)")) goto fail;
|
||||
found = schema_has_column(db, "dm_custody", "supernode");
|
||||
if (found < 0 || (found && schema_exec(db, "ALTER TABLE dm_custody DROP COLUMN supernode"))) goto fail;
|
||||
const char* tables[] = {"dm_custody", "dm_custody_done", "dm_media_jobs"};
|
||||
for (unsigned i = 0; i < 3; i++) {
|
||||
found = schema_has_column(db, tables[i], "delete_on_open");
|
||||
if (found < 0) goto fail;
|
||||
if (!found) {
|
||||
char sql[256];
|
||||
snprintf(sql, sizeof(sql), "ALTER TABLE %s ADD COLUMN delete_on_open INTEGER NOT NULL DEFAULT %d", tables[i], i == 2 ? -1 : 1);
|
||||
if (schema_exec(db, sql)) goto fail;
|
||||
}
|
||||
}
|
||||
const char* routed[] = {"dm_files", "dm_file_sources", "dm_custody", "dm_media_jobs", "dm_media_holders"};
|
||||
for (unsigned i = 0; i < sizeof(routed) / sizeof(routed[0]); i++) {
|
||||
found = schema_has_column(db, routed[i], "group_id");
|
||||
if (found < 0) goto fail;
|
||||
if (found) {
|
||||
char sql[128]; snprintf(sql, sizeof(sql), "ALTER TABLE %s DROP COLUMN group_id", routed[i]);
|
||||
if (schema_exec(db, sql)) goto fail;
|
||||
DEBUG_INFO(DEBUG_CATEGORY_DM, "DM media schema: retained %s, removed route binding", routed[i]);
|
||||
}
|
||||
}
|
||||
if (schema_exec(db, "RELEASE dm_media_schema")) goto fail;
|
||||
DEBUG_INFO(DEBUG_CATEGORY_DM, "DM media schema: author identity, device receipts and retention policy ready");
|
||||
return 0;
|
||||
fail:
|
||||
schema_exec(db, "ROLLBACK TO dm_media_schema"); schema_exec(db, "RELEASE dm_media_schema"); return -1;
|
||||
}
|
||||
|
||||
int dm_schema_init(struct UTUN_INSTANCE* inst) {
|
||||
sqlite3* db = inst ? inst->topo_sqlite_db : NULL;
|
||||
if (!db) return schema_error(db, "missing database");
|
||||
if (schema_exec(db, "SAVEPOINT dm_schema")) return -1;
|
||||
const char* conversations =
|
||||
"CREATE TABLE IF NOT EXISTS dm_conversations(conv_id TEXT PRIMARY KEY,peer_node_id INTEGER NOT NULL,"
|
||||
"peer_x25519 BLOB NOT NULL,peer_ed25519 BLOB NOT NULL,peer_name TEXT DEFAULT '',group_id INTEGER DEFAULT 0,"
|
||||
"last_out_seq INTEGER DEFAULT 0,last_in_seq INTEGER DEFAULT 0,created_at INTEGER DEFAULT 0,last_ts INTEGER DEFAULT 0)";
|
||||
const char* messages =
|
||||
"CREATE TABLE IF NOT EXISTS dm_messages(conv_id TEXT NOT NULL,dir INTEGER NOT NULL,seq INTEGER NOT NULL,"
|
||||
"ts INTEGER NOT NULL,author INTEGER NOT NULL,ct TEXT DEFAULT '',data BLOB,sig BLOB,body BLOB,PRIMARY KEY(conv_id,author,seq))";
|
||||
const char* deleted =
|
||||
"CREATE TABLE IF NOT EXISTS dm_deleted_messages(conv_id TEXT NOT NULL,dir INTEGER NOT NULL,seq INTEGER NOT NULL,"
|
||||
"author INTEGER NOT NULL,body_hash BLOB NOT NULL,media_id BLOB,media_receipt BLOB,PRIMARY KEY(conv_id,author,seq))";
|
||||
if (schema_exec(db, conversations)) goto fail;
|
||||
const char* names[] = {"mode", "local_party", "peer_party", "local_key", "closed"};
|
||||
const char* declarations[] = {"INTEGER NOT NULL DEFAULT 0", "BLOB", "BLOB", "BLOB", "INTEGER NOT NULL DEFAULT 0"};
|
||||
for (unsigned i = 0; i < sizeof(names) / sizeof(names[0]); i++) {
|
||||
int found = schema_has_column(db, "dm_conversations", names[i]);
|
||||
if (found < 0) goto fail;
|
||||
if (!found) {
|
||||
char sql[160]; snprintf(sql, sizeof(sql), "ALTER TABLE dm_conversations ADD COLUMN %s %s", names[i], declarations[i]);
|
||||
if (schema_exec(db, sql)) goto fail;
|
||||
}
|
||||
}
|
||||
if (schema_exec(db, messages) || schema_exec(db, deleted)) goto fail;
|
||||
int found = schema_has_column(db, "dm_messages", "body");
|
||||
if (found < 0) goto fail;
|
||||
if (!found) {
|
||||
if (schema_exec(db, "ALTER TABLE dm_messages RENAME TO dm_messages_previous") || schema_exec(db, messages) ||
|
||||
schema_exec(db, "INSERT INTO dm_messages(conv_id,dir,seq,ts,author,ct,data,sig)"
|
||||
" SELECT conv_id,dir,seq,ts,author,ct,data,sig FROM dm_messages_previous") ||
|
||||
schema_exec(db, "DROP TABLE dm_messages_previous")) goto fail;
|
||||
DEBUG_INFO(DEBUG_CATEGORY_DM, "DM schema: retained DEVICE history; message identity now includes author");
|
||||
}
|
||||
found = schema_has_column(db, "dm_deleted_messages", "author");
|
||||
if (found < 0) goto fail;
|
||||
if (!found) {
|
||||
if (schema_exec(db, "ALTER TABLE dm_deleted_messages RENAME TO dm_deleted_previous") || schema_exec(db, deleted)) goto fail;
|
||||
sqlite3_stmt* st = NULL;
|
||||
int rc = sqlite3_prepare_v2(db, "INSERT INTO dm_deleted_messages(conv_id,dir,seq,author,body_hash,media_id,media_receipt)"
|
||||
" SELECT d.conv_id,d.dir,d.seq,CASE WHEN d.dir=1 THEN ? ELSE c.peer_node_id END,d.body_hash,d.media_id,d.media_receipt"
|
||||
" FROM dm_deleted_previous d LEFT JOIN dm_conversations c USING(conv_id)", -1, &st, NULL);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)inst->node_id);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_step(st);
|
||||
int final = sqlite3_finalize(st);
|
||||
if (rc != SQLITE_DONE || final != SQLITE_OK) { schema_error(db, "retain deleted message fingerprints"); goto fail; }
|
||||
if (schema_exec(db, "DROP TABLE dm_deleted_previous")) goto fail;
|
||||
}
|
||||
if (schema_exec(db,
|
||||
"CREATE INDEX IF NOT EXISTS dm_messages_time ON dm_messages(conv_id,ts,author,seq);"
|
||||
"CREATE TABLE IF NOT EXISTS dm_outbox(conv_id TEXT NOT NULL,seq INTEGER NOT NULL,body BLOB NOT NULL,"
|
||||
"retry_at INTEGER NOT NULL DEFAULT 0,PRIMARY KEY(conv_id,seq));"
|
||||
"CREATE TABLE IF NOT EXISTS dm_delivery(conv_id TEXT NOT NULL,author INTEGER NOT NULL,seq INTEGER NOT NULL,"
|
||||
"recipient INTEGER NOT NULL,receipt BLOB,first_attempt INTEGER NOT NULL DEFAULT 0,direct_until INTEGER NOT NULL DEFAULT 0,"
|
||||
"retry_at INTEGER NOT NULL DEFAULT 0,supernode INTEGER NOT NULL DEFAULT 0,PRIMARY KEY(conv_id,author,seq,recipient));"
|
||||
"CREATE TABLE IF NOT EXISTS dm_sequence(node_id INTEGER PRIMARY KEY,value INTEGER NOT NULL);")) goto fail;
|
||||
sqlite3_stmt* st = NULL;
|
||||
int rc = sqlite3_prepare_v2(db, "INSERT INTO dm_sequence(node_id,value) SELECT ?,COALESCE(MAX(last_out_seq),0) FROM dm_conversations"
|
||||
" WHERE 1 ON CONFLICT(node_id) DO UPDATE SET value=MAX(value,excluded.value)", -1, &st, NULL);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)inst->node_id);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_step(st);
|
||||
int final = sqlite3_finalize(st); st = NULL;
|
||||
if (rc != SQLITE_DONE || final != SQLITE_OK) { schema_error(db, "initialize author sequence"); goto fail; }
|
||||
rc = sqlite3_prepare_v2(db, "INSERT INTO dm_delivery(conv_id,author,seq,recipient,retry_at)"
|
||||
" SELECT o.conv_id,?,o.seq,c.peer_node_id,o.retry_at FROM dm_outbox o JOIN dm_conversations c USING(conv_id)"
|
||||
" WHERE c.mode=0 ON CONFLICT(conv_id,author,seq,recipient) DO NOTHING", -1, &st, NULL);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)inst->node_id);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_step(st);
|
||||
final = sqlite3_finalize(st);
|
||||
if (rc != SQLITE_DONE || final != SQLITE_OK) { schema_error(db, "retain DEVICE outbox recipients"); goto fail; }
|
||||
if (schema_exec(db, "RELEASE dm_schema")) goto fail;
|
||||
DEBUG_INFO(DEBUG_CATEGORY_DM, "DM schema: ready; author sequence, frozen parties and per-device delivery");
|
||||
return 0;
|
||||
fail:
|
||||
schema_exec(db, "ROLLBACK TO dm_schema"); schema_exec(db, "RELEASE dm_schema");
|
||||
return -1;
|
||||
}
|
||||
|
||||
int dm_mailbox_schema_init(struct UTUN_INSTANCE* inst) {
|
||||
sqlite3* db = inst ? inst->topo_sqlite_db : NULL;
|
||||
if (!db) return schema_error(db, "missing mailbox database");
|
||||
if (schema_exec(db, "SAVEPOINT dm_mailbox_schema")) return -1;
|
||||
if (schema_exec(db, "CREATE TABLE IF NOT EXISTS dm_pending_messages(recipient INTEGER NOT NULL,sender INTEGER NOT NULL,"
|
||||
"seq INTEGER NOT NULL,msg BLOB NOT NULL,retry_at INTEGER NOT NULL DEFAULT 0,PRIMARY KEY(recipient,sender,seq));"
|
||||
"CREATE TABLE IF NOT EXISTS dm_mail_receipts(recipient INTEGER NOT NULL,sender INTEGER NOT NULL,"
|
||||
"seq INTEGER NOT NULL,receipt BLOB NOT NULL,PRIMARY KEY(recipient,sender,seq))")) goto fail;
|
||||
const char* mailbox_tables[] = {"dm_pending_messages", "dm_mail_receipts"};
|
||||
for (unsigned i = 0; i < 2; i++) {
|
||||
int found = schema_has_column(db, mailbox_tables[i], "group_id");
|
||||
if (found < 0) goto fail;
|
||||
if (found) {
|
||||
char sql[128]; snprintf(sql, sizeof(sql), "ALTER TABLE %s DROP COLUMN group_id", mailbox_tables[i]);
|
||||
if (schema_exec(db, sql)) goto fail;
|
||||
DEBUG_INFO(DEBUG_CATEGORY_DM, "DM mailbox schema: retained %s, removed route binding", mailbox_tables[i]);
|
||||
}
|
||||
}
|
||||
if (schema_exec(db, "RELEASE dm_mailbox_schema")) goto fail;
|
||||
DEBUG_INFO(DEBUG_CATEGORY_DM, "DM mailbox schema: identity-based pending and receipts ready");
|
||||
return 0;
|
||||
fail:
|
||||
schema_exec(db, "ROLLBACK TO dm_mailbox_schema"); schema_exec(db, "RELEASE dm_mailbox_schema");
|
||||
return -1;
|
||||
}
|
||||
|
||||
int dm_sequence_next(struct UTUN_INSTANCE* inst, uint64_t* seq) {
|
||||
sqlite3* db = inst ? inst->topo_sqlite_db : NULL; sqlite3_stmt* st = NULL;
|
||||
if (!db || !seq) return schema_error(db, "invalid sequence arguments");
|
||||
int rc = sqlite3_prepare_v2(db, "SELECT value FROM dm_sequence WHERE node_id=?", -1, &st, NULL);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)inst->node_id);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_step(st);
|
||||
sqlite3_int64 value = rc == SQLITE_ROW ? sqlite3_column_int64(st, 0) : -1;
|
||||
int final = sqlite3_finalize(st);
|
||||
if (rc != SQLITE_ROW || final != SQLITE_OK || value < 0 || value == INT64_MAX) return schema_error(db, "sequence unavailable/exhausted");
|
||||
*seq = (uint64_t)value + 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int dm_sequence_take(struct UTUN_INSTANCE* inst, uint64_t seq) {
|
||||
sqlite3* db = inst ? inst->topo_sqlite_db : NULL; sqlite3_stmt* st = NULL;
|
||||
if (!db || !seq || seq > INT64_MAX || sqlite3_get_autocommit(db)) return schema_error(db, "sequence outside send transaction");
|
||||
int rc = sqlite3_prepare_v2(db, "UPDATE dm_sequence SET value=? WHERE node_id=? AND value=?", -1, &st, NULL);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)seq);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 2, (sqlite3_int64)inst->node_id);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 3, (sqlite3_int64)(seq - 1));
|
||||
if (rc == SQLITE_OK) rc = sqlite3_step(st);
|
||||
int changed = sqlite3_changes(db), final = sqlite3_finalize(st);
|
||||
if (rc != SQLITE_DONE || final != SQLITE_OK || changed != 1) return schema_error(db, "sequence reservation conflict");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/* dm_schema — атомарное создание/обновление локальной схемы личных бесед.
|
||||
* Сохраняет историю, счётчики, точные тела outbox и отпечатки удалённых сообщений.
|
||||
* Изменения выполняются внутри savepoint в uasync-потоке; ошибка откатывает схему. */
|
||||
#ifndef UTUN_DM_SCHEMA_H
|
||||
#define UTUN_DM_SCHEMA_H
|
||||
#include <stdint.h>
|
||||
struct UTUN_INSTANCE;
|
||||
|
||||
/* 0/-1: создать схему и перенести существующие DEVICE-записи без переподписи. */
|
||||
int dm_schema_init(struct UTUN_INSTANCE* inst);
|
||||
/* 0/-1: создать mailbox pending/receipts без привязки к маршруту; сохранить имеющиеся записи.
|
||||
* Вызывается dm_mailbox_init независимо от dm_core_init. */
|
||||
int dm_mailbox_schema_init(struct UTUN_INSTANCE* inst);
|
||||
/* Общая схема файлов endpoint/S/M и квитанций устройств; вызывается до media owners.
|
||||
* Сохранённые DEVICE-файлы остаются, уникальность меняется с dir на author. 0/-1. */
|
||||
int dm_media_schema_init(struct UTUN_INSTANCE* inst);
|
||||
/* 0/-1: прочитать следующий seq автора. Резервирование выполняет take в той же
|
||||
* транзакции, что запись сообщения; до commit тело нельзя передавать в сеть. */
|
||||
int dm_sequence_next(struct UTUN_INSTANCE* inst, uint64_t* seq);
|
||||
/* 0/-1: увеличить счётчик с seq-1 до seq. Вызывать внутри транзакции отправки. */
|
||||
int dm_sequence_take(struct UTUN_INSTANCE* inst, uint64_t seq);
|
||||
#endif
|
||||
@@ -11,6 +11,7 @@
|
||||
#include "../utun_instance.h"
|
||||
#include "../routing_layer/conn_mgr.h"
|
||||
#include "../routing_layer/etcp_router.h"
|
||||
#include "../routing_layer/topo_group.h"
|
||||
#include "../transport_layer/etcp.h"
|
||||
|
||||
#define FT_REQUEST 1 /* tid:16, object:16, size:8, offset:8 */
|
||||
@@ -34,6 +35,7 @@ struct file_transfer_op {
|
||||
struct file_transfer* ft;
|
||||
uint64_t group, peer, size, offset, progress_tb, request_tb;
|
||||
uint8_t tid[16], object[16];
|
||||
char path[1024]; /* Конкретная локальная копия, независимо от UUID содержимого. */
|
||||
int receiving, up;
|
||||
FILE* file;
|
||||
struct CONN_MGR_HANDLE* cm;
|
||||
@@ -60,8 +62,8 @@ static void ft_finish(struct file_transfer_op* op, int error) {
|
||||
while (*p && *p != op) p = &(*p)->next;
|
||||
if (*p) *p = op->next;
|
||||
ft->count--;
|
||||
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "file_transfer: finish peer=%llu receive=%d bytes=%llu/%llu error=%d active=%u",
|
||||
(unsigned long long)op->peer, op->receiving, (unsigned long long)op->offset,
|
||||
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "file_transfer: finish group=%llu peer=%llu receive=%d bytes=%llu/%llu error=%d active=%u",
|
||||
(unsigned long long)op->group, (unsigned long long)op->peer, op->receiving, (unsigned long long)op->offset,
|
||||
(unsigned long long)op->size, error, ft->count);
|
||||
file_transfer_done_fn done = op->done;
|
||||
void* arg = op->arg;
|
||||
@@ -69,9 +71,21 @@ static void ft_finish(struct file_transfer_op* op, int error) {
|
||||
if (done) done(arg, error);
|
||||
}
|
||||
|
||||
/* Отложенный callback не продолжает I/O после отзыва; до возврата 0 операция уже освобождена. */
|
||||
static int ft_active(struct file_transfer_op* op) {
|
||||
if (topo_group_chat_allowed(op->ft->inst, op->group, op->peer)) return 1;
|
||||
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "file_transfer: revoke group=%llu peer=%llu receive=%d",
|
||||
(unsigned long long)op->group, (unsigned long long)op->peer, op->receiving);
|
||||
ft_finish(op, -2); return 0;
|
||||
}
|
||||
|
||||
/* Отправить подписанную кодограмму, не превышая порог очереди. */
|
||||
static int ft_send(struct file_transfer* ft, uint64_t group, uint64_t peer,
|
||||
uint8_t cmd, const uint8_t* data, size_t size) {
|
||||
if (!topo_group_chat_allowed(ft->inst, group, peer)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file_transfer: send denied group=%llu peer=%llu cmd=%u",
|
||||
(unsigned long long)group, (unsigned long long)peer, cmd); return -1;
|
||||
}
|
||||
struct ETCP_ROUTER_CONN* rc = etcp_router_conn_get(ft->inst, group, peer, ETCP_RT_ID_FILE_TRANSFER);
|
||||
if (!rc) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "file_transfer: router allocation failed"); return -1; }
|
||||
queue_set_threshold(rc->send_q, 0, 0);
|
||||
@@ -112,6 +126,7 @@ static void ft_ready(struct ll_queue* q, void* arg) {
|
||||
static void ft_pump(void* arg) {
|
||||
struct file_transfer_op* op = arg;
|
||||
op->pump = NULL;
|
||||
if (!ft_active(op)) return;
|
||||
if (!op->up || op->receiving) return;
|
||||
struct file_transfer* ft = op->ft;
|
||||
if (op->offset < op->size) {
|
||||
@@ -143,6 +158,7 @@ static void ft_connected(struct CONN_MGR_HANDLE* h, uint64_t peer, uint64_t grou
|
||||
enum conn_mgr_event event, void* arg) {
|
||||
(void)h; (void)peer; (void)group;
|
||||
struct file_transfer_op* op = arg;
|
||||
if (!ft_active(op)) return;
|
||||
op->up = event == CONN_EVENT_UP;
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "file_transfer: CM event=%d peer=%llu receive=%d offset=%llu",
|
||||
event, (unsigned long long)op->peer, op->receiving, (unsigned long long)op->offset);
|
||||
@@ -156,6 +172,7 @@ static void ft_connected(struct CONN_MGR_HANDLE* h, uint64_t peer, uint64_t grou
|
||||
static void ft_tick(void* arg) {
|
||||
struct file_transfer_op* op = arg;
|
||||
op->timer = NULL;
|
||||
if (!ft_active(op)) return;
|
||||
uint64_t now = get_time_tb();
|
||||
if (now - op->progress_tb >= 300000) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file_transfer: inactivity timeout peer=%llu offset=%llu/%llu",
|
||||
@@ -174,15 +191,21 @@ static void ft_tick(void* arg) {
|
||||
/* Добавить операцию до conn_mgr_open; callbacks CM используют уже живой owner. */
|
||||
static struct file_transfer_op* ft_open(struct file_transfer* ft, uint64_t group, uint64_t peer,
|
||||
const uint8_t object[16], uint64_t size, const char* path, int receiving) {
|
||||
if (!ft || ft->closing || !group || !peer || !object || !path || !size || size > INT64_MAX || ft->count >= FT_MAX_OPS) {
|
||||
if (!ft || ft->closing || !group || !peer || !object || !path || strlen(path) >= 1024 ||
|
||||
!size || size > INT64_MAX || ft->count >= FT_MAX_OPS) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file_transfer: invalid request or operation limit size=%llu",
|
||||
(unsigned long long)size);
|
||||
return NULL;
|
||||
}
|
||||
if (!topo_group_chat_allowed(ft->inst, group, peer)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file_transfer: open denied group=%llu peer=%llu receive=%d",
|
||||
(unsigned long long)group, (unsigned long long)peer, receiving); return NULL;
|
||||
}
|
||||
struct file_transfer_op* op = u_calloc(1, sizeof(*op));
|
||||
if (!op) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "file_transfer: operation allocation failed"); return NULL; }
|
||||
op->ft = ft; op->group = group; op->peer = peer; op->size = size; op->receiving = receiving;
|
||||
memcpy(op->object, object, 16);
|
||||
snprintf(op->path, sizeof(op->path), "%s", path);
|
||||
op->file = fopen(path, receiving ? "wb" : "rb");
|
||||
if (!op->file) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "file_transfer: open failed path=%s: %s", path, strerror(errno));
|
||||
@@ -198,8 +221,8 @@ static struct file_transfer_op* ft_open(struct file_transfer* ft, uint64_t group
|
||||
ft_finish(op, -1);
|
||||
return NULL;
|
||||
}
|
||||
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "file_transfer: start peer=%llu receive=%d size=%llu active=%u",
|
||||
(unsigned long long)peer, receiving, (unsigned long long)size, ft->count);
|
||||
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "file_transfer: start group=%llu peer=%llu receive=%d size=%llu active=%u",
|
||||
(unsigned long long)group, (unsigned long long)peer, receiving, (unsigned long long)size, ft->count);
|
||||
return op;
|
||||
}
|
||||
|
||||
@@ -239,12 +262,17 @@ static void ft_serve(struct file_transfer* ft, uint64_t group, uint64_t peer, co
|
||||
|
||||
/* Signed source, transfer ID и точные границы отделяют операции и повторы. */
|
||||
static void ft_recv(struct ETCP_CONN* conn, struct ll_entry* e) {
|
||||
if (!e || !conn || !e->dgram || e->len <= ROUTER_SVC_PAYLOAD_OFF) goto done;
|
||||
if (!e || !conn || !e->dgram || e->len < ROUTER_SVC_PAYLOAD_OFF) goto done;
|
||||
struct file_transfer* ft = conn->instance->file_transfer;
|
||||
if (!ft || ft->closing) goto done;
|
||||
uint64_t group, peer;
|
||||
memcpy(&group, e->dgram + ROUTER_SVC_GROUP_OFF, 8);
|
||||
memcpy(&peer, e->dgram + ROUTER_SVC_SRC_OFF, 8);
|
||||
if (e->len == ROUTER_SVC_PAYLOAD_OFF) { file_transfer_revalidate_members(ft, group); goto done; }
|
||||
if (!topo_group_chat_allowed(ft->inst, group, peer)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file_transfer: receive denied group=%llu peer=%llu",
|
||||
(unsigned long long)group, (unsigned long long)peer); goto done;
|
||||
}
|
||||
if (!(e->dgram[ROUTER_SVC_FLAGS_OFF] & ROUTER_FLAG_SIGNED)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file_transfer: unsigned packet peer=%llu", (unsigned long long)peer);
|
||||
goto done;
|
||||
@@ -296,7 +324,14 @@ struct file_transfer* file_transfer_create(struct UTUN_INSTANCE* inst, file_tran
|
||||
struct file_transfer* ft = u_calloc(1, sizeof(*ft));
|
||||
if (!ft) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "file_transfer: owner allocation failed"); return NULL; }
|
||||
ft->inst = inst; ft->lookup = lookup; ft->arg = arg;
|
||||
if (etcp_router_bind(inst, ETCP_RT_ID_FILE_TRANSFER, ft_recv) != 0) { u_free(ft); return NULL; }
|
||||
if (etcp_router_bind(inst, ETCP_RT_ID_FILE_TRANSFER, ft_recv) != 0) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "file_transfer: router binding failed"); u_free(ft); return NULL;
|
||||
}
|
||||
if (etcp_router_require_crypto(inst, ETCP_RT_ID_FILE_TRANSFER, ROUTER_FLAG_SIGNED) ||
|
||||
etcp_router_set_authorize(inst, ETCP_RT_ID_FILE_TRANSFER, topo_group_chat_allowed)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "file_transfer: router policy setup failed");
|
||||
etcp_router_unbind(inst, ETCP_RT_ID_FILE_TRANSFER); u_free(ft); return NULL;
|
||||
}
|
||||
inst->file_transfer = ft;
|
||||
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "file_transfer: initialized max_operations=%u", FT_MAX_OPS);
|
||||
return ft;
|
||||
@@ -341,3 +376,27 @@ void file_transfer_cancel_object(struct file_transfer* ft, const uint8_t object[
|
||||
ft_finish(found, -2);
|
||||
}
|
||||
}
|
||||
|
||||
void file_transfer_cancel_path(struct file_transfer* ft, const char* path) {
|
||||
if (!ft || !path) return;
|
||||
for (;;) {
|
||||
struct file_transfer_op* found = NULL;
|
||||
for (struct file_transfer_op* op = ft->ops; op; op = op->next)
|
||||
if (!strcmp(op->path, path)) { found = op; break; }
|
||||
if (!found) break;
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "file_transfer: revoke copy peer=%llu path=%s", (unsigned long long)found->peer, path);
|
||||
ft_finish(found, -2);
|
||||
}
|
||||
}
|
||||
|
||||
void file_transfer_revalidate_members(struct file_transfer* ft, uint64_t group) {
|
||||
if (!ft || ft->closing) return;
|
||||
/* done может отменить другие операции: следующий указатель через callback не сохраняем. */
|
||||
for (;;) {
|
||||
struct file_transfer_op* denied = NULL;
|
||||
for (struct file_transfer_op* op = ft->ops; op; op = op->next)
|
||||
if (op->group == group && !topo_group_chat_allowed(ft->inst, group, op->peer)) { denied = op; break; }
|
||||
if (!denied) return;
|
||||
ft_active(denied);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
/* file_transfer — передача неизменяемого файла между явно заданными узлами.
|
||||
* Не знает о чатах, ключах содержимого, custody или БД. lookup авторизует запрос
|
||||
* и возвращает путь; получатель проверяет содержимое в своём done.
|
||||
* Проверяет допуск self/peer к CHAT-группе; lookup отдельно авторизует объект
|
||||
* и возвращает путь. Ключи содержимого, custody и проверка результата принадлежат владельцу.
|
||||
* Каждая операция владеет CM handle до завершения/отмены, включая DOWN/TIMEOUT.
|
||||
* Все API/callbacks в uasync; destroy вызывается вне callbacks, до удаления групп.
|
||||
* До 16 входящих/исходящих операций суммарно. DATA до 8192 байт; router-пакеты подписаны,
|
||||
* До 16 входящих/исходящих операций суммарно. DATA до 8192 байт; router DATA и controls подписаны,
|
||||
* uint64 wire-поля имеют native byte order. Простой 30с завершает операцию ошибкой;
|
||||
* при восстановлении пути/простое приём повторяет REQUEST с непрерывного полученного offset.
|
||||
* Собственное временное назначение передаёт вызывающий: файл открывается с усечением,
|
||||
* в том числе до возможного отказа запуска. При ошибке/cancel файл не удаляется и отбрасывается caller.
|
||||
* после проверки допуска, но до возможного ресурсного отказа запуска. При ошибке/cancel файл не удаляется и отбрасывается caller.
|
||||
* Отзыв отменяет операции/таймеры/waiters данной группы; общий ETCP и другие группы сохраняются.
|
||||
*/
|
||||
#ifndef FILE_TRANSFER_H
|
||||
#define FILE_TRANSFER_H
|
||||
@@ -47,5 +48,13 @@ void file_transfer_cancel(struct file_transfer_op* op);
|
||||
/* Отозвать все операции объекта во всех группах (TTL/delete); ft=NULL допустим, object обязателен.
|
||||
* callbacks приёма вызываются синхронно с -2, при ошибке fclose — с -1. */
|
||||
void file_transfer_cancel_object(struct file_transfer* ft, const uint8_t object[16]);
|
||||
/* Отменить операции именно этого локального пути. Совмещённые endpoint/custody
|
||||
* одного UUID имеют разные файлы; отзыв одного не затрагивает другую копию. */
|
||||
void file_transfer_cancel_path(struct file_transfer* ft, const char* path);
|
||||
|
||||
/* После commit изменения состава отменить операции group, для которых self/peer больше не допущены.
|
||||
* ft=NULL допустим; done вызывается синхронно после освобождения операции с -2 (либо -1 при ошибке fclose).
|
||||
* Новый pull через другую допущенную группу создаёт отдельную операцию; личные данные владельца не удаляются. */
|
||||
void file_transfer_revalidate_members(struct file_transfer* ft, uint64_t group);
|
||||
|
||||
#endif
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "../../lib/file_io.h"
|
||||
#include "../utun_instance.h"
|
||||
#include "../routing_layer/etcp_router.h"
|
||||
#include "../routing_layer/route_crypto.h"
|
||||
#include "../routing_layer/topo_group.h"
|
||||
#include "../routing_layer/topo_node.h"
|
||||
#include "../routing_layer/topo_node_sqlite.h"
|
||||
@@ -19,6 +20,7 @@
|
||||
#include "../lib/u_async.h"
|
||||
#include "../lib/sqlite3.h"
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <time.h>
|
||||
#include <errno.h>
|
||||
#include "../lib/platform_compat.h"
|
||||
@@ -32,8 +34,6 @@ static void md_etcp_recv(struct ETCP_CONN* conn, struct ll_entry* entry);
|
||||
static void md_on_conn_status(struct ETCP_CONN* conn, int status, void* arg);
|
||||
static void md_on_props_changed(uint64_t node_id, const char* adm_tags, const char* channel_id, void* arg);
|
||||
static void md_super_connect(struct media_delivery_ctx* md, uint64_t peer_node_id, uint64_t group_id);
|
||||
static int md_send(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t dst_node_id,
|
||||
const uint8_t* data, size_t len, int force);
|
||||
static void md_repl_pump(void* arg);
|
||||
static void md_repl_watchdog(void* arg);
|
||||
static void md_repl_notify(struct media_delivery_ctx* md, uint64_t group_id);
|
||||
@@ -550,21 +550,22 @@ static void md_repl_notify(struct media_delivery_ctx* md, uint64_t group_id) {
|
||||
/* ── send via etcp_router helper ── */
|
||||
|
||||
/* Репликация использует force=0; остальные команды сохраняют существующую политику очереди. */
|
||||
static int md_send(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t dst_node_id,
|
||||
int md_send(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t dst_node_id,
|
||||
const uint8_t* data, size_t len, int force) {
|
||||
if (!inst || !data || len == 0) return -1;
|
||||
struct TOPO_GROUP* grp = inst->topo_groups ? topo_groups_find(inst->topo_groups, group_id) : NULL;
|
||||
struct ETCP_CONN* conn = grp ? topo_group_find_conn_for_node(grp, dst_node_id) : NULL;
|
||||
if (!conn)
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "%s: no conn to 0x%016llx group=0x%016llx subcmd=0x%02x — packet queued, no route",
|
||||
MD_ID, (unsigned long long)dst_node_id, (unsigned long long)group_id, (unsigned)data[0]);
|
||||
if (!inst || !data || !len || len >= UINT16_MAX) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "%s: invalid send bytes=%zu", MD_ID, len); return -1;
|
||||
}
|
||||
if (!topo_group_chat_allowed(inst, group_id, dst_node_id)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "%s: send denied group=%llx peer=%llx cmd=%u", MD_ID,
|
||||
(unsigned long long)group_id, (unsigned long long)dst_node_id, data[0]); return -1;
|
||||
}
|
||||
struct ll_entry* e = queue_entry_new(0);
|
||||
if (!e) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "%s: send entry allocation failed", MD_ID); return -1; }
|
||||
e->dgram = u_malloc(len + 1);
|
||||
if (!e->dgram) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "%s: send payload allocation failed", MD_ID); queue_entry_free(e); return -1; }
|
||||
e->dgram[0] = ETCP_RT_ID_MEDIA_DELIVERY;
|
||||
memcpy(e->dgram + 1, data, len); e->len = (uint16_t)(len + 1);
|
||||
int rc = etcp_route_send(inst, group_id, dst_node_id, e, force, 0);
|
||||
int rc = etcp_route_send(inst, group_id, dst_node_id, e, force, ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT);
|
||||
if (rc != 0) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "%s: md_send FAIL rc=%d dst=0x%016llx group=0x%016llx",
|
||||
MD_ID, rc, (unsigned long long)dst_node_id, (unsigned long long)group_id);
|
||||
@@ -574,30 +575,30 @@ static int md_send(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t dst_n
|
||||
|
||||
/* ── subcommand handlers ── */
|
||||
|
||||
static void md_handle_serve_reg(struct media_delivery_ctx* md, uint64_t from_node,
|
||||
static void md_handle_serve_reg(struct media_delivery_ctx* md, uint64_t group_id, uint64_t from_node,
|
||||
const uint8_t* data, size_t len) {
|
||||
if (len < sizeof(struct media_pkt_serve_reg)) return;
|
||||
struct media_pkt_serve_reg* pkt = (struct media_pkt_serve_reg*)data;
|
||||
const struct media_pkt_serve_reg* pkt = (const void*)data;
|
||||
if (len != sizeof(*pkt) || pkt->group_id != group_id) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "%s: SERVE_REG invalid size/group bytes=%zu group=%llx", MD_ID, len,
|
||||
(unsigned long long)group_id); return;
|
||||
}
|
||||
|
||||
if (!md->served_nodes) {
|
||||
md->served_nodes = queue_new(md->inst->ua, 64, offsetof(struct media_served_node, node_id), 8, "md_served");
|
||||
md->served_nodes = queue_new(md->inst->ua, 64, offsetof(struct media_served_node, node_id), 16, "md_served");
|
||||
if (!md->served_nodes) { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "%s: queue_new(served_nodes) failed", MD_ID); return; }
|
||||
}
|
||||
struct ll_entry* e = queue_find_data_by_index(md->served_nodes, &from_node);
|
||||
uint64_t key[2] = {from_node, group_id};
|
||||
struct ll_entry* e = queue_find_data_by_index(md->served_nodes, key);
|
||||
if (!e) {
|
||||
struct ll_entry* qe = queue_entry_new(sizeof(struct media_served_node));
|
||||
if (!qe) { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "%s: queue_entry_new(served_node) failed", MD_ID); return; }
|
||||
struct media_served_node* sn = (struct media_served_node*)qe->data;
|
||||
memset(sn, 0, sizeof(*sn));
|
||||
sn->node_id = from_node; sn->group_id = pkt->group_id;
|
||||
sn->node_id = from_node; sn->group_id = group_id;
|
||||
sn->joined_at = (int64_t)time(NULL);
|
||||
memcpy(qe->data, &from_node, 8);
|
||||
queue_data_put_with_index(md->served_nodes, qe);
|
||||
}
|
||||
|
||||
uint8_t ack[9] = { MEDIA_SUBCMD_SERVE_ACK, 0, 0 };
|
||||
memcpy(ack + 1, &pkt->group_id, 8);
|
||||
(void)from_node;
|
||||
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "%s: SERVE_REG node=0x%016llx group=%016llx",
|
||||
MD_ID, (unsigned long long)from_node, (unsigned long long)pkt->group_id);
|
||||
}
|
||||
@@ -886,6 +887,11 @@ static void stream_pump(void* arg) {
|
||||
struct media_block_source* source = sc->source;
|
||||
sc->pump = NULL;
|
||||
if (!md->initialized) { stream_free(sc); return; }
|
||||
if (!topo_group_chat_allowed(md->inst, sc->group_id, sc->dst_node_id)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "%s: TX revoked group=%llx peer=%llx", MD_ID,
|
||||
(unsigned long long)sc->group_id, (unsigned long long)sc->dst_node_id);
|
||||
stream_free(sc); return;
|
||||
}
|
||||
etcp_router_cancel_send_ready(md->inst, sc->group_id, sc->dst_node_id, ETCP_RT_ID_MEDIA_DELIVERY, &sc->waiter);
|
||||
for (int budget = 0; budget < 4; budget++) {
|
||||
if (!source->failed && sc->offset == source->available && !source->verified) return;
|
||||
@@ -1110,7 +1116,8 @@ static int md_group_role(struct media_delivery_ctx* md, uint64_t group_id, uint6
|
||||
struct TOPO_GROUP* g = md->inst->topo_groups ? topo_groups_find(md->inst->topo_groups, group_id) : NULL;
|
||||
if (!g || g->group_type != TOPO_GROUP_TYPE_CHAT || g->stopping || !g->channel_id[0]) return 0;
|
||||
char sql[256];
|
||||
sqlite3_snprintf(sizeof(sql), sql, "SELECT adm_tags FROM \"peers_%w\" WHERE node_id=?", g->channel_id);
|
||||
sqlite3_snprintf(sizeof(sql), sql,
|
||||
"SELECT adm_tags FROM \"peers_%w\" WHERE node_id=? AND record_state=0 AND source=0 AND deleted=0 AND admitted=1", g->channel_id);
|
||||
sqlite3_stmt* st = NULL;
|
||||
int rc = sqlite3_prepare_v2(md->db, sql, -1, &st, NULL);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)node_id);
|
||||
@@ -1201,7 +1208,8 @@ void media_delivery_refresh_roles(struct UTUN_INSTANCE* inst) {
|
||||
any = 1;
|
||||
md_seed_local(md, group->group_id);
|
||||
char sql[256];
|
||||
sqlite3_snprintf(sizeof(sql), sql, "SELECT node_id,adm_tags FROM \"peers_%w\" WHERE node_id!=?", group->channel_id);
|
||||
sqlite3_snprintf(sizeof(sql), sql,
|
||||
"SELECT node_id,adm_tags FROM \"peers_%w\" WHERE node_id!=? AND record_state=0 AND source=0 AND deleted=0 AND admitted=1", group->channel_id);
|
||||
sqlite3_stmt* st = NULL;
|
||||
int rc = sqlite3_prepare_v2(md->db, sql, -1, &st, NULL);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)md->self_node_id);
|
||||
@@ -1225,8 +1233,17 @@ static void md_reconcile_timer(void* arg) {
|
||||
|
||||
/* member_sync сообщает и изменение подписанных свойств, и BGP-события; REMOVE маршрута не удаляет участника. */
|
||||
static void md_on_props_changed(uint64_t node_id, const char* adm_tags, const char* channel_id, void* arg) {
|
||||
(void)node_id; (void)adm_tags; (void)channel_id;
|
||||
(void)adm_tags;
|
||||
struct media_delivery_ctx* md = arg;
|
||||
char* end = NULL;
|
||||
uint64_t group = channel_id ? strtoull(channel_id, &end, 10) : 0;
|
||||
if (!group || !end || *end) { DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "%s: invalid membership channel", MD_ID); return; }
|
||||
if (!sqlite3_get_autocommit(md->db)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "%s: access reconciliation deferred during SQL transaction", MD_ID); return;
|
||||
}
|
||||
etcp_router_revalidate_group(md->inst, group);
|
||||
if (node_id == md->self_node_id && !topo_group_chat_allowed(md->inst, group, node_id))
|
||||
media_delivery_cancel_channel(md->inst, group);
|
||||
media_delivery_refresh_roles(md->inst);
|
||||
}
|
||||
|
||||
@@ -1238,9 +1255,12 @@ static void md_on_conn_status(struct ETCP_CONN* conn, int status, void* arg) {
|
||||
switch (status) {
|
||||
case ETCP_CONN_STATUS_DOWN:
|
||||
case ETCP_CONN_STATUS_DELETE:
|
||||
if (md->served_nodes) {
|
||||
struct ll_entry* e = queue_find_data_by_index(md->served_nodes, &node_id);
|
||||
if (e) { queue_remove_data(md->served_nodes, e); queue_entry_free(e); }
|
||||
for (struct ll_entry* e = md->served_nodes ? md->served_nodes->head : NULL; e;) {
|
||||
struct ll_entry* next = e->next;
|
||||
if (((struct media_served_node*)e->data)->node_id == node_id) {
|
||||
queue_remove_data(md->served_nodes, e); queue_entry_free(e);
|
||||
}
|
||||
e = next;
|
||||
}
|
||||
break;
|
||||
default: break;
|
||||
@@ -1263,10 +1283,24 @@ static void md_etcp_recv(struct ETCP_CONN* conn, struct ll_entry* entry) {
|
||||
const uint8_t* data = entry->dgram;
|
||||
size_t len = entry->len;
|
||||
/* Единый формат router_deliver: [svc_id][src_node_id(8)][dst_node_id(8)][subcmd][...] */
|
||||
if (len < ROUTER_SVC_PAYLOAD_OFF + 1) { queue_dgram_free(entry); queue_entry_free(entry); return; }
|
||||
if (len < ROUTER_SVC_PAYLOAD_OFF) { queue_dgram_free(entry); queue_entry_free(entry); return; }
|
||||
uint64_t from_node, group_id;
|
||||
memcpy(&from_node, data + ROUTER_SVC_SRC_OFF, 8);
|
||||
memcpy(&group_id, data + ROUTER_SVC_GROUP_OFF, 8);
|
||||
if (len == ROUTER_SVC_PAYLOAD_OFF) {
|
||||
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "%s: channel closed group=%llx peer=%llx", MD_ID,
|
||||
(unsigned long long)group_id, (unsigned long long)from_node);
|
||||
uint64_t key[2] = {from_node, group_id};
|
||||
struct ll_entry* served = md->served_nodes ? queue_find_data_by_index(md->served_nodes, key) : NULL;
|
||||
if (served) { queue_remove_data(md->served_nodes, served); queue_entry_free(served); }
|
||||
for (struct stream_ctx* sc = md->streams; sc;) {
|
||||
struct stream_ctx* next = sc->next;
|
||||
if (sc->group_id == group_id && sc->dst_node_id == from_node) stream_free(sc);
|
||||
sc = next;
|
||||
}
|
||||
media_download_peer_closed(inst, group_id, from_node);
|
||||
queue_dgram_free(entry); queue_entry_free(entry); return;
|
||||
}
|
||||
uint8_t subcmd = data[ROUTER_SVC_PAYLOAD_OFF];
|
||||
data += ROUTER_SVC_PAYLOAD_OFF; len -= ROUTER_SVC_PAYLOAD_OFF;
|
||||
|
||||
@@ -1283,13 +1317,19 @@ static void md_etcp_recv(struct ETCP_CONN* conn, struct ll_entry* entry) {
|
||||
}
|
||||
switch (subcmd) {
|
||||
case MEDIA_SUBCMD_SERVE_REG:
|
||||
md_handle_serve_reg(md, from_node, data, len);
|
||||
md_handle_serve_reg(md, group_id, from_node, data, len);
|
||||
break;
|
||||
case MEDIA_SUBCMD_SERVE_LEAVE:
|
||||
if (len != sizeof(struct media_pkt_serve_leave)
|
||||
|| ((const struct media_pkt_serve_leave*)data)->group_id != group_id) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "%s: SERVE_LEAVE invalid size/group bytes=%zu group=%llx", MD_ID, len,
|
||||
(unsigned long long)group_id); break;
|
||||
}
|
||||
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "%s: SERVE_LEAVE from 0x%016llx",
|
||||
MD_ID, (unsigned long long)from_node);
|
||||
if (md->served_nodes) {
|
||||
struct ll_entry* e = queue_find_data_by_index(md->served_nodes, &from_node);
|
||||
uint64_t key[2] = {from_node, group_id};
|
||||
struct ll_entry* e = queue_find_data_by_index(md->served_nodes, key);
|
||||
if (e) { queue_remove_data(md->served_nodes, e); queue_entry_free(e); }
|
||||
}
|
||||
break;
|
||||
@@ -1422,6 +1462,10 @@ int media_delivery_init(struct UTUN_INSTANCE* inst) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "%s: failed to bind ETCP_RT_ID_MEDIA_DELIVERY", MD_ID);
|
||||
return -1;
|
||||
}
|
||||
if (etcp_router_require_crypto(inst, ETCP_RT_ID_MEDIA_DELIVERY, ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT) ||
|
||||
etcp_router_set_authorize(inst, ETCP_RT_ID_MEDIA_DELIVERY, topo_group_chat_allowed)) {
|
||||
etcp_router_unbind(inst, ETCP_RT_ID_MEDIA_DELIVERY); return -1;
|
||||
}
|
||||
|
||||
if (member_sync_add_props_cbk(inst, md_on_props_changed, md)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "%s: cannot subscribe to member roles", MD_ID);
|
||||
@@ -1525,7 +1569,7 @@ static int md_collect_supernodes(struct UTUN_INSTANCE* inst, uint64_t group_id,
|
||||
struct TOPO_GROUP* g = inst->topo_groups ? topo_groups_find(inst->topo_groups, group_id) : NULL;
|
||||
if (!g || g->group_type != TOPO_GROUP_TYPE_CHAT || !g->channel_id[0]) return 0;
|
||||
char sql[256];
|
||||
sqlite3_snprintf(sizeof(sql), sql, "SELECT node_id,adm_tags FROM \"peers_%w\" WHERE node_id!=?", g->channel_id);
|
||||
sqlite3_snprintf(sizeof(sql), sql, "SELECT node_id,adm_tags FROM \"peers_%w\" WHERE node_id!=? AND record_state=0 AND admitted=1", g->channel_id);
|
||||
sqlite3_stmt* st = NULL;
|
||||
int rc = sqlite3_prepare_v2(inst->md.db, sql, -1, &st, NULL);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)inst->node_id);
|
||||
@@ -1568,15 +1612,6 @@ void media_delivery_send_have_block(struct UTUN_INSTANCE* inst, uint64_t group_i
|
||||
}
|
||||
if (md_sql_finish(inst->md.db, st, rc, "read own announcement timestamp")) return;
|
||||
|
||||
uint8_t smsg[64]; size_t soff = 0;
|
||||
memcpy(smsg + soff, hb.block_id, 16); soff += 16;
|
||||
memcpy(smsg + soff, &hb.chunk, 4); soff += 4;
|
||||
memcpy(smsg + soff, &hb.timestamp, 8); soff += 8;
|
||||
uint64_t self = inst->node_id; memcpy(smsg + soff, &self, 8); soff += 8;
|
||||
if (sc_ed25519_sign(inst->my_ed25519_privkey, smsg, soff, hb.node_sign) != SC_OK) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "%s: Ed25519 sign failed for HAVE_BLOCK", MD_ID);
|
||||
return;
|
||||
}
|
||||
md_send(inst, group_id, dst_node_id, (const uint8_t*)&hb, sizeof(hb), 1);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ extern "C" {
|
||||
|
||||
struct UTUN_INSTANCE;
|
||||
|
||||
/* Общая отправка медиа из delivery/download: копирует payload, проверяет допуск, подписывает и шифрует.
|
||||
* force разрешает превышение порога send_q; 0 — принято, -1 — ошибка. Только uasync. */
|
||||
int md_send(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer, const uint8_t* data, size_t len, int force);
|
||||
|
||||
// ── константы ──
|
||||
|
||||
#define MEDIA_REPL_TIMEOUT_TB 20000 // начальный таймаут 2s (0.1ms)
|
||||
@@ -66,9 +70,8 @@ struct md_file_load {
|
||||
* Верхняя граница выдачи — sqlite_sequence, включая удалённые ID и промежутки других групп. */
|
||||
|
||||
struct media_served_node {
|
||||
struct ll_entry ll; // индекс по node_id (8 байт)
|
||||
uint64_t node_id;
|
||||
uint64_t group_id;
|
||||
uint64_t node_id; // начало составного ключа (узел, группа), 16 байт
|
||||
uint64_t group_id; // CHAT-группа регистрации
|
||||
int64_t joined_at; // timestamp регистрации
|
||||
};
|
||||
|
||||
@@ -110,7 +113,7 @@ struct media_delivery_ctx {
|
||||
int query_timeout_tb; // Дедлайн одной попытки QUERY, независимо от входящих чанков; 0 — 2с.
|
||||
int dl_max_attempts; // попыток на блок (по умолч. MEDIA_DL_MAX_ATTEMPTS)
|
||||
sqlite3* db; // = inst->topo_sqlite_db (кешируем для удобства)
|
||||
struct ll_queue* served_nodes; // media_served_node — только суперузел
|
||||
struct ll_queue* served_nodes; // media_served_node, индекс (node_id, group_id); регистрации независимы по группам
|
||||
struct ll_queue* super_peers; // media_super_peer — только суперузел
|
||||
struct ll_queue* downloads; // media_download — активные загрузки
|
||||
struct ll_queue* block_sources; // media_block_source, заимствованный индекс по block_id.
|
||||
@@ -147,7 +150,7 @@ void media_delivery_announce_media(struct UTUN_INSTANCE* inst, uint64_t group_id
|
||||
const uint8_t* media_id, const uint8_t* block_ids,
|
||||
int num_blocks);
|
||||
|
||||
/* Отправить один HAVE_BLOCK суперузлу (подпись Ed25519 + md_send). Общий helper
|
||||
/* Отправить один HAVE_BLOCK суперузлу через подписанный/зашифрованный router. Общий helper
|
||||
* для announce_media / announce_local_blocks / media_download. */
|
||||
void media_delivery_send_have_block(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t dst_node_id,
|
||||
const uint8_t* media_id, const uint8_t* block_id, uint32_t chunk);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
/* media_delivery_proto — wire-структуры и подкоманды доставки блоков медиа CHAT-группы.
|
||||
* Описывает запросы держателей, чанки, подтверждения наличия и репликацию между суперузлами.
|
||||
* Это формат payload сервиса router, без группового маршрутизационного заголовка; обработчики — media_delivery/download.
|
||||
* Сетевые данные требуют SIGNED|ENCRYPTED, control — SIGNED. Локальный узел и конечный peer должны быть
|
||||
* активными членами CHAT-группы (source=0, deleted=0, admitted=1); наличие общего ETCP не даёт доступа.
|
||||
* QUERY и передача блоков доступны обычным участникам; HAVE_BLOCK/PROCESSING принимает суперузел,
|
||||
* SYNC/CHANGED требуют роли суперузла с обеих сторон. Повторённый group_id должен совпадать с router.
|
||||
* Числа SYNC кодируются little endian; остальные packed-поля — native byte order текущего формата.
|
||||
* SERVE_REG/LEAVE меняют только вспомогательный served_nodes; текущий загрузчик их не посылает.
|
||||
* SERVE_ACK сейчас не отправляется, входящие SERVE_ACK/HAVE_BLOCK_ACK игнорируются; ожидания ACK и его retry нет. */
|
||||
@@ -120,7 +124,7 @@ struct media_pkt_have_block {
|
||||
uint8_t block_id[16];
|
||||
uint32_t chunk;
|
||||
int64_t timestamp; // unix epoch
|
||||
uint8_t node_sign[64]; // Ed25519 подпись
|
||||
// Отправитель, группа и все поля защищены обязательной подписью router.
|
||||
};
|
||||
|
||||
#define MEDIA_HAVE_BLOCK_SIZE (sizeof(struct media_pkt_have_block))
|
||||
|
||||
@@ -110,28 +110,6 @@ static int md_mkdir_parent(const char* filepath) {
|
||||
|
||||
/* ── send helpers ── */
|
||||
|
||||
static int md_dl_send(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t dst, const uint8_t* data, size_t len) {
|
||||
if (!inst || !data || !len || len >= UINT16_MAX) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "%s: invalid send bytes=%zu", MDL_ID, len); return -1;
|
||||
}
|
||||
{ struct TOPO_GROUP* grp = inst->topo_groups ? topo_groups_find(inst->topo_groups, group_id) : NULL;
|
||||
struct ETCP_CONN* gc = grp ? topo_group_find_conn_for_node(grp, dst) : NULL;
|
||||
if (!gc)
|
||||
DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "%s: no conn to 0x%016llx subcmd=0x%02x — packet queued, no route",
|
||||
MDL_ID, (unsigned long long)dst, (unsigned)data[0]);
|
||||
}
|
||||
struct ll_entry* e = queue_entry_new(0);
|
||||
if (!e) { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "%s: queue_entry_new failed for send to 0x%016llx", MDL_ID, (unsigned long long)dst); return -1; }
|
||||
e->dgram = u_malloc(len + 1);
|
||||
if (!e->dgram) { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "%s: u_malloc(%zu) failed for send to 0x%016llx", MDL_ID, len + 1, (unsigned long long)dst); queue_entry_free(e); return -1; }
|
||||
e->dgram[0] = ETCP_RT_ID_MEDIA_DELIVERY;
|
||||
memcpy(e->dgram + 1, data, len); e->len = (uint16_t)(len + 1);
|
||||
int rc = etcp_route_send(inst, group_id, dst, e, 1, 0);
|
||||
if (rc != 0) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "%s: send failed rc=%d node=%016llx group=%016llx",
|
||||
MDL_ID, rc, (unsigned long long)dst, (unsigned long long)group_id);
|
||||
return rc;
|
||||
}
|
||||
|
||||
static int md_dl_send_block_req(struct UTUN_INSTANCE* inst, uint64_t dst, struct media_download* dl, int bi) {
|
||||
struct media_pkt_block_req req;
|
||||
memset(&req, 0, sizeof(req));
|
||||
@@ -144,7 +122,7 @@ static int md_dl_send_block_req(struct UTUN_INSTANCE* inst, uint64_t dst, struct
|
||||
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "%s: BLOCK_REQ to 0x%016llx blk=%d/%d block_id=%02x%02x%02x%02x...",
|
||||
MDL_ID, (unsigned long long)dst, bi, dl->num_blocks,
|
||||
dl->blocks[bi].block_id[0], dl->blocks[bi].block_id[1], dl->blocks[bi].block_id[2], dl->blocks[bi].block_id[3]);
|
||||
return md_dl_send(inst, dl->group_id, dst, (const uint8_t*)&req, sizeof(req));
|
||||
return md_send(inst, dl->group_id, dst, (const uint8_t*)&req, sizeof(req), 1);
|
||||
}
|
||||
|
||||
/* ── forward decl ── */
|
||||
@@ -178,19 +156,9 @@ static void md_dl_send_block_processing(struct UTUN_INSTANCE* inst, struct media
|
||||
hb.chunk = (uint32_t)bi;
|
||||
hb.timestamp = (int64_t)time(NULL);
|
||||
|
||||
uint8_t smsg[64]; size_t soff = 0;
|
||||
memcpy(smsg + soff, dl->blocks[bi].block_id, 16); soff += 16;
|
||||
memcpy(smsg + soff, &hb.chunk, 4); soff += 4;
|
||||
memcpy(smsg + soff, &hb.timestamp, 8); soff += 8;
|
||||
uint64_t self = inst->node_id; memcpy(smsg + soff, &self, 8); soff += 8;
|
||||
if (sc_ed25519_sign(inst->my_ed25519_privkey, smsg, soff, hb.node_sign) != SC_OK) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "%s: Ed25519 sign failed for BLOCK_PROCESSING", MDL_ID);
|
||||
return;
|
||||
}
|
||||
|
||||
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "%s: BLOCK_PROCESSING to super 0x%016llx block=%d",
|
||||
MDL_ID, (unsigned long long)super, bi);
|
||||
md_dl_send(inst, dl->group_id, super, (const uint8_t*)&hb, sizeof(hb));
|
||||
md_send(inst, dl->group_id, super, (const uint8_t*)&hb, sizeof(hb), 1);
|
||||
}
|
||||
|
||||
/* ── запрос блока у текущего держателя ── */
|
||||
@@ -201,7 +169,7 @@ static void md_dl_end_attempt(struct media_download* dl, int bi) {
|
||||
struct media_pkt_cancel can = {.subcmd = MEDIA_SUBCMD_CANCEL, .chunk = (uint32_t)bi};
|
||||
memcpy(can.media_id, dl->media_id, 16); memcpy(can.block_id, b->block_id, 16);
|
||||
memcpy(can.transfer_id, b->transfer_id, 16);
|
||||
md_dl_send(dl->inst, dl->group_id, b->holders[b->holder_idx], (const uint8_t*)&can, sizeof(can));
|
||||
md_send(dl->inst, dl->group_id, b->holders[b->holder_idx], (const uint8_t*)&can, sizeof(can), 1);
|
||||
}
|
||||
if (b->source) {
|
||||
if (!b->source->verified) md_block_abort(b->source);
|
||||
@@ -217,6 +185,10 @@ static int md_dl_request_block(struct media_download* dl, int bi) {
|
||||
b->state = MD_BLK_WAIT; b->wait_reason = MD_WAIT_SEARCH; return 0;
|
||||
}
|
||||
uint64_t holder = b->holders[b->holder_idx];
|
||||
if (!topo_group_chat_allowed(dl->inst, dl->group_id, holder)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "%s: block holder denied group=%llx holder=%llx", MDL_ID,
|
||||
(unsigned long long)dl->group_id, (unsigned long long)holder); return -1;
|
||||
}
|
||||
struct media_download_peer* p = md_dl_get_peer(dl, holder);
|
||||
if (!p || p->pending_event) { b->state = MD_BLK_WAIT; b->wait_reason = MD_WAIT_SLOT; return 0; }
|
||||
if (md_dl_reset_block_file(dl, bi)) { dl->err = -1; return -1; }
|
||||
@@ -392,6 +364,10 @@ void md_dl_conn_cb(struct CONN_MGR_HANDLE* h, uint64_t node_id, uint64_t group_i
|
||||
static void md_dl_pump_cb(void* arg) {
|
||||
struct media_download* dl = arg;
|
||||
dl->pump_timer = NULL;
|
||||
if (!topo_group_chat_allowed(dl->inst, dl->group_id, dl->inst->node_id)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "%s: download membership revoked group=%llx", MDL_ID, (unsigned long long)dl->group_id);
|
||||
md_dl_finish(dl, -1); return;
|
||||
}
|
||||
int failed = dl->err != 0;
|
||||
for (int pi = 0; pi < dl->num_peers; pi++) {
|
||||
struct media_download_peer* p = &dl->peers[pi];
|
||||
@@ -429,7 +405,7 @@ static void md_dl_collect_supernodes(struct UTUN_INSTANCE* inst, struct media_do
|
||||
struct TOPO_GROUP* g = inst->topo_groups ? topo_groups_find(inst->topo_groups, dl->group_id) : NULL;
|
||||
if (g && g->group_type == TOPO_GROUP_TYPE_CHAT && g->channel_id[0] && inst->topo_sqlite_db) {
|
||||
char sql[256];
|
||||
snprintf(sql, sizeof(sql), "SELECT node_id FROM peers_%s WHERE node_type=4 ORDER BY node_id", g->channel_id);
|
||||
snprintf(sql, sizeof(sql), "SELECT node_id FROM peers_%s WHERE record_state=0 AND node_type=4 ORDER BY node_id", g->channel_id);
|
||||
sqlite3_stmt* st = NULL;
|
||||
int rc = sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL);
|
||||
if (rc == SQLITE_OK) {
|
||||
@@ -493,7 +469,7 @@ static int md_dl_query_pump(struct media_download* dl, uint64_t now_tb) {
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "%s: QUERY peer=%016llx source=%d blocks=%d id=%02x%02x deadline=%llu",
|
||||
MDL_ID, (unsigned long long)dl->query_node, source, dl->query_count, dl->query_id[0], dl->query_id[1],
|
||||
(unsigned long long)dl->query_deadline_tb);
|
||||
if (!md_dl_send(dl->inst, dl->group_id, dl->query_node, packet, MEDIA_QUERY_HDR_SIZE + (size_t)q->num_blocks * 16))
|
||||
if (!md_send(dl->inst, dl->group_id, dl->query_node, packet, MEDIA_QUERY_HDR_SIZE + (size_t)q->num_blocks * 16, 1))
|
||||
return 0;
|
||||
dl->query_deadline_tb = 0; // Отказ отправки: следующий источник той же пачки.
|
||||
}
|
||||
@@ -803,6 +779,10 @@ int media_download_start(struct UTUN_INSTANCE* inst, uint64_t group_id,
|
||||
}
|
||||
struct media_delivery_ctx* md = &inst->md;
|
||||
if (!md->initialized) { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "%s: start before media_delivery init", MDL_ID); return -1; }
|
||||
if (!topo_group_chat_allowed(inst, group_id, inst->node_id)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "%s: start denied outside active CHAT membership group=%llx", MDL_ID,
|
||||
(unsigned long long)group_id); return -1;
|
||||
}
|
||||
|
||||
char location[MEDIA_PATH_MAX], normalized[MEDIA_PATH_MAX];
|
||||
uint8_t author_pubkey[32];
|
||||
@@ -889,6 +869,26 @@ int media_download_start(struct UTUN_INSTANCE* inst, uint64_t group_id,
|
||||
return 0;
|
||||
}
|
||||
|
||||
void media_download_peer_closed(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer) {
|
||||
for (struct ll_entry* e = inst->md.downloads ? inst->md.downloads->head : NULL; e; e = e->next) {
|
||||
struct media_download* dl = (struct media_download*)e->data;
|
||||
if (dl->group_id != group) continue;
|
||||
int affected = dl->query_deadline_tb && dl->query_node == peer;
|
||||
if (affected) dl->query_deadline_tb = 0;
|
||||
for (int i = 0; i < dl->num_peers; i++) {
|
||||
struct media_download_peer* p = &dl->peers[i];
|
||||
if (p->node_id != peer) continue;
|
||||
if (p->cm_handle) { conn_mgr_close(p->cm_handle); p->cm_handle = NULL; }
|
||||
p->connected = 0; p->pending_event = CONN_EVENT_DOWN + 1; affected = 1;
|
||||
}
|
||||
if (!affected) continue;
|
||||
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "%s: router closed group=%llx peer=%llx; defer download failover", MDL_ID,
|
||||
(unsigned long long)group, (unsigned long long)peer);
|
||||
if (!dl->pump_timer) dl->pump_timer = uasync_call_soon(inst->ua, dl, md_dl_pump_cb);
|
||||
if (!dl->pump_timer) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "%s: cannot schedule closed peer", MDL_ID); dl->err = -1; }
|
||||
}
|
||||
}
|
||||
|
||||
int media_download_cancel(struct UTUN_INSTANCE* inst,
|
||||
const uint8_t* media_id, const uint8_t* block_id, uint32_t chunk) {
|
||||
(void)block_id; (void)chunk;
|
||||
|
||||
@@ -136,6 +136,9 @@ int media_download_start(struct UTUN_INSTANCE* inst, uint64_t group_id,
|
||||
int media_download_cancel(struct UTUN_INSTANCE* inst,
|
||||
const uint8_t* media_id, const uint8_t* block_id, uint32_t chunk);
|
||||
|
||||
/* Router закрыл group/peer: отменить его CM, отложить failover активных попыток; callbacks загрузки здесь не вызываются. */
|
||||
void media_download_peer_closed(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer);
|
||||
|
||||
/* ── handlers for incoming data, called from md_etcp_recv dispatch ── */
|
||||
/* Полный ответ пачки. group_id/from_node — из router; несовпадающие/истёкшие попытки игнорируются.
|
||||
* Повреждённый пакет не меняет состояние; ошибка поиска продолжает обход источников.
|
||||
|
||||
@@ -28,6 +28,13 @@
|
||||
Производный `node_type` и общий диагностический флаг `md.is_supernode` не дают полномочий.
|
||||
Группа и отправитель берутся только из router; группа не дублируется в SYNC payload.
|
||||
|
||||
Для всего MEDIA_DELIVERY router требует подпись и шифрование данных, подпись управляющих
|
||||
пакетов и активное членство обоих конечных узлов (`source=0, deleted=0, admitted=1`).
|
||||
Проверка не зависит от общего ETCP или членства промежуточного узла. QUERY допускает
|
||||
обычного участника, включая автора файла. Отзыв членства закрывает каналы этой группы,
|
||||
очищает очереди, прекращает выдачи и запускает смену держателя в затронутых загрузках.
|
||||
При потере собственного членства работа загрузок группы отменяется. Другие группы сохраняются.
|
||||
|
||||
Перечитывание ролей выполняется при init, событиях member_sync и каждые 30 секунд.
|
||||
Так обнаруживаются и группы, созданные после init. Chat core и media_index должны
|
||||
быть готовы до media_delivery_init; отказ подписки на роли завершает init ошибкой.
|
||||
|
||||
@@ -37,6 +37,7 @@ struct mf_job {
|
||||
struct mf_message request, response;
|
||||
struct queue_waiter_handle waiter;
|
||||
int state; /* 0 queued, 1 worker, 2 reply; command=0 — sweep */
|
||||
int cancelled; /* Uasync: результат worker больше не выдаётся; память живёт до done. */
|
||||
};
|
||||
struct mf_local {
|
||||
struct mf_local* next;
|
||||
@@ -109,7 +110,7 @@ int member_files_member(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t nod
|
||||
(unsigned long long)group, (unsigned long long)node, storage); return 0;
|
||||
}
|
||||
char sql[256];
|
||||
snprintf(sql, sizeof(sql), "SELECT adm_tags,source,adm_tags_sig,deleted FROM \"peers_%llu\" WHERE node_id=?",
|
||||
snprintf(sql, sizeof(sql), "SELECT adm_tags,source,adm_tags_sig,deleted FROM \"peers_%llu\" WHERE node_id=? AND record_state=0 AND admitted=1",
|
||||
(unsigned long long)group);
|
||||
sqlite3_stmt* st = NULL;
|
||||
if (sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL) != SQLITE_OK) {
|
||||
@@ -148,8 +149,18 @@ int mf_server_allowed(struct member_files* f, uint64_t group, uint64_t peer, con
|
||||
return allowed;
|
||||
}
|
||||
|
||||
/* Клиент сохраняет доступ к группе, роли storage и владельцу каталога до выдачи результата. */
|
||||
static int mf_client_allowed(struct member_files* f, uint64_t group, uint64_t server, const struct mf_message* q) {
|
||||
return member_files_member(f->inst, group, f->inst->node_id, 0) && member_files_member(f->inst, group, server, 1) &&
|
||||
(!q->owner || member_files_member(f->inst, group, q->owner, 0));
|
||||
}
|
||||
|
||||
/* Router подтверждает доставку отдельно; здесь отправка всегда соблюдает порог очереди. */
|
||||
int mf_send(struct member_files* f, uint64_t group, uint64_t peer, const struct mf_message* m) {
|
||||
if (!topo_group_chat_allowed(f->inst, group, peer)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: send denied group=%llx peer=%llx cmd=%u id=%llu",
|
||||
(unsigned long long)group, (unsigned long long)peer, m->command, (unsigned long long)m->request_id); return -1;
|
||||
}
|
||||
if (peer == f->inst->node_id) {
|
||||
if (f->local_count >= MF_MAX_JOBS) return 1;
|
||||
struct mf_local* local = u_calloc(1, sizeof(*local));
|
||||
@@ -197,6 +208,11 @@ static void mf_pending_finish(struct mf_pending* p, const struct mf_message* res
|
||||
static void mf_pending_pump(void* arg) {
|
||||
struct mf_pending* p = arg; p->pump = NULL;
|
||||
if (p->files->closing) return;
|
||||
if (!mf_client_allowed(p->files, p->group, p->server, &p->request)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: pending access revoked group=%llx server=%llx id=%llu",
|
||||
(unsigned long long)p->group, (unsigned long long)p->server, (unsigned long long)p->request.request_id);
|
||||
mf_pending_finish(p, NULL, MF_ACCESS_DENIED); return;
|
||||
}
|
||||
if (p->retry) { uasync_cancel_timeout(p->files->inst->ua, p->retry); p->retry = NULL; }
|
||||
if (get_time_tb() - p->started >= MF_REPLAY_TB) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: request expired before send id=%llu cmd=%s",
|
||||
@@ -241,7 +257,7 @@ struct mf_pending* member_files_request(struct UTUN_INSTANCE* inst, uint64_t gro
|
||||
request->command, request->status, request->data_len); return NULL;
|
||||
}
|
||||
if (!f || f->closing || !request || !cb || f->pending_count >= MF_MAX_PENDING ||
|
||||
!member_files_member(inst, group, inst->node_id, 0) || !member_files_member(inst, group, server, 1)) {
|
||||
!mf_client_allowed(f, group, server, request)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: client request denied group=%llu server=%llu",
|
||||
(unsigned long long)group, (unsigned long long)server); return NULL;
|
||||
}
|
||||
@@ -289,6 +305,18 @@ static void mf_job_free(struct mf_job* j) {
|
||||
mf_receive_resume(f);
|
||||
}
|
||||
|
||||
/* Worker владеет снимком до done; отмена очереди не вызывает completion синхронно. */
|
||||
static void mf_job_cancel(struct mf_job* j) {
|
||||
struct member_files* f = j->files;
|
||||
j->cancelled = 1;
|
||||
etcp_router_cancel_send_ready(f->inst, j->group, j->peer, ETCP_RT_ID_MEMBER_FILES, &j->waiter);
|
||||
unsigned queued = j == f->worker ? mf_worker_cancel(f->disk, j) : 0;
|
||||
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "member_files: cancel job group=%llx peer=%llx id=%llu cmd=%s state=%d disk_queued=%u",
|
||||
(unsigned long long)j->group, (unsigned long long)j->peer, (unsigned long long)j->request.request_id,
|
||||
mf_command_text(j->request.command), j->state, queued);
|
||||
if (j != f->worker) mf_job_free(j);
|
||||
}
|
||||
|
||||
static void mf_server_deferred(void* arg) {
|
||||
struct member_files* f = arg; f->pump = NULL; mf_server_pump(f);
|
||||
}
|
||||
@@ -314,6 +342,8 @@ static void mf_server_ready(struct ll_queue* q, void* arg) {
|
||||
|
||||
static void mf_server_work(void* arg) {
|
||||
struct mf_job* j = arg;
|
||||
if (j->request.command) DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "member_files: disk command start group=%llx peer=%llx id=%llu cmd=%u",
|
||||
(unsigned long long)j->group, (unsigned long long)j->peer, (unsigned long long)j->request.request_id, j->request.command);
|
||||
if (j->request.command) mf_storage_execute(j->files->storage, j->group, j->peer, &j->request, &j->response);
|
||||
else mf_storage_sweep(j->files->storage, get_time_tb());
|
||||
}
|
||||
@@ -321,7 +351,7 @@ static void mf_server_work(void* arg) {
|
||||
static void mf_server_done(void* arg, int error) {
|
||||
struct mf_job* j = arg;
|
||||
struct member_files* f = j->files; f->worker = NULL;
|
||||
if (f->closing || !j->request.command) { mf_job_free(j); if (!f->closing) mf_server_pump(f); return; }
|
||||
if (f->closing || j->cancelled || !j->request.command) { mf_job_free(j); if (!f->closing) mf_server_pump(f); return; }
|
||||
if (error) { j->response = j->request; j->response.kind = MF_RESPONSE; j->response.status = MF_BUSY; j->response.data_len = 0; }
|
||||
if (error) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: disk job failed id=%llu cmd=%s error=%d jobs=%u",
|
||||
(unsigned long long)j->request.request_id, mf_command_text(j->request.command), error, f->job_count);
|
||||
@@ -334,7 +364,8 @@ static void mf_roots(struct mf_job* j) {
|
||||
*r = j->request; r->kind = MF_RESPONSE; r->type = MF_DIRECTORY; r->data_len = 0; r->more = 0;
|
||||
if (*r->path || r->command != MF_LIST) { r->status = MF_INVALID_PATH; return; }
|
||||
char sql[256];
|
||||
snprintf(sql, sizeof(sql), "SELECT node_id,join_ts,update_ts,adm_tags FROM \"peers_%llu\" WHERE deleted=0 AND source=0 ORDER BY node_id",
|
||||
snprintf(sql, sizeof(sql), "SELECT node_id,join_ts,update_ts,adm_tags FROM \"peers_%llu\""
|
||||
" WHERE record_state=0 AND deleted=0 AND source=0 AND admitted=1 ORDER BY node_id",
|
||||
(unsigned long long)j->group);
|
||||
sqlite3_stmt* st = NULL; EVP_MD_CTX* hash = EVP_MD_CTX_new();
|
||||
if (!hash || EVP_DigestInit_ex(hash, EVP_sha256(), NULL) != 1 ||
|
||||
@@ -396,6 +427,8 @@ static void mf_server_pump(struct member_files* f) {
|
||||
j->response.data_len = 0; j->state = 2; mf_server_pump(f); return;
|
||||
}
|
||||
j->state = 1; f->worker = j;
|
||||
if (j->request.command) DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "member_files: disk command queued group=%llx peer=%llx id=%llu cmd=%u jobs=%u",
|
||||
(unsigned long long)j->group, (unsigned long long)j->peer, (unsigned long long)j->request.request_id, j->request.command, f->job_count);
|
||||
mf_worker_submit(f->disk, mf_server_work, j, mf_server_done, j);
|
||||
}
|
||||
|
||||
@@ -429,6 +462,10 @@ static void mf_tick(void* arg) {
|
||||
|
||||
/* Общая обработка проверенного сетевого сообщения или команды внутри экземпляра. */
|
||||
static void mf_dispatch(struct member_files* f, uint64_t group, uint64_t peer, const struct mf_message* message) {
|
||||
if (!topo_group_chat_allowed(f->inst, group, peer)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: delivery denied group=%llx peer=%llx cmd=%u id=%llu",
|
||||
(unsigned long long)group, (unsigned long long)peer, message->command, (unsigned long long)message->request_id); return;
|
||||
}
|
||||
struct mf_message m = *message;
|
||||
if (m.kind == MF_REQUEST && f->storage_status != MF_OK) {
|
||||
int status = mf_server_allowed(f, group, peer, &m) ? f->storage_status : MF_ACCESS_DENIED;
|
||||
@@ -447,7 +484,7 @@ static void mf_dispatch(struct member_files* f, uint64_t group, uint64_t peer, c
|
||||
if (p->request.command != m.command || p->request.owner != m.owner || strcmp(p->request.path, m.path)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: mismatched response id=%llu", (unsigned long long)m.request_id); return;
|
||||
}
|
||||
if (!member_files_member(f->inst, group, peer, 1)) { m.status = MF_ACCESS_DENIED; m.data_len = 0; }
|
||||
if (!mf_client_allowed(f, group, peer, &p->request)) { m.status = MF_ACCESS_DENIED; m.data_len = 0; }
|
||||
if (m.status == MF_BUSY && (m.command == MF_COMMIT || (m.command >= MF_MKDIR && m.command <= MF_REMOVE_PATH))) {
|
||||
if (m.cursor > MF_REPLAY_TB) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: invalid busy hint id=%llu delay_tb=%llu; using5s",
|
||||
(unsigned long long)m.request_id, (unsigned long long)m.cursor);
|
||||
@@ -500,7 +537,10 @@ static void mf_recv(struct ETCP_CONN* conn, struct ll_entry* e) {
|
||||
}
|
||||
uint64_t group, peer;
|
||||
memcpy(&group, e->dgram + ROUTER_SVC_GROUP_OFF, 8); memcpy(&peer, e->dgram + ROUTER_SVC_SRC_OFF, 8);
|
||||
if (e->len == ROUTER_SVC_PAYLOAD_OFF) { mf_transfers_peer_down(f, group, peer); goto out; }
|
||||
if (e->len == ROUTER_SVC_PAYLOAD_OFF) {
|
||||
mf_transfers_peer_down(f, group, peer);
|
||||
member_files_revalidate_members(f->inst, group); goto out;
|
||||
}
|
||||
if ((e->dgram[ROUTER_SVC_FLAGS_OFF] & (ROUTER_FLAG_SIGNED | ROUTER_FLAG_ENCRYPTED)) !=
|
||||
(ROUTER_FLAG_SIGNED | ROUTER_FLAG_ENCRYPTED)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: unsigned/unencrypted packet peer=%llu", (unsigned long long)peer); goto out;
|
||||
@@ -544,6 +584,11 @@ int member_files_init(struct UTUN_INSTANCE* inst) {
|
||||
mf_storage_destroy(f->storage); mf_worker_destroy(f->disk); mf_worker_unreserve(f->cleanup); u_free(f); return -1;
|
||||
}
|
||||
inst->member_files = f;
|
||||
if (etcp_router_require_crypto(inst, ETCP_RT_ID_MEMBER_FILES, ROUTER_FLAG_SIGNED | ROUTER_FLAG_ENCRYPTED) ||
|
||||
etcp_router_set_authorize(inst, ETCP_RT_ID_MEMBER_FILES, topo_group_chat_allowed)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "member_files: router access policy initialization failed");
|
||||
member_files_destroy(inst); return -1;
|
||||
}
|
||||
f->storage_status = MF_BUSY;
|
||||
etcp_router_set_receive_ready(inst, ETCP_RT_ID_MEMBER_FILES, mf_receive_ready);
|
||||
f->tick = uasync_set_timeout(inst->ua, 10000, f, mf_tick, "member_files");
|
||||
@@ -565,7 +610,7 @@ void member_files_quiesce(struct UTUN_INSTANCE* inst) {
|
||||
while (f->pending) member_files_cancel(f->pending);
|
||||
while (f->locals) mf_local_free(f->locals);
|
||||
struct mf_job* j = f->jobs;
|
||||
while (j) { struct mf_job* next = j->next; if (j != f->worker) mf_job_free(j); j = next; }
|
||||
while (j) { struct mf_job* next = j->next; mf_job_cancel(j); j = next; }
|
||||
etcp_router_unbind(inst, ETCP_RT_ID_MEMBER_FILES);
|
||||
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "member_files: quiesce workers=%d transfers=%u", f->worker != NULL, f->transfer_count);
|
||||
}
|
||||
@@ -597,8 +642,43 @@ void member_files_cancel_group(struct UTUN_INSTANCE* inst, uint64_t group) {
|
||||
if (!found) break;
|
||||
member_files_cancel(found);
|
||||
}
|
||||
struct mf_job* j = f->jobs;
|
||||
while (j) { struct mf_job* next = j->next; if (j->group == group && j != f->worker) mf_job_free(j); j = next; }
|
||||
for (;;) {
|
||||
struct mf_job* found = NULL;
|
||||
for (struct mf_job* j = f->jobs; j; j = j->next) if (j->group == group && !j->cancelled) { found = j; break; }
|
||||
if (!found) break;
|
||||
mf_job_cancel(found);
|
||||
}
|
||||
}
|
||||
|
||||
/* Поиск повторяется после каждого callback/resume: он может менять остальные запросы. */
|
||||
void member_files_revalidate_members(struct UTUN_INSTANCE* inst, uint64_t group) {
|
||||
struct member_files* f = inst ? inst->member_files : NULL;
|
||||
if (!f || f->closing) return;
|
||||
for (;;) {
|
||||
struct mf_job* denied = NULL;
|
||||
for (struct mf_job* j = f->jobs; j; j = j->next)
|
||||
if (j->group == group && j->request.command && !j->cancelled && !mf_server_allowed(f, group, j->peer, &j->request)) {
|
||||
denied = j; break;
|
||||
}
|
||||
if (!denied) break;
|
||||
mf_job_cancel(denied);
|
||||
}
|
||||
mf_transfers_revalidate(f, group);
|
||||
for (;;) {
|
||||
struct mf_pending* denied = NULL;
|
||||
for (struct mf_pending* p = f->pending; p; p = p->next)
|
||||
if (p->group == group && !mf_client_allowed(f, group, p->server, &p->request)) { denied = p; break; }
|
||||
if (!denied) break;
|
||||
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "member_files: revoke request group=%llx server=%llx id=%llu",
|
||||
(unsigned long long)group, (unsigned long long)denied->server, (unsigned long long)denied->request.request_id);
|
||||
mf_pending_finish(denied, NULL, MF_ACCESS_DENIED);
|
||||
}
|
||||
struct mf_local* local = f->locals;
|
||||
while (local) {
|
||||
struct mf_local* next = local->next;
|
||||
if (local->group == group && !topo_group_chat_allowed(inst, group, inst->node_id)) mf_local_free(local);
|
||||
local = next;
|
||||
}
|
||||
}
|
||||
|
||||
static void mf_gui_done(void* arg, const struct mf_message* response) {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* Все API — uasync. Запрос копируется; callback получает заимствованный ответ, действительный только до возврата.
|
||||
* Чат-сервис вызывает init; quiesce запрещает новые команды и отменяет producers; destroy вне callbacks ждёт
|
||||
* собственный дисковый поток до удаления CHAT-групп. Cancel не удаляет опубликованные файлы.
|
||||
* Отзыв отменяет ожидающее I/O; начатая команда завершается, её данные отозванному peer не выдаются.
|
||||
* GUI-команды используют trampolines; результат — CHAT_EVT_MEMBER_FILES_RESPONSE/TRANSFER. */
|
||||
#ifndef MEMBER_FILES_H
|
||||
#define MEMBER_FILES_H
|
||||
@@ -29,6 +30,8 @@ void member_files_destroy(struct UTUN_INSTANCE* inst);
|
||||
|
||||
// Отменить свои запросы/передачи одной группы перед её удалением; дерево storage этим не удаляется.
|
||||
void member_files_cancel_group(struct UTUN_INSTANCE* inst, uint64_t group);
|
||||
// После commit состава/ролей: отменить утратившие права запросы, задания и передачи только указанной группы.
|
||||
void member_files_revalidate_members(struct UTUN_INSTANCE* inst, uint64_t group);
|
||||
|
||||
// ── Команды файлового сервера ──
|
||||
|
||||
|
||||
@@ -37,6 +37,8 @@ int mf_transfer_receive_ready(struct member_files* files, uint64_t group, uint64
|
||||
void mf_transfer_dispatch(struct member_files* files, uint64_t group, uint64_t peer, const struct mf_message* message);
|
||||
/* Завершить потоки пары с IO_ERROR при reset/close router; прочие пары не затрагиваются. */
|
||||
void mf_transfers_peer_down(struct member_files* files, uint64_t group, uint64_t peer);
|
||||
/* Отменить передачи группы с отозванным членством/ролью/owner; начатый I/O завершается перед обязательной очисткой. */
|
||||
void mf_transfers_revalidate(struct member_files* files, uint64_t group);
|
||||
/* Планировать готовые pumps / обработать прогресс и таймауты; все функции вызываются в uasync. */
|
||||
void mf_transfers_wake(struct member_files* files);
|
||||
void mf_transfers_tick(struct member_files* files, uint64_t now);
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
#define TRANSFER_MAX 16
|
||||
#define TRANSFER_TIMEOUT_TB 1200000ULL
|
||||
|
||||
enum transfer_work { TRANSFER_PREPARE, TRANSFER_READ, TRANSFER_WRITE, TRANSFER_VERIFY, TRANSFER_FINISH };
|
||||
enum transfer_work { TRANSFER_PREPARE, TRANSFER_READ, TRANSFER_WRITE, TRANSFER_VERIFY, TRANSFER_PUBLISH, TRANSFER_FINISH };
|
||||
struct mf_chunk { uint32_t bytes; uint8_t data[MF_BLOCK_SIZE]; };
|
||||
struct mf_transfer {
|
||||
struct mf_transfer* next;
|
||||
@@ -37,6 +37,7 @@ struct mf_transfer {
|
||||
int fd, server, sending, starting, active, opened, busy, stopping, finished_io, peer_down;
|
||||
int status, work_status, reply_ready, reply_final, verified, committed, end_sent, end_received;
|
||||
int temp_owned; /* Только worker: локальный download-temp успешно создан через O_EXCL. */
|
||||
int cleanup_abort; /* Неизменяемый снимок для обязательной очистки; worker не читает живой status. */
|
||||
enum transfer_work work;
|
||||
uint64_t size, offset, received, read_offset, io_offset, io_bytes, io_size, progress_tb, last_event, last_log, logged_offset;
|
||||
unsigned head, count, io_head, io_count;
|
||||
@@ -84,7 +85,8 @@ static struct mf_message transfer_storage_request(struct mf_transfer* t, uint8_t
|
||||
static int transfer_allowed(struct mf_transfer* t) {
|
||||
struct UTUN_INSTANCE* inst = t->files->inst;
|
||||
if (!t->server) return member_files_member(inst, t->request.group, inst->node_id, 0) &&
|
||||
member_files_member(inst, t->request.group, t->request.server, 1);
|
||||
member_files_member(inst, t->request.group, t->request.server, 1) &&
|
||||
member_files_member(inst, t->request.group, t->request.owner, 0);
|
||||
struct mf_message q = t->open;
|
||||
return mf_server_allowed(t->files, t->request.group, t->request.server, &q);
|
||||
}
|
||||
@@ -190,28 +192,30 @@ static void transfer_work(void* arg) {
|
||||
if (!rc && memcmp(&after, &t->source, sizeof(after))) rc = MF_STALE;
|
||||
}
|
||||
}
|
||||
} else if (t->work == TRANSFER_PUBLISH) {
|
||||
rc = mf_disk_sync(t->fd);
|
||||
int closed = mf_disk_close(t->fd); t->fd = -1;
|
||||
if (!rc) rc = closed;
|
||||
if (!rc) rc = mf_disk_local_publish(t->temp, t->request.local);
|
||||
if (!rc) t->temp_owned = 0;
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "member_files: download publish id=%llu status=%s path=%s",
|
||||
(unsigned long long)t->request.id, mf_status_text(rc), t->request.local);
|
||||
} else {
|
||||
if (t->server && !t->sending && t->status && !t->committed) {
|
||||
if (t->cleanup_abort) {
|
||||
struct mf_message q = transfer_storage_request(t, MF_ABORT);
|
||||
mf_storage_execute(t->files->storage, t->request.group, t->request.server, &q, &t->result);
|
||||
if (t->result.status) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: stream cleanup failed id=%llu status=%s",
|
||||
(unsigned long long)t->request.id, mf_status_text(t->result.status));
|
||||
}
|
||||
if (t->fd >= 0) {
|
||||
if (!t->status && !t->sending) rc = mf_disk_sync(t->fd);
|
||||
int closed = mf_disk_close(t->fd); t->fd = -1;
|
||||
if (!rc) rc = closed;
|
||||
rc = mf_disk_close(t->fd); t->fd = -1;
|
||||
}
|
||||
if (!t->server && !t->sending && t->temp_owned) {
|
||||
if (!t->status && !rc) rc = mf_disk_local_publish(t->temp, t->request.local);
|
||||
if (t->status || rc) {
|
||||
int removed = mf_disk_local_remove(t->temp);
|
||||
if (removed) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "member_files: owned temp cleanup failed id=%llu path=%s status=%s",
|
||||
(unsigned long long)t->request.id, t->temp, mf_status_text(removed));
|
||||
else t->temp_owned = 0;
|
||||
} else t->temp_owned = 0;
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "member_files: download temp finish id=%llu owned=%d status=%s io_status=%s path=%s",
|
||||
(unsigned long long)t->request.id, t->temp_owned, mf_status_text(t->status), mf_status_text(rc), t->temp);
|
||||
int removed = mf_disk_local_remove(t->temp);
|
||||
if (removed) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "member_files: owned temp cleanup failed id=%llu path=%s status=%s",
|
||||
(unsigned long long)t->request.id, t->temp, mf_status_text(removed));
|
||||
else t->temp_owned = 0;
|
||||
if (!rc) rc = removed;
|
||||
}
|
||||
EVP_MD_CTX_free(t->digest); t->digest = NULL;
|
||||
}
|
||||
@@ -221,6 +225,16 @@ static void transfer_work(void* arg) {
|
||||
static void transfer_done(void* arg, int error) {
|
||||
struct mf_transfer* t = arg; t->busy = 0;
|
||||
int status = error ? MF_CANCELLED : t->work_status;
|
||||
if (!status && !transfer_allowed(t)) {
|
||||
status = MF_ACCESS_DENIED; t->peer_down = 1;
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: disk result revoked group=%llx peer=%llx id=%llu work=%d",
|
||||
(unsigned long long)t->request.group, (unsigned long long)t->request.server,
|
||||
(unsigned long long)t->request.id, t->work);
|
||||
}
|
||||
if (t->work == TRANSFER_PUBLISH) {
|
||||
if (!t->status) t->status = status;
|
||||
transfer_submit(t, TRANSFER_FINISH); return;
|
||||
}
|
||||
if (t->work == TRANSFER_FINISH) {
|
||||
t->finished_io = 1;
|
||||
if (!t->status) t->status = status;
|
||||
@@ -255,33 +269,36 @@ static void transfer_done(void* arg, int error) {
|
||||
}
|
||||
|
||||
static void transfer_submit(struct mf_transfer* t, enum transfer_work work) {
|
||||
if (work != TRANSFER_FINISH && !transfer_allowed(t)) { transfer_stop(t, MF_ACCESS_DENIED); return; }
|
||||
t->work = work; t->busy = 1;
|
||||
if (work == TRANSFER_FINISH) {
|
||||
t->cleanup_abort = t->server && !t->sending && t->status && !t->committed;
|
||||
struct mf_work* cleanup = t->cleanup; t->cleanup = NULL;
|
||||
mf_worker_submit_reserved(t->files->disk, cleanup, transfer_work, t, transfer_done, t);
|
||||
} else mf_worker_submit(t->files->disk, transfer_work, t, transfer_done, t);
|
||||
}
|
||||
|
||||
static void transfer_stop(struct mf_transfer* t, int status) {
|
||||
if (t->stopping) {
|
||||
if ((t->files->closing || status) && t->finished_io && !t->busy) transfer_release(t);
|
||||
else if (!t->busy && !t->finished_io) transfer_submit(t, TRANSFER_FINISH);
|
||||
return;
|
||||
}
|
||||
if (status && !t->status) {
|
||||
t->status = status;
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: stream stopped id=%llu peer=%llx reason=%s",
|
||||
(unsigned long long)t->request.id, (unsigned long long)t->request.server, mf_status_text(status));
|
||||
}
|
||||
if (t->stopping) {
|
||||
if (t->busy && t->work != TRANSFER_FINISH) mf_worker_cancel(t->files->disk, t);
|
||||
else if ((t->files->closing || status) && t->finished_io && !t->busy) transfer_release(t);
|
||||
else if (!t->busy && !t->finished_io) transfer_submit(t, TRANSFER_FINISH);
|
||||
return;
|
||||
}
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "member_files: stream stopping id=%llu starting=%d active=%d io=%d pending=%d",
|
||||
(unsigned long long)t->request.id, t->starting, t->active, t->busy, t->pending != NULL);
|
||||
t->stopping = 1; t->starting = 0;
|
||||
if (t->pump) { uasync_call_soon_cancel(t->files->inst->ua, t->pump); t->pump = NULL; }
|
||||
etcp_router_cancel_send_ready(t->files->inst, t->request.group, t->request.server, ETCP_RT_ID_MEMBER_FILES, &t->waiter);
|
||||
if (t->pending) { struct mf_pending* p = t->pending; t->pending = NULL; member_files_cancel(p); }
|
||||
if (t->busy) return;
|
||||
if (t->busy) { if (t->work != TRANSFER_FINISH) mf_worker_cancel(t->files->disk, t); return; }
|
||||
if (t->finished_io) { transfer_release(t); return; }
|
||||
transfer_submit(t, TRANSFER_FINISH);
|
||||
transfer_submit(t, !t->status && !t->server && !t->sending ? TRANSFER_PUBLISH : TRANSFER_FINISH);
|
||||
}
|
||||
|
||||
static int transfer_enqueue(struct mf_transfer* t, struct mf_message* q) {
|
||||
@@ -295,6 +312,7 @@ static int transfer_enqueue(struct mf_transfer* t, struct mf_message* q) {
|
||||
static void transfer_pump(void* arg) {
|
||||
struct mf_transfer* t = arg; t->pump = NULL;
|
||||
if (t->files->closing) return;
|
||||
if (!t->stopping && !transfer_allowed(t)) { transfer_stop(t, MF_ACCESS_DENIED); return; }
|
||||
if (t->starting) {
|
||||
t->starting = 0;
|
||||
if (t->sending || t->server) transfer_submit(t, TRANSFER_PREPARE);
|
||||
@@ -402,7 +420,7 @@ struct mf_transfer* member_files_transfer_start(const struct mf_transfer_request
|
||||
if (!f || f->closing || !callback || f->transfer_count >= TRANSFER_MAX || !q->group || !q->server || !q->owner ||
|
||||
!mf_path_valid(q->remote, 0) || !*q->local || strnlen(q->local, sizeof(q->local)) > MF_PATH_MAX ||
|
||||
(q->upload && q->owner != q->inst->node_id) || !member_files_member(q->inst, q->group, q->inst->node_id, 0) ||
|
||||
!member_files_member(q->inst, q->group, q->server, 1)) {
|
||||
!member_files_member(q->inst, q->group, q->server, 1) || !member_files_member(q->inst, q->group, q->owner, 0)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: stream start denied/limit reached"); return NULL;
|
||||
}
|
||||
struct mf_transfer_request request = *q;
|
||||
@@ -526,20 +544,35 @@ void mf_transfers_tick(struct member_files* f, uint64_t now) {
|
||||
void member_files_transfer_cancel(struct mf_transfer* t) { if (t && !t->stopping) transfer_stop(t, MF_CANCELLED); }
|
||||
|
||||
void mf_transfers_quiesce(struct member_files* f, uint64_t group) {
|
||||
struct mf_transfer* t = f->transfers;
|
||||
while (t) {
|
||||
struct mf_transfer* next = t->next;
|
||||
if (!group || t->request.group == group) { t->peer_down = 1; transfer_stop(t, MF_CANCELLED); }
|
||||
t = next;
|
||||
for (;;) {
|
||||
struct mf_transfer* found = NULL;
|
||||
for (struct mf_transfer* t = f->transfers; t; t = t->next)
|
||||
if (!t->peer_down && (!group || t->request.group == group)) { found = t; break; }
|
||||
if (!found) break;
|
||||
found->peer_down = 1; transfer_stop(found, MF_CANCELLED);
|
||||
}
|
||||
}
|
||||
|
||||
void mf_transfers_peer_down(struct member_files* f, uint64_t group, uint64_t peer) {
|
||||
struct mf_transfer* t = f->transfers;
|
||||
while (t) {
|
||||
struct mf_transfer* next = t->next;
|
||||
if (t->request.group == group && t->request.server == peer) { t->peer_down = 1; transfer_stop(t, MF_IO_ERROR); }
|
||||
t = next;
|
||||
for (;;) {
|
||||
struct mf_transfer* found = NULL;
|
||||
for (struct mf_transfer* t = f->transfers; t; t = t->next)
|
||||
if (!t->peer_down && t->request.group == group && t->request.server == peer) { found = t; break; }
|
||||
if (!found) break;
|
||||
found->peer_down = 1; transfer_stop(found, MF_IO_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
void mf_transfers_revalidate(struct member_files* f, uint64_t group) {
|
||||
for (;;) {
|
||||
struct mf_transfer* denied = NULL;
|
||||
for (struct mf_transfer* t = f->transfers; t; t = t->next)
|
||||
if (!t->peer_down && t->request.group == group && !transfer_allowed(t)) { denied = t; break; }
|
||||
if (!denied) break;
|
||||
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "member_files: revoke stream group=%llx peer=%llx id=%llu io=%d",
|
||||
(unsigned long long)group, (unsigned long long)denied->request.server,
|
||||
(unsigned long long)denied->request.id, denied->busy);
|
||||
denied->peer_down = 1; transfer_stop(denied, MF_ACCESS_DENIED);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ struct mf_work {
|
||||
mf_done_fn done;
|
||||
void* data;
|
||||
void* arg;
|
||||
int completed;
|
||||
int completed, error, cancellable;
|
||||
};
|
||||
struct mf_worker {
|
||||
struct UASYNC* ua;
|
||||
@@ -36,8 +36,8 @@ static void work_finish(struct mf_work* j) {
|
||||
w->count--;
|
||||
DEBUG_TRACE(DEBUG_CATEGORY_MEDIA, "member_files: worker callback owner=%p task=%p remaining=%u pid=%ld",
|
||||
(void*)w, (void*)j, w->count, (long)getpid());
|
||||
mf_done_fn done = j->done; void* arg = j->arg;
|
||||
u_free(j); done(arg, 0);
|
||||
mf_done_fn done = j->done; void* arg = j->arg; int error = j->error;
|
||||
u_free(j); done(arg, error);
|
||||
}
|
||||
|
||||
static void work_completed(void* arg) {
|
||||
@@ -119,9 +119,32 @@ void mf_worker_submit_reserved(struct mf_worker* w, struct mf_work* j,
|
||||
void mf_worker_submit(struct mf_worker* w, mf_work_fn work, void* data, mf_done_fn done, void* arg) {
|
||||
struct mf_work* j = w->count < 64 ? mf_worker_reserve() : NULL;
|
||||
if (!j) { DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: disk queue rejected active=%u", w->count); done(arg, -1); return; }
|
||||
j->cancellable = 1;
|
||||
mf_worker_submit_reserved(w, j, work, data, done, arg);
|
||||
}
|
||||
|
||||
/* Снятая работа сохраняет jobs/completion до done; callbacks здесь не запускаются. */
|
||||
unsigned mf_worker_cancel(struct mf_worker* w, void* data) {
|
||||
unsigned cancelled = 0;
|
||||
pthread_mutex_lock(&w->mutex);
|
||||
struct mf_work** link = &w->queue;
|
||||
struct mf_work* previous = NULL;
|
||||
while (*link) {
|
||||
struct mf_work* j = *link;
|
||||
if (j->cancellable && j->data == data) {
|
||||
*link = j->queued_next;
|
||||
if (w->tail == j) w->tail = previous;
|
||||
j->queued_next = NULL; j->error = MF_WORK_CANCELLED; j->completed = 1;
|
||||
uasync_post_reserved(w->ua, j->completion);
|
||||
cancelled++;
|
||||
} else { previous = j; link = &j->queued_next; }
|
||||
}
|
||||
if (cancelled) pthread_cond_broadcast(&w->condition);
|
||||
pthread_mutex_unlock(&w->mutex);
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "member_files: worker cancel owner=%p data=%p queued=%u", (void*)w, data, cancelled);
|
||||
return cancelled;
|
||||
}
|
||||
|
||||
void mf_worker_drain(struct mf_worker* w) {
|
||||
if (!w) return;
|
||||
/* Не запускаем loop рекурсивно: завершаем только собственные callbacks. */
|
||||
|
||||