Author SHA1 Message Date
evgeny ac37710adc Record passing full suite desktop and Android validation 2026-11-04 02:03:14 +01:00
evgeny 455bbf88d4 Document completed invite call device and stream isolation audits 2026-11-04 02:01:09 +01:00
evgeny d629de54e8 Keep simulated unreachable addresses across call status publication 2026-11-04 01:59:36 +01:00
evgeny c8bb3dcbc3 Initialize membership trees for the CHAT call routing fixture 2026-11-04 01:57:10 +01:00
evgeny 189c61c3de Run call path regression with admitted CHAT members over shared transport 2026-11-04 01:56:40 +01:00
evgeny 9b4ef4d83b Authorize referenced stream handles in cross-group overload controls 2026-11-04 01:55:03 +01:00
evgeny 3fee6e6f82 Require explicit JOIN refusal without channel disclosure for wrong keys 2026-11-04 01:53:03 +01:00
evgeny 6f261cd5df Document supported invitation entry and call revocation policy 2026-11-04 01:51:51 +01:00
evgeny 9d12bbad39 Preserve PIN retry receipts and initialize full stream test lifecycle 2026-11-04 01:51:50 +01:00
evgeny 8b5f8ae944 Cover stream endpoint isolation and shared control queue revocation 2026-11-04 01:50:43 +01:00
evgeny 8f3a0f7867 Test delayed device keys and scrub PIN caches after cancellation 2026-11-04 01:48:24 +01:00
evgeny c05a213d0d Exercise call revocation and cross-group packets over shared ETCP 2026-11-04 01:46:58 +01:00
evgeny e357a035ab Check current group admission for stream data and delayed controls 2026-11-04 01:44:00 +01:00
evgeny 1edd009068 Revoke device invitations when group admission or peer identity changes 2026-11-04 01:42:57 +01:00
evgeny 8dc0195d53 Apply group admission to video and cancel calls on membership change 2026-11-04 01:42:32 +01:00
evgeny d83ab168cb Authorize call channels and stop revoked calls immediately 2026-11-04 01:42:20 +01:00
evgeny 8633b35388 Verify retired invitation packets cannot enter a group API 2026-11-04 01:37:54 +01:00
evgeny 9dcfa17de8 Drop tests for the removed legacy invitation implementation 2026-11-04 01:37:54 +01:00
evgeny 6baec7f311 Delete obsolete INVITE_INFO client and server implementation 2026-11-04 01:37:32 +01:00
evgeny 54da6e554b Remove legacy invitation module from the core build 2026-11-04 01:37:32 +01:00
evgeny d652706047 Stop exposing the unused unauthenticated INVITE_INFO endpoint 2026-11-04 01:37:32 +01:00
evgeny d27665262f Record final MEMBERS verification and full regression results 2026-11-04 01:25:36 +01:00
evgeny 15ea4ff37e Preserve local member settings when repairing invalid signed records 2026-11-04 01:21:46 +01:00
evgeny c24412276f Run STREAM relay scenarios as an independent integration target 2026-11-04 01:15:57 +01:00
evgeny 96410eb606 Distinguish helper delivery latency from its bootstrap timer 2026-11-04 01:12:35 +01:00
evgeny d33aea6305 Record completed MEMBERS version verification and isolation coverage 2026-11-04 01:12:22 +01:00
evgeny eb9d509c47 Keep relay race fixtures active independently of standby scenarios 2026-11-04 01:11:49 +01:00
evgeny 2650eec1cf Include transport liveness in optional STREAM test diagnostics 2026-11-04 01:11:17 +01:00
evgeny 3e3f3096ae Log actual bootstrap timer latency before the integration assertion 2026-11-04 01:07:26 +01:00
evgeny c23f2d2058 Describe final callback ordering and preserve-before-verify startup behavior 2026-11-04 01:07:09 +01:00
evgeny 459a811bce Skip whole-group projection when incoming versions make no changes 2026-11-04 01:06:43 +01:00
evgeny 778b548c9c Preserve fallback versions until receive verification instead of purging at start 2026-11-04 01:05:59 +01:00
evgeny ddf570c791 Keep caller transactions intact on invalid receive commit and guard SQLite blob reads 2026-11-04 01:05:04 +01:00
evgeny 797e5d39c4 Sign radio fixture credentials with the initialized node identity 2026-11-04 01:04:20 +01:00
evgeny 525e8e127c Verify recovery completes the waiting neighbour handshake 2026-11-04 01:03:50 +01:00
evgeny 4a4244e417 Align service fixtures with authenticated membership and revoked disclosure boundaries 2026-11-04 01:03:25 +01:00
evgeny 33e1a588e8 Complete ordinary membership handshake after recovery and skip unchanged snapshots 2026-11-04 01:02:40 +01:00
evgeny 23a6b2ed9e Test delayed inviter dependencies without leaking unverified shared identities 2026-11-04 01:00:15 +01:00
evgeny 07858614c8 Exercise member endpoint isolation and bootstrap over the shared ETCP transport 2026-11-04 01:00:15 +01:00
evgeny 0669cd7b06 Document the implemented single-table member receive lifecycle 2026-11-04 00:58:51 +01:00
evgeny fc6dc6a93b Explain per-record fallback and concurrent synchronization in public headers 2026-11-04 00:58:51 +01:00
evgeny 8b3fb5f137 Read published member properties when opening desktop dialogs 2026-11-04 00:57:49 +01:00
evgeny 235bef7f7e Filter Android member views and update desktop file fixtures for versioned rows 2026-11-04 00:57:49 +01:00
evgeny eb9aca7fe8 Keep desktop membership views on published records 2026-11-04 00:57:02 +01:00
evgeny d548e31c1d Publish staged results only when the database commit succeeds 2026-11-04 00:57:02 +01:00
evgeny de6fa56a73 Avoid the POSIX revoke name in member version tests 2026-11-04 00:56:12 +01:00
evgeny c67b3e32e8 Cover publication after callback cancellation and real request authorization 2026-11-04 00:56:00 +01:00
evgeny 70a5f0345f Preserve separately signed owner revocations when repairing invalid profiles 2026-11-04 00:55:22 +01:00
evgeny e162b60961 Allow policy edits against the published snapshot during parallel receives 2026-11-04 00:54:18 +01:00
evgeny 20ba312340 Pass staged page cursor metadata in the adapter contract order 2026-11-04 00:54:02 +01:00
evgeny 63751b3715 Test policy and member publication as one completed exchange 2026-11-04 00:53:51 +01:00
evgeny b4de48f0f6 Exercise staged member pages and retain SQL fault injection coverage 2026-11-04 00:53:18 +01:00
evgeny 45064e2f33 Remove obsolete group-wide pending admission checkpoint 2026-11-04 00:53:18 +01:00
evgeny a13a6e0941 Check current admission before resuming dependent synchronization 2026-11-04 00:52:48 +01:00
evgeny 8201b075f4 Use one transactional receive path for MEMBERS and revalidate after publication 2026-11-04 00:52:38 +01:00
evgeny c02d5a6cfc Prevent invalid old versions from winning freshness comparisons 2026-11-04 00:48:23 +01:00
evgeny 7319f900c4 Exercise concurrent member versions, fallback, SQL rollback and restart 2026-11-04 00:46:27 +01:00
evgeny 1aa528d352 Authorize member disclosure separately from recovery requests and clean abandoned versions 2026-11-04 00:45:06 +01:00
evgeny fcff5c626b Connect MEMBERS to staged receive and publish only actual version changes 2026-11-04 00:43:50 +01:00
evgeny 81bba75d02 Stage each member exchange separately and validate versions before atomic publication 2026-11-04 00:42:39 +01:00
evgeny 592b6e1b75 Verify prospective invitation chains without publishing node identities 2026-11-04 00:38:17 +01:00
evgeny 3481e2fe69 Project member admission inside the caller transaction and notify after commit 2026-11-04 00:33:40 +01:00
evgeny 81bd4ae982 Publish staged data safely after leaving the Merkle state transition 2026-11-04 00:32:50 +01:00
evgeny 017db6bb2d Read only published membership for attachment roles and invitations 2026-11-04 00:32:08 +01:00
evgeny 33e403ab44 Prevent pending member versions from affecting radio and stream state 2026-11-04 00:32:08 +01:00
evgeny 278d46b985 Keep file and media roles independent of in-progress member sync 2026-11-04 00:32:08 +01:00
evgeny 9e526ab003 Exclude unverified membership versions from personal service routes 2026-11-04 00:31:30 +01:00
evgeny a45e6a3b12 Keep JOIN and feedback on published membership versions 2026-11-04 00:31:01 +01:00
evgeny cfd8ea055e Keep pending member profiles out of frontend reads and local edits 2026-11-04 00:31:01 +01:00
evgeny 0b4bb1659a Exclude pending member versions from Merkle and admission 2026-11-04 00:30:30 +01:00
evgeny bd175ac87f Keep per-record fallback distinct from membership revocation 2026-11-04 00:29:59 +01:00
evgeny b0c762aea7 Introduce per-exchange member versions in the existing peers table 2026-11-04 00:28:54 +01:00
evgeny 13fd7cd34a Record completed Merkle transport stage and full regression results 2026-11-04 00:13:27 +01:00
evgeny 306c38ca82 Test receive-only Merkle permissions, confirmation and source races 2026-11-04 00:09:46 +01:00
evgeny 2c9f122d57 Keep protocol and isolation fixtures explicit about exchange direction 2026-11-04 00:05:26 +01:00
evgeny c3b6a70bfb Complete receive-only Merkle exchange with deferred publication 2026-11-04 00:04:55 +01:00
evgeny d1d00c1393 Document agreed deferred member verification lifecycle 2026-11-03 23:52:45 +01:00
evgeny a4243328cc Preserve interrupted Merkle draft before member versioning 2026-11-03 23:43:24 +01:00
evgeny e7ffbd5411 Record complete history isolation regression results 2026-11-03 23:26:27 +01:00
evgeny 0804d8cc9c Record history isolation and separate MEMBERS bootstrap work 2026-11-03 23:23:19 +01:00
evgeny 6b11e9f167 Cover stale keys and queued Merkle revocation with diagnostics 2026-11-03 23:21:44 +01:00
evgeny 58c47bb24f Require current CHAT admission for history and sound settings 2026-11-03 23:19:22 +01:00
evgeny c11e38963a Reproduce revoked history and sound access on shared transport 2026-11-03 23:19:07 +01:00
evgeny f4939e4444 Clarify endpoint authorization and transit routing boundary 2026-11-03 23:10:32 +01:00
evgeny 5528194be5 Record complete personal delivery regression results 2026-11-03 23:05:49 +01:00
evgeny e9d794a718 Document identity-based personal delivery and storage assignments 2026-11-03 23:02:36 +01:00
evgeny 0b3e23746b Exercise fresh storage attempts and late refusals over real routes 2026-11-03 23:00:52 +01:00
evgeny 586f19faf5 Validate immutable assignment replies and test probe route revocation 2026-11-03 23:00:07 +01:00
evgeny d4ea78d619 Test pair-route revocation, shared custody and assignment migration 2026-11-03 22:58:46 +01:00
evgeny d44dd362d3 Cancel personal file probes when their group admission is revoked 2026-11-03 22:56:48 +01:00
evgeny c67eb03f6e Check quota backoff and reject stale assignment acknowledgments 2026-11-03 22:56:47 +01:00
evgeny bfc8e26431 Retry rejected storage with a fresh durable assignment 2026-11-03 22:55:34 +01:00
evgeny 9edc06cb09 Adapt media replay tests to storage assignment identities 2026-11-03 22:54:57 +01:00
evgeny 27b0166324 dm: enforce monotonic storage decisions and update fault scenarios 2026-11-03 22:43:20 +01:00
evgeny 515da0304c dm: accept authenticated mailbox conversations without a direct shared group 2026-11-03 22:41:23 +01:00
evgeny b1bfb94f1f test: identify custody replies and force storage-mediated file delivery 2026-11-03 22:40:37 +01:00
evgeny 13d838988e dm: bind custody replies to persisted assignment identities 2026-11-03 22:37:35 +01:00
evgeny 1d0459534d dm: track storage requests separately from shared immutable copies 2026-11-03 22:33:44 +01:00
evgeny 75ab57798e dm: define durable storage request identities and scoped source expiry 2026-11-03 22:29:37 +01:00
evgeny 3d4dd893b6 docs: record pairwise private delivery checks and remaining custody work 2026-11-03 22:27:05 +01:00
evgeny 62b50c05ff test: preserve private queues when removing stored group routes 2026-11-03 22:24:39 +01:00
evgeny 18c85dc44f dm: remove persisted CHAT routes from private media and custody 2026-11-03 22:23:55 +01:00
evgeny 710a9de15c dm: keep private file identity and source records independent of routes 2026-11-03 22:22:50 +01:00
evgeny a9e05ed8e3 dm: authorize media objects separately from their CHAT routes 2026-11-03 22:19:35 +01:00
evgeny cef3f36ab3 test: prepare complete channel fixtures for private delivery 2026-11-03 22:17:36 +01:00
evgeny d14555db46 test: exercise private mailbox across pairwise CHAT groups 2026-11-03 22:16:17 +01:00
evgeny c09ab9f344 dm: initialize mailbox schema independently of the direct service 2026-11-03 22:14:16 +01:00
evgeny 29013ebccc dm: route mailbox delivery by each pair of node identities 2026-11-03 22:13:29 +01:00
evgeny 3c58fc80e3 dm: resolve service roles independently of delivery routes 2026-11-03 22:11:46 +01:00
evgeny 307ca10733 docs: separate personal delivery identity from group routing 2026-11-03 22:09:12 +01:00
evgeny 998efdbb74 Record full regression results for file transfer and direct DM isolation 2026-11-03 21:54:02 +01:00
evgeny 9f11dcae0e Document file and DM isolation and the custody reassignment boundary 2026-11-03 21:53:17 +01:00
evgeny 47e0c99f64 Authorize DM delivery and route selection by current group admission 2026-11-03 21:47:58 +01:00
evgeny e980ae048a Exercise DM admission before group teardown hides the unauthorized packet 2026-11-03 21:47:17 +01:00
evgeny 31e439e74d Test DM history isolation and retry through another shared group 2026-11-03 21:46:14 +01:00
evgeny 11ab7188fa Test file transfer unsigned controls and delayed sender revocation 2026-11-03 21:44:08 +01:00
evgeny 33db6c909f Check revoked file writes independently of cancellation callbacks 2026-11-03 21:42:11 +01:00
evgeny 27eb0fa546 Authorize file transfers before I/O and revoke active group operations 2026-11-03 21:41:49 +01:00
evgeny dfdd89b5f0 Reproduce cross-group file transfer effects and destination truncation 2026-11-03 21:40:24 +01:00
evgeny a3600c8a44 Record full attachment isolation regression results 2026-11-03 21:20:48 +01:00
evgeny ce840f56b3 Record attachment isolation checks and resolved deletion conflict 2026-11-03 21:19:02 +01:00
evgeny 614ed165e1 Document attachment revocation and committed channel teardown 2026-11-03 21:17:48 +01:00
evgeny eabf2924a1 Test attachment worker completion after membership revocation 2026-11-03 21:14:10 +01:00
evgeny 0e78c1280b Keep cancelled key requests paused during media backfill 2026-11-03 21:13:00 +01:00
evgeny 2552a9cfa9 Test attachment key revocation across groups on shared ETCP 2026-11-03 21:12:17 +01:00
evgeny 9a8814e2e6 Preserve member identity across attachment restart tests 2026-11-03 21:10:50 +01:00
evgeny 07f0ea6e14 Test durable attachment revocation, rollback and readmission 2026-11-03 21:08:55 +01:00
evgeny aebc3fcd89 Keep attachment SQL fixtures consistent with group admission 2026-11-03 21:07:30 +01:00
evgeny 3d60298d21 Commit channel deletion before stopping its runtime work 2026-11-03 21:07:08 +01:00
evgeny 1227e216f7 Document attachment cancellation conflict with channel deletion 2026-11-03 21:04:03 +01:00
evgeny dcd46a31e0 Show cancelled attachment key requests after access revocation 2026-11-03 21:02:09 +01:00
evgeny f3cff571dc Authorize attachment key exchange and cancel durable revoked work 2026-11-03 21:00:59 +01:00
evgeny 1b34f5ad82 Record NAT and multicast isolation checks and remaining scope 2026-11-03 20:58:22 +01:00
evgeny 64d9657aa9 Test multicast revocation in shared queues and routed reports 2026-11-03 20:57:07 +01:00
evgeny 0c5c5c03ca Authorize CHAT multicast traffic and revoke queued deliveries 2026-11-03 20:52:37 +01:00
evgeny 2a873c1715 Recheck all NAT roles before results and fallback probes 2026-11-03 20:49:46 +01:00
evgeny 2156bab53b Test CHAT NAT control injection and raw observation isolation 2026-11-03 20:49:23 +01:00
evgeny 455d9971ba Test cancellation of NAT rounds when a third participant is revoked 2026-11-03 20:48:56 +01:00
evgeny 1ebfad32f5 Isolate CHAT NAT controls and revoke pending participant probes 2026-11-03 20:47:17 +01:00
evgeny c83803e5d6 Record radio and JOIN isolation checks and remaining scope 2026-11-03 20:43:38 +01:00
evgeny faca1266dd Revoke CHAT service work when its group starts teardown 2026-11-03 20:43:16 +01:00
evgeny 47082e966c Restore revoked BGP session before the next isolation scenario 2026-11-03 20:42:42 +01:00
evgeny a6748335b4 Test JOIN key isolation and revocation before confirmation 2026-11-03 20:41:39 +01:00
evgeny c3575aca6f Cancel JOIN waiters when connection or inviter rights are revoked 2026-11-03 20:40:33 +01:00
evgeny 5d0e4a4673 Authorize routed JOIN and revoke invite keys and registrations 2026-11-03 20:40:14 +01:00
evgeny 509368b76d Test radio invitation isolation and active invitation revocation 2026-11-03 20:35:38 +01:00
evgeny 8b9adff206 Wire radio invitation revocation into group admission changes 2026-11-03 20:34:40 +01:00
evgeny b245988ffa Authorize radio invitations and revoke only denied target branches 2026-11-03 20:34:16 +01:00
evgeny cd25f92df4 Record passing full regression run for file and avatar isolation 2026-11-03 20:33:35 +01:00
evgeny 83fb89f9ea Document verified file revocation contract and avatar isolation 2026-11-03 20:31:39 +01:00
evgeny b8a1d523e7 Bind admin key import to its authorized CHAT group 2026-11-03 20:29:33 +01:00
evgeny 4c8f13bcc6 Test avatar revocation with a partial image and router backlog 2026-11-03 20:21:58 +01:00
evgeny 71f73a21e1 Authorize avatar channels and cancel revoked peer state 2026-11-03 20:21:16 +01:00
evgeny ec8d8f8734 Respect file retry timing and isolate disk barrier test from sweep 2026-11-03 20:18:20 +01:00
evgeny 38fd7a723a Log disk queue and execution boundaries for revocation diagnostics 2026-11-03 20:16:59 +01:00
evgeny abbf1cc60c Test file revocation before and after disk execution starts 2026-11-03 20:15:23 +01:00
evgeny 0edb47fb4f Revalidate file operations after group membership changes 2026-11-03 20:11:44 +01:00
evgeny e22e66bc4e Cancel revoked file streams and separate publication from cleanup 2026-11-03 20:11:31 +01:00
evgeny 0d7452b836 Authorize MEMBER_FILES channels and revoke pending group operations 2026-11-03 20:10:40 +01:00
evgeny ae26d626c8 Test queued cancellation against running work and reserved cleanup 2026-11-03 20:09:45 +01:00
evgeny 1a7f877f32 Cancel queued disk work without interrupting running operations 2026-11-03 20:09:07 +01:00
evgeny dd60f089a1 Record verified CHAT isolation stage and disk revocation boundary 2026-11-03 20:07:17 +01:00
evgeny 3069d1c781 Give protocol unit fixtures an explicit non-CHAT group type 2026-11-03 20:00:09 +01:00
evgeny 09a3d09013 Recheck broadcast admission between subscriber callbacks 2026-11-03 20:00:09 +01:00
evgeny 47846709f2 Cancel pending radio batches when CHAT membership is revoked 2026-11-03 19:59:50 +01:00
evgeny 6965e11fe1 Detach revoked CM owners before callbacks and test group recovery 2026-11-03 19:58:33 +01:00
evgeny f30ef75384 Wire member revocation into CM and complete scoped policy tests 2026-11-03 19:56:27 +01:00
evgeny 83925d4e56 Authorize CHAT connection attempts and cancel revoked owners 2026-11-03 19:54:45 +01:00
evgeny f2c8993589 Reject raw CHAT data before delivery and forwarding 2026-11-03 19:52:24 +01:00
evgeny 9f6495e4ae Define CHAT data admission and test scoped router policy 2026-11-03 19:51:32 +01:00
evgeny 31c8f35b6b Scope router access policies to CHAT without changing UTUN 2026-11-03 19:49:58 +01:00
evgeny 5ac829363e Reproduce CHAT cross-group access on a shared ETCP link 2026-11-03 19:45:58 +01:00
evgeny cbffb6492a Scope group isolation plan to CHAT and defer UTUN 2026-11-03 19:38:35 +01:00
evgeny bb02709be3 Plan staged isolation of group service access 2026-11-03 19:33:45 +01:00
evgeny 22abcd13ca Record remaining cross-group authorization audit gaps 2026-11-03 19:20:19 +01:00
evgeny 80f2bb1c84 Validate the versioned supernode role in isolation fixture 2026-11-03 19:14:17 +01:00
evgeny 7198a83031 Exercise foreign announcements against a signed supernode role 2026-11-03 19:13:35 +01:00
evgeny c02e196b4e Read the channel signing key with its single-key API 2026-11-03 19:12:20 +01:00
evgeny a6416297af Seed a signed CHAT member in service shutdown regression 2026-11-03 19:11:47 +01:00
evgeny 30c7364976 Adapt lifecycle and relay checks to authenticated CHAT media 2026-11-03 19:11:04 +01:00
evgeny 4a34b174c5 Document authenticated media admission and revocation boundaries 2026-11-03 19:10:03 +01:00
evgeny eac0163b69 Cover registration isolation and authorization through a relay 2026-11-03 19:08:59 +01:00
evgeny 6be77de7f6 Scope media registrations and active roles to their CHAT group 2026-11-03 19:07:51 +01:00
evgeny ed9e171683 Test cross-group media denial over ETCP and buffered revocation 2026-11-03 19:07:51 +01:00
evgeny fc4dddfc28 Recheck buffered deliveries and align media test membership 2026-11-03 19:02:21 +01:00
evgeny d7630b9700 Update download and router tests for authenticated admission 2026-11-03 18:57:33 +01:00
evgeny a3b94aff34 Use protected media packets in integration fixtures 2026-11-03 18:57:13 +01:00
evgeny 2b37d071b5 Model admitted CHAT members in media test fixtures 2026-11-03 18:56:36 +01:00
evgeny 7255e76ef7 Restrict media delivery to admitted channel members 2026-11-03 18:54:54 +01:00
evgeny 5f2eb2ef70 Enforce authenticated service admission in the router 2026-11-03 18:53:50 +01:00
evgeny 66f817e0b5 Add Ubuntu OpenSSL installer for HPKE support 2026-11-03 18:31:28 +01:00
evgeny 5d7efad400 Add VibeChat web presentation and group diagrams 2026-11-03 18:05:29 +01:00
evgeny c9bdfc5054 Limit Android build concurrency to reduce peak memory use 2026-11-03 17:14:30 +01:00
evgeny 2b83be1faa Allow retry when Android policy conflict refresh fails 2026-11-03 16:48:59 +01:00
evgeny 51b9c6e6f0 Clean up CLI invitation policy command documentation 2026-11-03 16:44:43 +01:00
evgeny 3330d840e6 Keep simulated planner tests independent of unused real sockets 2026-11-03 16:44:13 +01:00
evgeny 5054a9aa99 Use a new membership epoch when rejoining within the same second 2026-11-03 16:41:26 +01:00
evgeny 03bd1382b8 Advance long planner retry tests to the next timer efficiently 2026-11-03 16:41:25 +01:00
evgeny a6e8bde6f8 Wait for complete group exchange before stream recovery assertions 2026-11-03 16:40:56 +01:00
evgeny ebba859223 Wait for new participant admission before the poll fixture opens its group session 2026-11-03 16:38:47 +01:00
evgeny 972041ac95 Start admitted group sessions from UP without releasing pending admission transport 2026-11-03 16:38:46 +01:00
evgeny cd22ae59e3 Update member synchronization contract for signed group policies 2026-11-03 16:37:04 +01:00
evgeny f3fb1c64ac Include admission in minimal media replication schemas 2026-11-03 16:36:38 +01:00
evgeny 9700ea3340 Allow membership closure to finish before releasing restored stream handle 2026-11-03 16:36:18 +01:00
evgeny 44d9b49252 Exclude unadmitted members from media roles and channel counters 2026-11-03 16:35:40 +01:00
evgeny 673c2a3752 Require current admission for live invitation and attachment authorization 2026-11-03 16:35:40 +01:00
evgeny d20111318c Check file ACL and session removal after signed membership exclusion 2026-11-03 16:34:45 +01:00
evgeny c6a5c13d3f Restore allowed peer sessions after own membership is readmitted 2026-11-03 16:34:44 +01:00
evgeny 3a54bdd3a2 Enforce invitation admission in active video stream membership 2026-11-03 16:33:58 +01:00
evgeny 63775a9045 Notify active service consumers when derived membership changes 2026-11-03 16:33:57 +01:00
evgeny 04567fd328 Use complete membership certificates in DM and radio mesh scenarios 2026-11-03 16:32:08 +01:00
evgeny c6a762b286 Create admitted signed members in headless voice fixtures 2026-11-03 16:31:43 +01:00
evgeny 8f988af0df Sign membership fixtures used by calls and DM media 2026-11-03 16:31:42 +01:00
evgeny 90b90738f1 Seed completed admission in isolated history synchronization tests 2026-11-03 16:31:13 +01:00
evgeny 626fa26a5b Add signed membership fixture for network service tests 2026-11-03 16:30:50 +01:00
evgeny 1c41dc0c32 Use signed membership in media delivery network scenarios 2026-11-03 16:30:06 +01:00
evgeny e93c67afd5 Update recovery admission and invitation signing fault fixtures 2026-11-03 16:29:43 +01:00
evgeny b6ea2ed401 Seed admission state in isolated topology and NAT fixtures 2026-11-03 16:29:43 +01:00
evgeny 8f85ed9d17 Implement separate transport and group membership admission checks 2026-11-03 16:29:22 +01:00
evgeny ced7a06ec8 Reject deleted and placeholder peers during initial membership transport 2026-11-03 16:29:16 +01:00
evgeny f7c3251969 Test online inviter confirmation and document invitation policy 2026-11-03 16:27:34 +01:00
evgeny b5e8d83c51 Document current invitation signatures and admission checkpoints 2026-11-03 16:26:18 +01:00
evgeny dbe639ff55 Name inviter confirmation callback for success and rejection 2026-11-03 16:25:51 +01:00
evgeny cb9036d54c Hide members excluded by invitation policy in Android snapshots 2026-11-03 16:25:50 +01:00
evgeny 48761ff473 Publish co-owner invite role and validate policy command arguments 2026-11-03 16:25:30 +01:00
evgeny e40e9f6eb1 Retain initial membership transport until group session starts 2026-11-03 16:24:35 +01:00
evgeny 600b87d601 Exclude members rejected by invitation policy from active member lists 2026-11-03 16:20:15 +01:00
evgeny b2be8ffc19 Translate desktop invitation policy controls into Russian and English 2026-11-03 16:19:36 +01:00
evgeny 34df38da5a Add CLI preview and apply command for invitation permissions 2026-11-03 16:19:12 +01:00
evgeny a1653d648b Keep unresolved invitation chains inactive and test interrupted sync and cycles 2026-11-03 16:19:12 +01:00
evgeny e7b850f3ea Keep requested transport for initial membership reconciliation before group admission 2026-11-03 16:17:52 +01:00
evgeny aae66a3de5 Seed real admitted administrators in JOIN tests and adapt desktop fixture 2026-11-03 16:11:18 +01:00
evgeny 01c2aaad70 Add Android group invitation permissions and selective re-signing 2026-11-03 16:10:00 +01:00
evgeny 24683009db Include membership admission API in message submission 2026-11-03 16:08:09 +01:00
evgeny 3ce096d352 Update join tests and start synchronization only after confirmed ready 2026-11-03 16:06:36 +01:00
evgeny af9ad53a7d Open invitation permissions from group settings 2026-11-03 16:05:40 +01:00
evgeny a141fc7215 Add desktop invitation policy preview and member preservation dialog 2026-11-03 16:05:40 +01:00
evgeny ac5fbf0ffb Deliver group policy command results to desktop interfaces 2026-11-03 16:04:26 +01:00
evgeny 6a1ef52c67 Expose policy preview and preservation through headless control 2026-11-03 16:04:26 +01:00
evgeny 8cb2a85dc9 Retain the last admission checkpoint across interrupted reconciliation 2026-11-03 16:03:39 +01:00
evgeny 0c0233600c Expose correlated policy preview and apply commands for interfaces 2026-11-03 16:02:31 +01:00
evgeny 45f6e4807f Enforce admission on local messages and feedback exchange 2026-11-03 16:00:58 +01:00
evgeny 635a03bbd8 Restrict member storage and invite connection candidates to admitted members 2026-11-03 16:00:58 +01:00
evgeny a35b09c4de Test policy transitions, preserved members and Merkle page ordering 2026-11-03 16:00:14 +01:00
evgeny ff94ff38ea Adapt member and feedback tests to policy pages and join signatures 2026-11-03 15:59:00 +01:00
evgeny 1aa1678048 Update poll and encrypted messaging membership fixtures 2026-11-03 15:58:59 +01:00
evgeny 1bf9ccb74b Update stream and call fixtures to signed join certificates 2026-11-03 15:58:59 +01:00
evgeny 2a7fdd0fb0 Update attachment and join fixtures to current certificate format 2026-11-03 15:58:30 +01:00
evgeny 57a9afb63e Update media membership fixtures to join-bound certificates 2026-11-03 15:58:30 +01:00
evgeny 7eb4450bef Verify group join binding independently of member profile 2026-11-03 15:58:30 +01:00
evgeny 08a233eeed Sign exact join credentials during admission and preservation 2026-11-03 15:57:53 +01:00
evgeny 7c0814345d Bind inviter certificates to the signed group join epoch 2026-11-03 15:57:52 +01:00
evgeny d89a8e127c Prepare policy response before commit and remove policy with channel 2026-11-03 15:57:07 +01:00
evgeny f611cbb80b Close excluded group sessions and validate preservation selections 2026-11-03 15:56:35 +01:00
evgeny 9410570e4e Confirm online signing and recompute admission after role changes 2026-11-03 15:55:01 +01:00
evgeny d682a18dd4 Correlate inviter responses with pending join credentials 2026-11-03 15:53:45 +01:00
evgeny 533b19f51f Enforce inviter permissions at registration and signing 2026-11-03 15:50:10 +01:00
evgeny 6aa24b6471 Restore effective membership on startup and group creation 2026-11-03 15:49:10 +01:00
evgeny b5d008ee87 Reconcile group policy with members and prefer group signatures 2026-11-03 15:48:34 +01:00
evgeny 94ae8231aa Store group policy and derived member admission 2026-11-03 15:47:36 +01:00
evgeny 23159f791a Add signed group invitation policy and admission evaluation 2026-11-03 15:47:10 +01:00
evgeny eb74188565 docs: show group member relays in private messaging slide 2026-11-03 15:02:25 +01:00
evgeny 58948a7c0a docs: add simple slide explaining private message encryption 2026-11-03 14:54:39 +01:00
evgeny 7e75f4f3ee docs: add P2P encryption and delivery illustration 2026-11-03 14:39:54 +01:00
evgeny 9c9f6f3440 Save the P2P encryption and delivery slide prompt 2026-11-03 14:32:19 +01:00
evgeny d5915aec80 Record DM member panel validation and layout checks 2026-11-03 14:21:47 +01:00
evgeny eab919c23b Document live DM device panels and release their UI callback 2026-11-03 14:18:48 +01:00
evgeny 888fe5f650 Keep DM device selection and scroll stable during refresh 2026-11-03 14:17:24 +01:00
evgeny 9e7366d5fc Open the Android DM device panel and validate snapshot decoding 2026-11-03 14:16:45 +01:00
evgeny 317ea6565f Add an Android panel for own and peer DM devices 2026-11-03 14:15:48 +01:00
evgeny cb354c4474 Expose DM device snapshots on Android and test desktop sections 2026-11-03 14:14:12 +01:00
evgeny 7bdeda499e Encode the DM member count explicitly as little endian 2026-11-03 14:13:02 +01:00
evgeny 88035fc25d Test current group devices without changing frozen delivery targets 2026-11-03 14:12:34 +01:00
evgeny a89c204586 Open and refresh DM member panels on conversation selection 2026-11-03 14:11:47 +01:00
evgeny 7939002dba Show own and peer devices in the DM member panel 2026-11-03 14:11:25 +01:00
evgeny 21702d6c01 Route DM device snapshots through the desktop bridge 2026-11-03 14:11:24 +01:00
evgeny 24e5baa8d4 Provide generation-bound DM device member snapshots 2026-11-03 14:10:16 +01:00
evgeny 0fd4f47c84 Expose current DM membership separately from delivery snapshots 2026-11-03 14:09:31 +01:00
evgeny 1f2f41abaa Translate deleted member controls and record committed changes 2026-11-03 14:01:51 +01:00
evgeny cacad65e8c Show deleted Android members in a collapsible group 2026-11-03 14:01:51 +01:00
evgeny be16b73087 Render and test the deleted member group 2026-11-03 14:01:51 +01:00
evgeny e836f07f4d Group deleted members separately in the desktop member list 2026-11-03 14:01:51 +01:00
evgeny 7ca717ff1f Document completed subgroup DM implementation and validation 2026-11-03 14:00:15 +01:00
evgeny f5e8f94336 Discard pending file opening events when clearing UI state 2026-11-03 13:56:33 +01:00
evgeny 0574d11037 Restore missing sender files from verified DM replicas 2026-11-03 13:52:11 +01:00
evgeny 1659a005c6 Refresh DM file status for each new recipient fact 2026-11-03 13:50:46 +01:00
evgeny b801ce533d docs: describe subgroup DM delivery, file keys and retention protocol 2026-11-03 13:44:45 +01:00
evgeny 15406c8e8a headless: expose DM mode selection and explicit attachment actions 2026-11-03 13:42:01 +01:00
evgeny 12b9841fca dm: retain conversation identity in errors and expiration events 2026-11-03 13:41:07 +01:00
evgeny 576236daeb tests: cover subgroup file receipts and endpoint storage policy across restart 2026-11-03 13:39:32 +01:00
evgeny 45f4ca0f31 ui: open PM files explicitly and translate conversation mode choices 2026-11-03 13:37:39 +01:00
evgeny 25d2ac981e dm: support colocated endpoint and storage with independent file lifetimes 2026-11-03 13:33:22 +01:00
evgeny c9b71ee1c9 media: allow independent revocation of endpoint and custody transfers 2026-11-03 13:32:27 +01:00
evgeny e4d9369e7d android: send taken receipts only after successful photo or video opening 2026-11-03 13:31:36 +01:00
evgeny c135825579 android: select device or subgroup PM and expose manual download 2026-11-03 13:31:04 +01:00
evgeny 75998f1606 android: show PM file state and identify messages by author and sequence 2026-11-03 13:28:50 +01:00
evgeny 1bd0bea2b4 android: obtain committed DM identity and expose attachment actions 2026-11-03 13:26:49 +01:00
evgeny 8a6a2072aa dm: keep invitations in the selected conversation mode 2026-11-03 13:25:27 +01:00
evgeny c2aafaf349 dm: honor pinned parties for calls and retry missing local files 2026-11-03 13:24:43 +01:00
evgeny daa9238431 chatgui: acknowledge PM files after viewer success 2026-11-03 13:23:38 +01:00
evgeny de9115b90a chatgui: report successful explicit media opening 2026-11-03 13:23:16 +01:00
evgeny 4c308be47f chatgui: distinguish manual PM downloads and preserve exact attachment actions 2026-11-03 13:22:53 +01:00
evgeny d0f7b2a364 dm: expose delivery facts and archived conversation state to clients 2026-11-03 13:21:41 +01:00
evgeny 445ead109c Wire desktop subgroup messaging and the fallback mode choice 2026-11-03 13:13:09 +01:00
evgeny b2fdf882b7 Choose subgroup or device DM from desktop member actions 2026-11-03 13:12:57 +01:00
evgeny a9534c4dd9 Open desktop conversations only for the matching start request 2026-11-03 13:11:57 +01:00
evgeny ba61e43713 Deliver correlated DM start results to the desktop UI 2026-11-03 13:11:40 +01:00
evgeny 22c6550f7e Return committed conversation IDs and correlate DM start requests 2026-11-03 13:10:59 +01:00
evgeny 6213a22779 Update custody fault fixtures for device facts and policy replies 2026-11-03 13:09:27 +01:00
evgeny d03c15cfce Initialize custody schema at the coordinator ownership boundary 2026-11-03 13:09:26 +01:00
evgeny 2d394ae74c Exercise explicit autoload and all-recipient retention in media regressions 2026-11-03 13:05:24 +01:00
evgeny ad73cf4097 Restore delivery snapshots for saved direct-message attachments 2026-11-03 13:04:39 +01:00
evgeny 85c9213576 Expose explicit download and successful user-open actions for DM files 2026-11-03 13:04:08 +01:00
evgeny 89b435e72c Honor local auto-download settings and persist storage demand independently 2026-11-03 13:03:25 +01:00
evgeny bfd14abdbb Probe all online attachment sources and choose the lowest measured RTT 2026-11-03 13:02:20 +01:00
evgeny c5c6d96384 Apply storage policy before cancellation and physical retirement 2026-11-03 13:00:16 +01:00
evgeny d5f3f74ba5 Freeze custody expiry and deletion policy at the first reservation 2026-11-03 12:59:35 +01:00
evgeny b700763062 Coordinate custody using device facts and the storage node policy 2026-11-03 12:58:57 +01:00
evgeny 1dcd2f1ce6 Separate file custody requests from per-device message mailboxes 2026-11-03 12:57:54 +01:00
evgeny 093bd92bad Define independent custody requests and source availability probes 2026-11-03 12:57:37 +01:00
evgeny 5013fc22d8 Commit verified file receipts separately from message delivery 2026-11-03 12:54:43 +01:00
evgeny 9ba34b0faa Validate downloaded and opened facts against the signed device snapshot 2026-11-03 12:54:07 +01:00
evgeny bd1e00e894 Initialize shared media schema and expose delete-on-open policy 2026-11-03 12:53:04 +01:00
evgeny cf12ee03fa Add per-device file facts and frozen storage policy to the schema 2026-11-03 12:52:55 +01:00
evgeny 7e7e670c0d Decrypt attachment key envelopes and retain verified ciphertext 2026-11-03 12:51:34 +01:00
evgeny de94b5bbaf Prepare attachments with an independent keypair and both party wraps 2026-11-03 12:51:10 +01:00
evgeny 08f9efb02b Register the subgroup DM integration test in the test suite 2026-11-03 12:49:52 +01:00
evgeny a33a913dbe Test subgroup DM fanout, author isolation, and atomic exit 2026-11-03 12:49:40 +01:00
evgeny 3899e091d1 Connect subgroup exit to DM closure and record sequence scope 2026-11-03 12:48:38 +01:00
evgeny a053d83a60 Cancel unfinished DM files when the local subgroup is left 2026-11-03 12:48:20 +01:00
evgeny a587e8dfdd Archive subgroup conversations atomically on membership exit 2026-11-03 12:47:46 +01:00
evgeny 5da3584a4d Test context flag without changing device body semantics 2026-11-03 12:39:46 +01:00
evgeny a1dd1fda75 Preserve empty device content types with an explicit context flag 2026-11-03 12:39:46 +01:00
evgeny 4dc5e1b11c Keep GUI history and media regression tests in device mode 2026-11-03 12:37:09 +01:00
evgeny d840688e96 Bind receipt mode and preserve device-mode GUI tests 2026-11-03 12:37:08 +01:00
evgeny 1b87f3efe9 Deliver immutable messages per device with ACK deadline fallback 2026-11-03 12:36:14 +01:00
evgeny 6c4e6254dd Use device mode in native Android and media regression tests 2026-11-03 12:34:38 +01:00
evgeny c2e503a90a Preserve device DM regression coverage with explicit mode 2026-11-03 12:34:38 +01:00
evgeny ce8f9d53f9 Keep call and attachment tests in explicit device DM mode 2026-11-03 12:34:22 +01:00
evgeny 452f5b4169 Expose DM mode and resolved conversation ID to headless clients 2026-11-03 12:34:21 +01:00
evgeny 8f4fec4247 Open fixed DEVICE or GROUPS conversations through the core 2026-11-03 12:33:57 +01:00
evgeny 7e508f720d Accept subgroup messages and distinguish own device replicas 2026-11-03 12:32:53 +01:00
evgeny 02087b1878 Store exact DM bodies with author identity and replay proofs 2026-11-03 12:31:14 +01:00
evgeny 5b734b9d83 Load pinned conversation keys from device-protected history 2026-11-03 12:30:41 +01:00
evgeny 9ff32d11c3 Test DM migration rollback and author sequence persistence 2026-11-03 12:27:11 +01:00
evgeny 2cb9d7a9c5 Test both attachment key envelopes and context isolation 2026-11-03 12:26:40 +01:00
evgeny b3460e2039 Build DM schema and require OpenSSL HPKE support 2026-11-03 12:26:39 +01:00
evgeny 451c66ede3 Use HPKE for per-attachment key pairs and two key envelopes 2026-11-03 12:25:32 +01:00
evgeny db8f3622d9 Add atomic DM schema migration and durable author sequence 2026-11-03 12:23:09 +01:00
evgeny f21c2582a2 Test subgroup context truncation and certificate tampering 2026-11-03 12:19:41 +01:00
evgeny 0ae66896bc Locate media through the common DM message view 2026-11-03 12:19:41 +01:00
evgeny 0573949808 Use shared bounds parser for DM body lengths 2026-11-03 12:19:05 +01:00
evgeny 2d8d57be47 Add common DM parser and signed subgroup recipient context 2026-11-03 12:18:25 +01:00
evgeny d81b3139fa Verify frozen device membership certificates independently 2026-11-03 12:17:30 +01:00
evgeny aa2e1b0319 Pin archived-history behavior after leaving a subgroup 2026-11-03 12:13:30 +01:00
evgeny 998e2dbb45 Keep recipient signature verification linear in device count 2026-11-03 12:11:52 +01:00
evgeny 8a08730be7 Expose verified membership without scanning subgroup profiles 2026-11-03 12:11:25 +01:00
evgeny 1dbc2bfce5 Record subgroup leave key retention decision 2026-11-03 12:10:23 +01:00
evgeny cf2880596c Reject ambiguous DM parties sharing an identity 2026-11-03 12:09:56 +01:00
evgeny c26d9e9a71 Test DM party resolution and immutable recipient snapshots 2026-11-03 12:08:58 +01:00
evgeny 26c8f7aa5a Build DM identity module and reject conflicting group keys 2026-11-03 12:07:47 +01:00
evgeny 58896d179e Add typed DM parties and verified recipient snapshots 2026-11-03 12:06:14 +01:00
evgeny 980afcdea8 Document subgroup DM delivery and attachment plan 2026-11-03 12:03:30 +01:00
262 changed files with 13959 additions and 2478 deletions

No files matched your search

-1
View File
@@ -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
+2
View File
@@ -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
+159 -34
View File
@@ -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 продолжает работать.
+320
View File
@@ -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; на физические устройства не устанавливались.
+36 -6
View File
@@ -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, конечные файлы и чужие имена сохраняются.
+13
View File
@@ -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 каждого вызова сохраняет контекст при вложенных доставках разных ядер.
Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.
+62
View File
@@ -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

+63
View File
@@ -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.
+738
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.
+99
View File
@@ -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

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

+129
View File
@@ -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 — роли; они могут находиться на одном узле».
Не показывай форматы пакетов, таблицы БД, размеры ключей и служебные таймеры. Сохрани ясную последовательность: кто участвует → чем шифруем → кому доставляем сообщение → откуда скачиваем файл.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

+37
View File
@@ -0,0 +1,37 @@
Создай один простой иллюстративный слайд 16:9 на русском языке.
Тип: infographic-diagram. Готовая иллюстрация для обычного пользователя, без технических знаний.
Главная мысль: все личные сообщения и вложения шифруются на устройстве отправителя и расшифровываются на устройстве получателя. Доставка возможна напрямую, через обычные устройства других участников группы и с хранением на сервере группы. ВСЕ узлы доставки, включая промежуточные устройства и сервер хранения, являются членами группы. Посторонние узлы в этой схеме не участвуют. Промежуточные узлы передают и хранят только зашифрованные данные и не могут прочитать содержимое личной беседы. Членство в группе само по себе не даёт ключей расшифровки личной беседы.
Стиль: современная спокойная инфографика, светлый фон, много свободного пространства, крупные чёткие русские подписи. Аккуратные простые иллюстрации смартфонов, ноутбука, сообщения, фотографии, документа, замков и небольшого сервера. Синий — отправитель, зелёный — получатель, золотой — защита, серый — промежуточные устройства и сервер. Без мелкого текста, таблиц, технических терминов и декоративного шума.
Вверху крупный заголовок, дословно:
«Личные сообщения и вложения зашифрованы».
Подзаголовок:
«Шифрование и расшифровка — на ваших устройствах».
Основная схема слева направо. Все устройства и маршруты заключены в одну общую светлую область с тонкой рамкой. На рамке крупная и хорошо заметная подпись:
«В доставке участвуют только узлы вашей группы».
Все изображённые смартфоны, ноутбуки и сервер находятся ВНУТРИ этой области. Не изображай внешние узлы, облака или маршруты за её пределами.
СЛЕВА — большой смартфон с подписью «Ваше устройство».
Внутри экрана видны понятные значки сообщения, фотографии и документа. У правого края экрана, внутри границы устройства, золотой закрытый замок: он обозначает шифрование перед выходом данных в сеть.
Подпись под смартфоном: «Шифрует перед отправкой».
СПРАВА — большой смартфон с подписью «Устройство собеседника».
У левого края экрана, внутри границы устройства, золотой открытый замок: расшифровка происходит здесь. Внутри экрана снова видны сообщение, фотография и документ.
Подпись под смартфоном: «Расшифровывает при получении».
МЕЖДУ УСТРОЙСТВАМИ — три ясно различимых пути доставки в трёх горизонтальных рядах.
Верхний путь: одна простая стрелка от левого смартфона к правому. На ней небольшой закрытый пакет с замком, без видимого содержимого. Короткая подпись: «Напрямую».
Средний путь: стрелки от отправителя через два небольших обычных устройства — ноутбук и смартфон — к получателю. Это такие же пользовательские устройства, как у отправителя и собеседника; они помогают пересылать зашифрованные данные. Изобрази только закрытые пакеты с замками на этом пути, не открытое содержимое. Подпись: «Через устройства участников группы».
Нижний путь: стрелки от отправителя через небольшой сервер к получателю. Подпись: «Через сервер группы».
На сервере изобрази закрытый пакет с замком. Под ним короткая подпись: «Хранит только зашифрованные копии».
Под промежуточными устройствами и сервером — одна общая ясная подпись: «Промежуточные узлы не могут прочитать содержимое».
Стрелки направлены от отправителя к получателю. Не пересекай стрелки и подписи. Понятный текст сообщений, фото и документы видны только внутри двух больших смартфонов отправителя и получателя. В пути и на промежуточных узлах — только закрытые пакеты. Не показывай расшифровку на промежуточных устройствах или сервере.
Внизу одна спокойная завершающая подпись со значком ключа:
«Ключи расшифровки — только на устройствах участников личной беседы».
Важно не создавать впечатление, что любой участник группы может читать личную переписку. Участники группы помогают доставлять; расшифровать могут только стороны личной беседы.
Ограничения: не упоминай алгоритмы, пары ключей, подгруппы, ACK, RTT, TTL, протоколы, базы данных и настройки удаления. Не добавляй обещаний анонимности или абсолютной защиты. Это одна простая схема для пользователя, а не архитектурная документация. Сохрани все перечисленные короткие подписи, не добавляй пояснительных абзацев. Никаких водяных знаков.
+6 -2
View File
@@ -311,8 +311,12 @@ DONE выдачи следует только после проверки бло
Локальное удаление канала ждёт его исходящие video/register задачи. Последний
completion продолжает удаление; новые исходящие медиа канала в это время отклоняются,
подготовленные результаты ожидающих отправок не публикуются как новые сообщения.
После завершения задач отменяются загрузки/раздача и удаляются учтённые файлы, включая
назначения неуспешных подготовок. Общие файлы других каналов сохраняются. Очистка файлов
После завершения задач сначала фиксируется удаление канала и его записей в SQLite.
SQL-отказ сохраняет работающую группу, запросы ключей, outbox и файлы. После commit
группа помечается stopping до любых cleanup callbacks; затем отменяются загрузки/раздача,
удаляются группа и DB_SYNC, очищаются учтённые файлы, включая назначения неуспешных подготовок.
Callback медиа-загрузки для отсутствующей/stopping группы только освобождает свой контекст.
Общие файлы других каналов сохраняются. Очистка файлов
выполняется вне внешней транзакции: SQL-откат не восстанавливает удалённый файл.
При ошибке очистки записи оставшихся файлов сохраняются для повторного вызова;
удаление метаданных канала и неполная файловая очистка диагностируются отдельно.
+135
View File
@@ -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.
+2
View File
@@ -0,0 +1,2 @@
# Главная страница презентации; общее правило репозитория исключает index.html.
!index.html
+62
View File
@@ -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

+99
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+307
View File
@@ -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>
+74
View File
@@ -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);
+241
View File
@@ -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; }
}
+18 -2
View File
@@ -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 \
+11 -5
View File
@@ -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) {
+1 -1
View File
@@ -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 не добавляет.
+41 -7
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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;
+27 -8
View File
@@ -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)",
+131 -11
View File
@@ -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;
}
+9 -1
View File
@@ -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 ответа либо нули.
+34 -26
View File
@@ -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);
+8 -5
View File
@@ -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);
+4
View File
@@ -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;
+6
View File
@@ -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]);
+23 -2
View File
@@ -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;
}
+3
View File
@@ -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 или результат смены прав приглашения. */
// ── Подключение получателя событий ──
+7 -4
View File
@@ -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;
+52 -2
View File
@@ -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; }
}
+97 -26
View File
@@ -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; }
+21 -11
View File
@@ -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);
+3 -2
View File
@@ -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);
+11 -1
View File
@@ -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)) {
+3 -2
View File
@@ -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); }
+1
View File
@@ -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},
+1 -1
View File
@@ -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);
+99 -19
View File
@@ -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);
+6
View File
@@ -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.
+27 -6
View File
@@ -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;
+4
View File
@@ -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 блокирует входящий и исходящий обмен. После снятия
+21 -1
View File
@@ -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);
+2
View File
@@ -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);
// ── Чтение и изменение аватарки ──
+428
View File
@@ -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);
}
+69
View File
@@ -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
+2 -1
View File
@@ -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);
+605 -174
View File
File diff suppressed because it is too large. Load diff
+30 -11
View File
@@ -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-группы;
+42 -16
View File
@@ -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,
+126 -51
View File
@@ -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);
}
+22 -4
View File
@@ -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 секунд.
+3
View File
@@ -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 вызывается один раз.
+477 -162
View File
File diff suppressed because it is too large. Load diff
+44 -13
View File
@@ -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; запрос создаётся обнулённым. */
+108
View File
@@ -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");
}
+23
View File
@@ -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
+18 -10
View File
@@ -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;
+224
View File
@@ -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, &current)) return -1;
return dm_party_equal(party, &current);
}
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;
}
+76
View File
@@ -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
+68 -61
View File
@@ -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;
}
+14 -6
View File
@@ -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), проверяется подпись получателя.
+239 -105
View File
@@ -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;
+8 -4
View File
@@ -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 общей группы.
+712 -313
View File
File diff suppressed because it is too large. Load diff
+45 -10
View File
@@ -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);
+1 -1
View File
@@ -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.
+141
View File
@@ -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;
}
+24
View File
@@ -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
+155
View File
@@ -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;
}
+60
View File
@@ -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
+229
View File
@@ -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;
}
+22
View File
@@ -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
+66 -7
View File
@@ -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);
}
}
+13 -4
View File
@@ -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
+74 -39
View File
@@ -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);
}
+8 -5
View File
@@ -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);
+5 -1
View File
@@ -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))
+37 -37
View File
@@ -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;
+3
View File
@@ -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; несовпадающие/истёкшие попытки игнорируются.
* Повреждённый пакет не меняет состояние; ошибка поиска продолжает обход источников.
+7
View File
@@ -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 ошибкой.
+89 -9
View File
@@ -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) {
+3
View File
@@ -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);
// ── Команды файлового сервера ──
+2
View File
@@ -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);
+66 -33
View File
@@ -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);
}
}
+26 -3
View File
@@ -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. */
Loaded 100 of 262 files, more files were not shown because too many files have changed in this diff. Show more