From 698d0ca3f76728b85a976da7caf67d73ca0112f8 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 26 Oct 2025 11:20:41 +0000 Subject: [PATCH] dht --- .gitignore | 1 + ...d3e5f7a9c0d1_create_dht_and_rdap_tables.py | 70 +++++ app/__main__.py | 27 +- app/api/__init__.py | 13 +- app/api/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 7562 bytes .../__pycache__/middleware.cpython-310.pyc | Bin 0 -> 6223 bytes .../__pycache__/_blockchain.cpython-310.pyc | Bin 0 -> 10384 bytes .../routes/__pycache__/_index.cpython-310.pyc | Bin 0 -> 591 bytes .../__pycache__/_system.cpython-310.pyc | Bin 0 -> 3680 bytes .../__pycache__/account.cpython-310.pyc | Bin 0 -> 408 bytes .../routes/__pycache__/admin.cpython-310.pyc | Bin 0 -> 59539 bytes .../routes/__pycache__/auth.cpython-310.pyc | Bin 0 -> 5001 bytes .../__pycache__/content.cpython-310.pyc | Bin 0 -> 15630 bytes .../__pycache__/content_index.cpython-310.pyc | Bin 0 -> 2223 bytes .../__pycache__/derivatives.cpython-310.pyc | Bin 0 -> 1272 bytes .../routes/__pycache__/dht.cpython-310.pyc | Bin 0 -> 4732 bytes .../routes/__pycache__/keys.cpython-310.pyc | Bin 0 -> 3686 bytes .../__pycache__/metrics.cpython-310.pyc | Bin 0 -> 1697 bytes .../__pycache__/network.cpython-310.pyc | Bin 0 -> 8216 bytes .../network_events.cpython-310.pyc | Bin 0 -> 2374 bytes .../__pycache__/node_storage.cpython-310.pyc | Bin 0 -> 6883 bytes .../progressive_storage.cpython-310.pyc | Bin 0 -> 12441 bytes .../__pycache__/statics.cpython-310.pyc | Bin 0 -> 690 bytes .../routes/__pycache__/sync.cpython-310.pyc | Bin 0 -> 2254 bytes .../__pycache__/tonconnect.cpython-310.pyc | Bin 0 -> 2297 bytes .../__pycache__/upload_status.cpython-310.pyc | Bin 0 -> 2304 bytes .../__pycache__/upload_tus.cpython-310.pyc | Bin 0 -> 8245 bytes app/api/routes/admin.py | 271 +++++++++++++++++- app/api/routes/content.py | 91 +++++- app/api/routes/dht.py | 125 ++++++++ app/api/routes/network.py | 62 +++- app/api/routes/progressive_storage.py | 130 +++++++++ app/bot/__init__.py | 9 +- app/client_bot/__init__.py | 9 +- app/core/models/dht.py | 22 ++ app/core/models/memory.py | 4 +- app/core/models/rdap.py | 16 ++ app/core/network/asn.py | 59 +++- .../dht/__pycache__/config.cpython-310.pyc | Bin 2228 -> 2449 bytes .../__pycache__/membership.cpython-310.pyc | Bin 9407 -> 10391 bytes .../__pycache__/prometheus.cpython-310.pyc | Bin 3357 -> 4349 bytes .../__pycache__/replication.cpython-310.pyc | Bin 11277 -> 12353 bytes .../dht/__pycache__/store.cpython-310.pyc | Bin 2305 -> 5208 bytes app/core/network/dht/config.py | 5 +- app/core/network/dht/membership.py | 18 +- app/core/network/dht/prometheus.py | 18 ++ app/core/network/dht/replication.py | 47 ++- app/core/network/dht/store.py | 83 ++++++ app/core/network/maintenance.py | 91 +++++- 49 files changed, 1118 insertions(+), 53 deletions(-) create mode 100644 alembic/versions/d3e5f7a9c0d1_create_dht_and_rdap_tables.py create mode 100644 app/api/__pycache__/__init__.cpython-310.pyc create mode 100644 app/api/__pycache__/middleware.cpython-310.pyc create mode 100644 app/api/routes/__pycache__/_blockchain.cpython-310.pyc create mode 100644 app/api/routes/__pycache__/_index.cpython-310.pyc create mode 100644 app/api/routes/__pycache__/_system.cpython-310.pyc create mode 100644 app/api/routes/__pycache__/account.cpython-310.pyc create mode 100644 app/api/routes/__pycache__/admin.cpython-310.pyc create mode 100644 app/api/routes/__pycache__/auth.cpython-310.pyc create mode 100644 app/api/routes/__pycache__/content.cpython-310.pyc create mode 100644 app/api/routes/__pycache__/content_index.cpython-310.pyc create mode 100644 app/api/routes/__pycache__/derivatives.cpython-310.pyc create mode 100644 app/api/routes/__pycache__/dht.cpython-310.pyc create mode 100644 app/api/routes/__pycache__/keys.cpython-310.pyc create mode 100644 app/api/routes/__pycache__/metrics.cpython-310.pyc create mode 100644 app/api/routes/__pycache__/network.cpython-310.pyc create mode 100644 app/api/routes/__pycache__/network_events.cpython-310.pyc create mode 100644 app/api/routes/__pycache__/node_storage.cpython-310.pyc create mode 100644 app/api/routes/__pycache__/progressive_storage.cpython-310.pyc create mode 100644 app/api/routes/__pycache__/statics.cpython-310.pyc create mode 100644 app/api/routes/__pycache__/sync.cpython-310.pyc create mode 100644 app/api/routes/__pycache__/tonconnect.cpython-310.pyc create mode 100644 app/api/routes/__pycache__/upload_status.cpython-310.pyc create mode 100644 app/api/routes/__pycache__/upload_tus.cpython-310.pyc create mode 100644 app/api/routes/dht.py create mode 100644 app/core/models/dht.py create mode 100644 app/core/models/rdap.py diff --git a/.gitignore b/.gitignore index 3b1fb24..231f844 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ playground .DS_Store messages.pot activeConfig +__pycache__ diff --git a/alembic/versions/d3e5f7a9c0d1_create_dht_and_rdap_tables.py b/alembic/versions/d3e5f7a9c0d1_create_dht_and_rdap_tables.py new file mode 100644 index 0000000..f528e6e --- /dev/null +++ b/alembic/versions/d3e5f7a9c0d1_create_dht_and_rdap_tables.py @@ -0,0 +1,70 @@ +"""create dht_records and rdap_cache tables + +Revision ID: d3e5f7a9c0d1 +Revises: c2d4e6f8a1b2 +Create Date: 2025-10-22 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd3e5f7a9c0d1' +down_revision: Union[str, None] = 'c2d4e6f8a1b2' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + # dht_records + if not inspector.has_table('dht_records'): + op.create_table( + 'dht_records', + sa.Column('fingerprint', sa.String(length=128), primary_key=True), + sa.Column('key', sa.String(length=512), nullable=False), + sa.Column('schema_version', sa.String(length=16), nullable=False, server_default='v1'), + sa.Column('logical_counter', sa.Integer(), nullable=False, server_default='0'), + sa.Column('timestamp', sa.Float(), nullable=False, server_default='0'), + sa.Column('node_id', sa.String(length=128), nullable=False), + sa.Column('signature', sa.String(length=512), nullable=True), + sa.Column('value', sa.JSON(), nullable=False, server_default=sa.text("'{}'::jsonb")), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + ) + # ensure index exists (but don't fail if it already exists) + try: + existing_indexes = {idx['name'] for idx in inspector.get_indexes('dht_records')} + except Exception: + existing_indexes = set() + if 'ix_dht_records_key' not in existing_indexes: + op.create_index('ix_dht_records_key', 'dht_records', ['key']) + + # rdap_cache + if not inspector.has_table('rdap_cache'): + op.create_table( + 'rdap_cache', + sa.Column('ip', sa.String(length=64), primary_key=True), + sa.Column('asn', sa.Integer(), nullable=True), + sa.Column('source', sa.String(length=64), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + ) + + +def downgrade() -> None: + try: + op.drop_table('rdap_cache') + except Exception: + pass + try: + op.drop_index('ix_dht_records_key', table_name='dht_records') + except Exception: + pass + try: + op.drop_table('dht_records') + except Exception: + pass diff --git a/app/__main__.py b/app/__main__.py index 4c4ea1c..af746e4 100644 --- a/app/__main__.py +++ b/app/__main__.py @@ -100,16 +100,12 @@ if __name__ == '__main__': except Exception as e: make_log('Startup', f'DB sync init failed: {e}', level='error') from app.api import app - from app.bot import dp as uploader_bot_dp - from app.client_bot import dp as client_bot_dp + # Delay aiogram dispatcher creation until loop is running from app.core._config import SANIC_PORT, PROJECT_HOST, DATABASE_URL from app.core.network.nodes import network_handshake_daemon, bootstrap_once_and_exit_if_failed - from app.core.network.maintenance import replication_daemon, heartbeat_daemon + from app.core.network.maintenance import replication_daemon, heartbeat_daemon, dht_gossip_daemon app.ctx.memory = main_memory - for _target in [uploader_bot_dp, client_bot_dp]: - _target._s_memory = app.ctx.memory - app.ctx.memory._app = app # Ensure DB schema exists using the same event loop as Sanic (idempotent) @@ -117,13 +113,28 @@ if __name__ == '__main__': app.add_task(execute_queue(app)) app.add_task(queue_daemon(app)) - app.add_task(uploader_bot_dp.start_polling(app.ctx.memory._telegram_bot)) - app.add_task(client_bot_dp.start_polling(app.ctx.memory._client_telegram_bot)) + # Start bots after loop is ready + async def _start_bots(): + try: + from app.bot import create_dispatcher as create_uploader_dp + from app.client_bot import create_dispatcher as create_client_dp + uploader_bot_dp = create_uploader_dp() + client_bot_dp = create_client_dp() + for _target in [uploader_bot_dp, client_bot_dp]: + _target._s_memory = app.ctx.memory + await asyncio.gather( + uploader_bot_dp.start_polling(app.ctx.memory._telegram_bot), + client_bot_dp.start_polling(app.ctx.memory._client_telegram_bot), + ) + except Exception as e: + make_log('Bots', f'Failed to start bots: {e}', level='error') + app.add_task(_start_bots()) # Start network handshake daemon and bootstrap step app.add_task(network_handshake_daemon(app)) app.add_task(bootstrap_once_and_exit_if_failed()) app.add_task(replication_daemon(app)) app.add_task(heartbeat_daemon(app)) + app.add_task(dht_gossip_daemon(app)) app.run(host='0.0.0.0', port=SANIC_PORT) else: diff --git a/app/api/__init__.py b/app/api/__init__.py index cdca550..2e2e219 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -25,7 +25,7 @@ from app.api.routes.auth import s_api_v1_auth_twa, s_api_v1_auth_select_wallet, from app.api.routes.statics import s_api_tonconnect_manifest, s_api_platform_metadata from app.api.routes.node_storage import s_api_v1_storage_post, s_api_v1_storage_get, \ s_api_v1_storage_decode_cid -from app.api.routes.progressive_storage import s_api_v1_5_storage_get, s_api_v1_5_storage_post +from app.api.routes.progressive_storage import s_api_v1_5_storage_get, s_api_v1_5_storage_post, s_api_v1_storage_fetch, s_api_v1_storage_proxy from app.api.routes.upload_tus import s_api_v1_upload_tus_hook from app.api.routes.account import s_api_v1_account_get from app.api.routes._blockchain import s_api_v1_blockchain_send_new_content_message, \ @@ -52,12 +52,16 @@ from app.api.routes.admin import ( s_api_v1_admin_system, s_api_v1_admin_uploads, s_api_v1_admin_users, + s_api_v1_admin_network, + s_api_v1_admin_network_config, + s_api_v1_admin_network_config_set, ) from app.api.routes.tonconnect import s_api_v1_tonconnect_new, s_api_v1_tonconnect_logout from app.api.routes.keys import s_api_v1_keys_request from app.api.routes.sync import s_api_v1_sync_pin, s_api_v1_sync_status from app.api.routes.upload_status import s_api_v1_upload_status from app.api.routes.metrics import s_api_metrics +from app.api.routes.dht import s_api_v1_dht_get, s_api_v1_dht_put app.add_route(s_index, "/", methods=["GET", "OPTIONS"]) @@ -84,6 +88,8 @@ app.add_route(s_api_v1_tonconnect_logout, "/api/v1/tonconnect.logout", methods=[ app.add_route(s_api_v1_5_storage_post, "/api/v1.5/storage", methods=["POST", "OPTIONS"]) app.add_route(s_api_v1_5_storage_get, "/api/v1.5/storage/", methods=["GET", "OPTIONS"]) +app.add_route(s_api_v1_storage_fetch, "/api/v1/storage.fetch/", methods=["GET", "OPTIONS"]) +app.add_route(s_api_v1_storage_proxy, "/api/v1/storage.proxy/", methods=["GET", "OPTIONS"]) app.add_route(s_api_v1_storage_post, "/api/v1/storage", methods=["POST", "OPTIONS"]) app.add_route(s_api_v1_storage_get, "/api/v1/storage/", methods=["GET", "OPTIONS"]) @@ -119,6 +125,9 @@ app.add_route(s_api_v1_admin_status, "/api/v1/admin.status", methods=["GET", "OP app.add_route(s_api_v1_admin_cache_setlimits, "/api/v1/admin.cache.setLimits", methods=["POST", "OPTIONS"]) app.add_route(s_api_v1_admin_cache_cleanup, "/api/v1/admin.cache.cleanup", methods=["POST", "OPTIONS"]) app.add_route(s_api_v1_admin_sync_setlimits, "/api/v1/admin.sync.setLimits", methods=["POST", "OPTIONS"]) +app.add_route(s_api_v1_admin_network, "/api/v1/admin.network", methods=["GET", "OPTIONS"]) +app.add_route(s_api_v1_admin_network_config, "/api/v1/admin.network.config", methods=["GET", "OPTIONS"]) +app.add_route(s_api_v1_admin_network_config_set, "/api/v1/admin.network.config.set", methods=["POST", "OPTIONS"]) # tusd HTTP hooks app.add_route(s_api_v1_upload_tus_hook, "/api/v1/upload.tus-hook", methods=["POST", "OPTIONS"]) @@ -129,6 +138,8 @@ app.add_route(s_api_v1_sync_pin, "/api/v1/sync.pin", methods=["POST", "OPTIONS"] app.add_route(s_api_v1_sync_status, "/api/v1/sync.status", methods=["GET", "OPTIONS"]) app.add_route(s_api_v1_upload_status, "/api/v1/upload.status/", methods=["GET", "OPTIONS"]) app.add_route(s_api_metrics, "/metrics", methods=["GET", "OPTIONS"]) +app.add_route(s_api_v1_dht_get, "/api/v1/dht.get", methods=["GET", "OPTIONS"]) +app.add_route(s_api_v1_dht_put, "/api/v1/dht.put", methods=["POST", "OPTIONS"]) @app.exception(BaseException) diff --git a/app/api/__pycache__/__init__.cpython-310.pyc b/app/api/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a2d1ae0200ed44b9568777169983044f0daff5ae GIT binary patch literal 7562 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 literal 0 HcmV?d00001 diff --git a/app/api/routes/__pycache__/_blockchain.cpython-310.pyc b/app/api/routes/__pycache__/_blockchain.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2d8ba2171af978a7490865be470ede2be1ea770b GIT binary patch literal 10384 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 B*Hr)j literal 0 HcmV?d00001 diff --git a/app/api/routes/__pycache__/_index.cpython-310.pyc b/app/api/routes/__pycache__/_index.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6312067b906f686d4bd15d8b7c15f5fa406fbd20 GIT binary patch literal 591 zcmZXRv2N5r5Qca6e6a(Uh9<2kC|u;$;*!%HicnHgOOc5CYn3+wZ@)ho`}kG{E;1l0!=m{Ky# z@RC*hm~&=?8GK^L!bE0B`@n2GV_E!@o?;pA5(n7W8bOojzZL2s#diw4N9iN?>TT**kRBYpOb?$vxu1zH##U=U?Ba^DT0$mVn6Zma(bm_z ziygdc0sS>|37!9X&^D!22HehT>!oTMib6WudO&$n)CSJS%?0VM>nMyCED;|fF6c^r zyU_l}F`&bqV?@ObM^7LYmv2|zH~2`(W#Pw-v!|$431b6ivbxv_n?eMS(y8~7NUOrj z**pg`hiR@J?y2dXo+&fUb2Bx$G83hDcV{_F^2uBl6=9<+?b42$dU<02S#Amk+Pe*! zsE1#tO=`PMJb9ze3T^AV)MBt^O&I-gfnbqE-4In>=-$qrxxaBsD7@r)wPe4trY+%d O_@fxUH)6?P6#oT$1c)>M literal 0 HcmV?d00001 diff --git a/app/api/routes/__pycache__/_system.cpython-310.pyc b/app/api/routes/__pycache__/_system.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d9a0559d05523bd77c07b21ecf1b87c650bdb19c GIT binary patch literal 3680 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

utwjHc5=&!M#q>!6(=tln~+-O23)= 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 literal 0 HcmV?d00001 diff --git a/app/api/routes/__pycache__/content_index.cpython-310.pyc b/app/api/routes/__pycache__/content_index.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4e7e2165946805e09d95c91a76cfdd89ef2a10d0 GIT binary patch literal 2223 zcmah~OK%)S5bo}I?$eIrI1eBg69h}*wM`C)$T5L93O-;VIU-_3G@4Ae*R!6-x~Dg> zSF@5M=NbvlanSA|hx`u2e?Tp6Iq?^KfvVZyIDnMd?V9SI>YA>{_f1->l?hzG|MJ_` zb(fHrI5~MSnA{+QWX^T?SS;S(aWfZy*nX%Qf3f+wC*lyW{ZbeS)w%kIu zqf%UMl|gs7`w+pkw=)rmK;bI) zl~7@ftCKg=M2SSLC0y*TDG}rI*xwdjl(qwkZeLvxA+cfniV^(*$|IVS4(ZS#Q-(4R z=!mXCWZ#gXH6lZMLo&K{GTu95>fV9MbJH(}0j&Mj^~VFQCZx$%^6SGhSmprDnW zSpyS1mxg6t9?^l7Tk>{p<>oe%w^gMB5%cO3Iu?XTPC@^QFy)Q4xHQ>)_9$h z5u;>hP1X4XpFH+`DmR~!XXXJsQeFbp|^a%heq9cM{Wq~XwbPJd=Q42orF+FA!$6G(7J_ljy%3Au?C$CEaujANNaOBG4R&W3?lsc?H1lAW_h89; zbH{R&27Z*SG+%G~Pqs|-gW0+S$|KBHZ2%b>LQY0hLY~Ij_XNtsIEB10Spm~^Xh5Sa zAR;*dQ_VtEL^KvtxKvthi$v^psI zzXfl0xY+c2kvcN&F3f4#Jfd^`+S=y)dYXv&Z+#VPg^K`+7YHYSSufM(tvi0ZIC;PJ zDof2GuYEfNIAz!S_N-8T`})yTyf>c+^)Qv&3x&mvB`BL!Sb@7>OT>O-QX7HV)n$I) z%S4s|jXrOKiycw zY!(FDpQUv=!)9o;_?4+iU0At5Cn@UkGcbCiZ?MaeW(ta_L!QSJGXw+yM4@}KNyTy? zk1^3~kbXr0&Y+hp&bTo`AhWw9nKFs8_vR_|769GpxZLDcNV(0OC(I^40E{0(M|Mh` z^01<+s&<0B>Ofu-Kwd_h01YAi7^~!70|>(IOv>+24o?BHS%GYVgd^kJ$?3KUcXK!e zj9_<810&d-X&7g?d&r){`6&S_tFUqg*3PPOhAvQ;g+zX6z{y=+$t@tjs+!4xPC=_Z zvvRj{me+yeTtLXgy0)?~353Kf_3g_=-$IF{OrnRa@&lnu zw{`>3#cxm1dh#;v_yENmiVsm7_prQ#(~nRf<;Xe+Z39t>gv%x@CqR_P37f6W?Z(X;2E ze=kDsnw0HN(RUg2?w7D(y;0L;&)e*&o)n%Zku{+}ei8;_?3J^f$d3@%d*i^1^j&}; zn?Wi?Gfv?I8R*3)*n4nwjP8kJ1Na8Fp}2_K0xiXBPsCM3Sa71d49f$ NRR{xqAYG$+`fqpRAyohX literal 0 HcmV?d00001 diff --git a/app/api/routes/__pycache__/derivatives.cpython-310.pyc b/app/api/routes/__pycache__/derivatives.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6e54a63f34a10f07a3f022ae4b92465ca4feca29 GIT binary patch literal 1272 zcmZWp-HsbI6t+FTnM~SjT1s1Z08+t*ZPmL9LA#3zLRsjhH<6GvJN71%&fnUew5cXi zt8$CP1$Qkdx#T$z?|^*ERbOFm;CMD!t%Rjxf40Bl<8waG6rE0hp#A;(U(>OJ&_8B! zxe=Iri4d0G0bq#X6lHjfvB4xInKiZ=Y^8SQjGYGCshfFYFZ0KKGk4Nf7K{VHF7tju z<2Lh;P`~xw#OY(*j`F-vkxGhO!qgXBmH;_mSMrp{s!w$HAdkhORGd93a>a9{cQ#<3 zi)0SobK?+x|K#x7gGVF!`0!{HV-sQ1S}>Ya|1`<;VCOZi(G0CH!)v_6H7qJJvlwAk zf|<>npUDvdhCOqZq$Vp@dCGr-S1skwf*L;~Yw-I9%J3Mi+H155*Jx?gmb||?#f4M6 z%_-;JoyNN}yHZ=TtN-)wg1OTKcuKx(@6Qz-};9VWA8H`*1=G1JF&dTVS_qpM~$-H3)b*RAgneldZM87_KI^0iwg&mE~ z@ZkNw&|dZiQJDcZpQo>8p9)H2(3ZmIS z67aN(a4+sRziqcdEX#rGPMrx`ZS*D2+ zo*}zYS@N8Tu9@s?Q>0QwLeWg>s~1baf(6k7qPS){lWf1GJ;6^a4#As*y=@MJ$J!Rf zb4a(Sl*U50OmI4lm`EuQsk%5kyjzMaS!|WlV-dFy^pVx8!+1VX4Bi4;NYz5i@V@3?1ws_ z^rTXi;FJpU1A%Hqc@m%N70A;xO5-Wd7L7LgHr#VJE(G7r3dU0jxcS2A{9dEmxgy;Q ombvdqGHGhxgluxZ)a{v&{sTCzO8FP&Cm=)ULKKn^cfmsIKM}lYSpWb4 literal 0 HcmV?d00001 diff --git a/app/api/routes/__pycache__/dht.cpython-310.pyc b/app/api/routes/__pycache__/dht.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..efdc2a3052679d188524af62cac2c66a8e668a0a GIT binary patch literal 4732 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 literal 0 HcmV?d00001 diff --git a/app/api/routes/__pycache__/metrics.cpython-310.pyc b/app/api/routes/__pycache__/metrics.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..91bc842f254fa0ef4241f24a88adc3b41e6d9714 GIT binary patch literal 1697 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 literal 0 HcmV?d00001 diff --git a/app/api/routes/__pycache__/network_events.cpython-310.pyc b/app/api/routes/__pycache__/network_events.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..28f3571f6b1f40ff24be7acb4070e2ee1887f846 GIT binary patch literal 2374 zcmZVf-Bl{4gWNiO$8t51K+X;Wt%OKPu0;nucfSdAkEvYa-HCIrP9t+>lC&&*n0 zmkYFzuLc?<$fYi@i=KMuDd(Je>2H{8(1VV>8Ag%x4Y{@zgajYoypMSw^WK}ZUUxKj zzPb9}qg}whRg1Ha4vXJt8sc98LNtOREk<306pkYt>s>uIx<;|qqe^Uc%>p-~YHW3_ z0~x(1H=|nYcHOw%trzQR)QFqiW`SE#E1v7l6}TO>~S|6&%rE;4xo-ZWxD( z=6 zd3O_}{)}?1Kl3hY0FDc&XAOcU2GOU880WM}WvY#lFo9p~TYWo6CzxLoPQOMf#0(Ls z66-mBpb0nEUdx=|Kj>pT*2hMU`L9da+{k`OY>;HPyOd6soS|?~SCtCe> zt`G1O^0f{5x~EO%!Md57>>xM$3nk1x3zgTkDM}F0#?`z^+=0&iC>B+5<@#T>zhPBg z&vm5D{G0>3?b7a3S1f_;W!PbUin3?KI6-6UcUn?6G_fMi9pcfKNK-E{#?~iVf{rzD zKDR&)kF7J>3!wdXvIyGt5469YX)liL*V<%BT*z&*e1c&QdlvDDGioJR8|EWazTkSi(ckWZ|u=N zbUP)hW!|`!oVBQk*uBRGpTd>Nb19oNT+bg6Qc|q z&-{_j0!3^7JreF(kDmtFyT-?OEm^XE+_ms;UPM&IsnV4~YSDWkT3Rwb2Cy zt}0OY0z!NkL5M6p=4?aJe-1$EurAHNnbB-sF_yUpli7Rx3-5Qe^{n~?uE(+l-HHQ2~ygcoqs#e}8%u=$hjsTjWtjVPx zv3o<99c4+k-Y~r&O%6e)xFVO|kjvIt=afAT16o#AQ6(z$w-;!M6(?l<3OpP}^rq@= Wz5>A2T97dpxwr-CEni1>jsF9w8I9fm literal 0 HcmV?d00001 diff --git a/app/api/routes/__pycache__/node_storage.cpython-310.pyc b/app/api/routes/__pycache__/node_storage.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..195a3634a0245451e2023b388a7861693d598992 GIT binary patch literal 6883 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 literal 0 HcmV?d00001 diff --git a/app/api/routes/__pycache__/progressive_storage.cpython-310.pyc b/app/api/routes/__pycache__/progressive_storage.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..05b5f80f1ba73b4657c6c33f7c37e081698ebfd2 GIT binary patch literal 12441 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 N^s(@E{M7jH{{tDy4q5;J literal 0 HcmV?d00001 diff --git a/app/api/routes/__pycache__/statics.cpython-310.pyc b/app/api/routes/__pycache__/statics.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..68ebc139196ece914d61db1c70dc266893f4cd12 GIT binary patch literal 690 zcmZWnPixdb6rY(SyU}K~dJ#Pdf)H?b?$T;O1Qpzhtq1oKW;3(9DVa=sGof@(3VsA{ zR?w65bNDUh>dCKAP<)duSa8Do<^9Y1^UIjg=qd^4)A!XDBjjgqTmtlwJrw%@K@v%8 zqUns%ptG9kXcnO!$@n9g#WIltd?#{vK=Sk#enE2TQ&6@wja8^;uU_xJc>3h9c(#9V zSkjPjae_WT`GI2ZAbg;Xe5Ht0VI^BEu z_U_b3b^d(iTx%b4zO3A;n@>ukc?;&qmZDZ%pNxgI%2__I&75mdH5|)~FX~L%X89Pz z@vehT<&lp%sD0cBt^BaUoHtPC5d>a*d}K`n5vpg@4?(@_lyzqWw$O8aziVqFq=NC> zI4;^2rQ%>Zr!2QlxT>_1_Qc<|1qOK=d>46x-IfGEyCzTdUg;v-F(g0Tigy}aBdiz_O`Py2zg#le? z<%ASYT*IIZmnTfu&-MqdqdCn7KC!q$xglvASI0j9YEmG3i)z^u*00g$U+UL)?wh7N U+Q_a}kHWREVYZB9ERC}CFH<_C;s5{u literal 0 HcmV?d00001 diff --git a/app/api/routes/__pycache__/sync.cpython-310.pyc b/app/api/routes/__pycache__/sync.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4b3b884556fc9693184ab17a188d11c9730df230 GIT binary patch literal 2254 zcmZuyNpBoQ6z-~Crn_hLc+Ex(iGYcSEyRf^ELj|46b!_ULPkifPFLCPac`-r@nozX z6l4xuEWw$8W{x@JS0Mfa>MLBrKOhLit7BPh~h)I~`0IKnaRV#Vu`9vfXl@kV6ER@YLz8C7DtYsXI4 zQFAMD<7&66_)1iZ>)m?X=r+{cj+$|++fuv}O~vhQ8+eygztOtWq(L*Dmd=aJzhpA0`K}8r-$ur6`5KG?TG@^m%;G&X+iRTkiSdxj{1qP4n0Qi^(hx1X$9Lf*zp(~>qTK4iU7%* z9e!09Wq5y<0sI42zDBv$)5eIP3CbG4V@36>LGaih`UDZvcmj7{h4f$7#?upRJOg*N zbDQsrRu0w(CJoXgtpgLJmGNwDPoP+Uo119mE;W-@=atp@-a>Boj{UD)CwKT`F)!E2 z6lovek)v!nFWcOk7RTZAX)<$wkIo^px#OJ>C&#BI8gQ@N){?V^CQc83N7~3m+TMa# zBy(gwcYn}+#1Pws104$PnA-Jf?(QStUc0T`0dGg{S8igK;{Xk$M`!-?NVwxFna-Ww z33B`)L!DihCgScHj1-#DhFFU@!j+2!3!5w@@3+ zVFS$}7rV%SnT_9s5o3&we%4=ZrGd_Yw1HgsW86j-j14pc;~cUd;J+J28?ozP^o7PK zlO)5U8e;~`^Nn$Ya>UyL_jG6`Iy5DNn6lmNHH87K@UZ!eaE{q4#7dwYj&y)_9iVLv zmyjlZQU;9<-_a5PtH5JDN4cJ3Vn2i?3-EN-*kw4EH6@vqWKNPPNlpVPTzFSxR3M)n zz0^G`r!8O!8sM{oBX zg(8KHLUy0hc0$Di-W1T0muNYRJ}WOaB6W#9KcA literal 0 HcmV?d00001 diff --git a/app/api/routes/__pycache__/tonconnect.cpython-310.pyc b/app/api/routes/__pycache__/tonconnect.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0c47f133f407bf853fa5c92139ba62e0a6bd250e GIT binary patch literal 2297 zcmai0TaOzx6drr7n|t=wc3WU23Q~Y(RjESaPN6MSg%x5K0g=#XcI@4qWG<=gbi1oW zDr#Sgc%~pFZ}gpiz$5>_uRQfHtOVs8Cwpl@A|szUwvW&Fob!ElifXlF!S9dtUw1Eq z_9u2u|7_@d2u=O~f?15DR%Uf9f;vq}MmyB#c4}u%$1%E-x|!GUGQZoX$L}u<8S=v%qSs4(~Hm`qn{gW@RejZ-^ z;%0d3`nNFVd?mTSZ)H)BhiS2+%c{u3c>1ZB;I3ys)G4&zp~)2x-;<%$wL}>t8PkbL z^admw!X@jl=ed~|&U87HtQW>t5aJBcj!w7Am}}h)QIy`EI{X(Sa5a@nu+BKL^h43 zOJq&auFVLe31K#K9?}g9;&a9>bD1}xBNKDi8&QnCOGdVN`y*#a$AOwtrTb)DRuxt1 zR)==C)}x@+nQz_=7WC+SdqT!d7@Z$4Osol+keiURPp$Fd#2PP6tdTo(#W!ksd}io& z&$795U^DWD-uT?anpzSox7G4}YvidF1l`c*q3$?mk40875M@Z>o0ti%*8c1EAK@}06M;X_1P_Z88eJ*Zj8+2VeK||Q%`?|Jy zjCVxqL1W|Et<_^Zeem|Gb`bY~TsP%+czV#hnhGAV{S%`nzd?b)z-K&-_CtAacAf7X zO#@6;(&j@0%J zSGvTuLdm5B-`dM~R)~G=^Se9-;0i>X@Nu2Hx61_=2tVzkyD(vzLMtcvohVK4fMD9s zx!Nm4PX~nnDui3$D~1Sh&S6}}+Lv*Z!r7tS?LBOh^>Nd zc}TkzpmNZyG8d=}G&rI!901O^1{AJODSXZP&K-FSh#OXKzXhHeC-g-kF`sz}tOgnd z>tYdhD3(wxqc{WN#wJvF5XS(hR#RNWVUv8so_H07DG1g=ypG}+Vd4$!BEG~W6m1l1 zD6W9eHry22i;FBvRLd9d;KXya^#C`66mFUsZ%-H@b@N|i&lh~pwEc?}+M4Gh*>O9F2jy%Z(Ij)=1MKqaYc!#xRb>I)m^Tp8s_Jf-6* zjJ9I{dpq1pi?|o>MoHcV$ibkwHw{6A2kWLhEXmtxl*YR}+n?qRGo~9SZeZPZvLo4&2Jce*meJVW0p2 literal 0 HcmV?d00001 diff --git a/app/api/routes/__pycache__/upload_status.cpython-310.pyc b/app/api/routes/__pycache__/upload_status.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6a915688306eda94f35320d2d85ad74065f28578 GIT binary patch literal 2304 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 literal 0 HcmV?d00001 diff --git a/app/api/routes/admin.py b/app/api/routes/admin.py index 35b6394..e8c9c55 100644 --- a/app/api/routes/admin.py +++ b/app/api/routes/admin.py @@ -10,7 +10,7 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Tuple from urllib.parse import urlparse -from base58 import b58encode +from app.core._utils.b58 import b58encode from sanic import response from sqlalchemy import Integer, String, and_, case, cast, func, or_, select, Text @@ -48,6 +48,9 @@ from app.core.models.user_activity import UserActivity from app.core._utils.share_links import build_content_links from app.core.content.content_id import ContentId from app.core.events.service import record_event +from app.core.network.dht import MetricKey # type stub; used in typing only +from app.core.network.dht import dht_config +from app.core.models._config import ServiceConfig MIN_ONCHAIN_INDEX = int(os.getenv("MIN_ONCHAIN_INDEX", "8")) @@ -2219,6 +2222,272 @@ async def s_api_v1_admin_cache_cleanup(request): return response.json({"ok": True, "removed": removed}) +async def s_api_v1_admin_network(request): + """Сводка состояния децентрализованной сети для вкладки "Состояние сети". + + Возвращает: + - summary: n_estimate, количество участников, число островов, сводка конфликтов репликаций + - members: список нод с ролями/версиями/достижимостью и атрибутами + - per_node_replication: сколько лизов держит каждая нода и сколько раз является лидером + """ + if (unauth := _ensure_admin(request)): + return unauth + + mem = getattr(request.app.ctx, 'memory', None) + if not mem: + return response.json({"error": "MEMORY_NOT_READY"}, status=503) + + membership = mem.membership.state + n_est = membership.n_estimate() + active_all = membership.active_members(include_islands=True) + active_filtered = membership.active_members(include_islands=False) + islands = [m for m in active_all if membership.reachability_ratio(m['node_id']) < dht_config.default_q] + + # Обогащение из БД (версии, роли, public_host) + db = request.ctx.db_session + known = (await db.execute(select(KnownNode))).scalars().all() + meta_by_pub = {r.public_key: (r, r.meta or {}) for r in known} + meta_by_host = {r.ip: (r, r.meta or {}) for r in known} + + # Precompute receipts stats per node + receipts_elements = membership.receipts.elements() if hasattr(membership, 'receipts') else {} + receipts_by_target: Dict[str, Dict[str, Any]] = {} + for _rid, rec in receipts_elements.items(): + tid = str(rec.get('target_id')) + if not tid: + continue + bucket = receipts_by_target.setdefault(tid, { 'total': 0, 'asn_set': set() }) + bucket['total'] += 1 + if rec.get('asn') is not None: + try: + bucket['asn_set'].add(int(rec.get('asn'))) + except Exception: + pass + + def _enrich(member: dict) -> dict: + pub = str(member.get('public_key') or '') + host = str(member.get('ip') or '') + row_meta = (meta_by_pub.get(pub) or meta_by_host.get(host) or (None, {}))[1] + caps = (member.get('meta') or {}).get('capabilities') or {} + rec_stat = receipts_by_target.get(member.get('node_id') or '', {'total': 0, 'asn_set': set()}) + return { + 'node_id': member.get('node_id'), + 'public_key': pub or None, + 'public_host': row_meta.get('public_host'), + 'version': row_meta.get('version'), + 'role': row_meta.get('role') or 'read-only', + 'ip': host or None, + 'asn': member.get('asn'), + 'ip_first_octet': member.get('ip_first_octet'), + 'reachability_ratio': membership.reachability_ratio(member.get('node_id')), + 'last_update': member.get('last_update'), + 'accepts_inbound': bool(caps.get('accepts_inbound')), + 'is_bootstrap': bool(caps.get('is_bootstrap')), + 'receipts_total': int(rec_stat.get('total') or 0), + 'receipts_asn_unique': len(rec_stat.get('asn_set') or ()), + } + + members_payload = [_enrich(m) for m in active_all] + # Server-side pagination + try: + page = max(1, int(request.args.get('page') or 1)) + except Exception: + page = 1 + try: + page_size = max(1, min(500, int(request.args.get('page_size') or 100))) + except Exception: + page_size = 100 + total_members = len(members_payload) + start = (page - 1) * page_size + end = start + page_size + members_page = members_payload[start:end] + + # Агрегация репликаций по снимку DHT + snapshot = mem.dht_store.snapshot() if hasattr(mem, 'dht_store') else {} + per_node = {} + conflict_under = 0 + conflict_over = 0 + for fp, rec in snapshot.items(): + key = rec.get('key') or '' + if not key.startswith('meta:'): + continue + value = rec.get('value') or {} + content_id = value.get('content_id') + leases = (value.get('replica_leases') or {}).values() + leader = value.get('leader') + # Конфликты + for ev in value.get('conflict_log') or []: + t = (ev.get('type') or '').upper() + if t == 'UNDER_REPLICATED': + conflict_under += 1 + elif t == 'OVER_REPLICATED': + conflict_over += 1 + # Пер-нодовые конфликты + nid = ev.get('node_id') + if nid: + p = per_node.setdefault(nid, {'leases_held': 0, 'leaderships': 0, 'sample_contents': [], 'conflicts': {'over': 0, 'lease_expired': 0}, 'conflict_samples': []}) + if t == 'OVER_REPLICATED': + p['conflicts']['over'] = p['conflicts'].get('over', 0) + 1 + elif t == 'LEASE_EXPIRED': + p['conflicts']['lease_expired'] = p['conflicts'].get('lease_expired', 0) + 1 + if content_id and len(p['conflict_samples']) < 10: + p['conflict_samples'].append({'content_id': content_id, 'type': t, 'ts': ev.get('ts')}) + # Лизы + for l in leases: + nid = l.get('node_id') + if not nid: + continue + p = per_node.setdefault(nid, {'leases_held': 0, 'leaderships': 0, 'sample_contents': [], 'conflicts': {'over': 0, 'lease_expired': 0}, 'conflict_samples': []}) + p['leases_held'] += 1 + if content_id and len(p['sample_contents']) < 5: + p['sample_contents'].append(content_id) + if leader: + p = per_node.setdefault(leader, {'leases_held': 0, 'leaderships': 0, 'sample_contents': [], 'conflicts': {'over': 0, 'lease_expired': 0}, 'conflict_samples': []}) + p['leaderships'] += 1 + + # Добавим trusted-only n_estimate и показатели активности в summary + # Соберём allowed_nodes так же, как в репликации + from app.core._utils.b58 import b58decode + from app.core.network.dht.crypto import compute_node_id + allowed_nodes = set() + for row, meta in meta_by_pub.values(): + try: + if (meta or {}).get('role') == 'trusted' and row.public_key: + allowed_nodes.add(compute_node_id(b58decode(row.public_key))) + except Exception: + pass + allowed_nodes.add(mem.node_id) + n_est_trusted = membership.n_estimate_trusted(allowed_nodes) if hasattr(membership, 'n_estimate_trusted') else n_est + # Активные trusted: те, кто в allowed_nodes и проходят TTL/Q + active_trusted = [m for m in active_filtered if m.get('node_id') in allowed_nodes] + # Экспортируем конфиг интервалов + from app.core.network.dht import dht_config + + # Build receipts report with validation status + receipts_raw = (membership.receipts.elements() if hasattr(membership, 'receipts') else {}) or {} + receipts: List[Dict[str, Any]] = [] + members_map = membership.members.elements() if hasattr(membership, 'members') else {} + for _rid, entry in receipts_raw.items(): + target_id = str(entry.get('target_id')) + issuer_id = str(entry.get('issuer_id')) + asn = entry.get('asn') + timestamp = entry.get('timestamp') + signature = str(entry.get('signature') or '') + status = 'unknown' + # verify if possible + issuer_pub = None + for mid, mdata in members_map.items(): + if mid == issuer_id: + issuer_pub = mdata.get('public_key') + break + if issuer_pub: + try: + from app.core._utils.b58 import b58decode as _b58d + from app.core.network.dht.crypto import compute_node_id + import nacl.signing # type: ignore + # node_id/pubkey match + if compute_node_id(_b58d(issuer_pub)) != issuer_id: + status = 'mismatch_node_id' + else: + payload = { + 'schema_version': dht_config.schema_version, + 'target_id': target_id, + 'issuer_id': issuer_id, + 'asn': int(asn) if asn is not None else None, + 'timestamp': float(timestamp or 0), + } + blob = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + vk = nacl.signing.VerifyKey(_b58d(issuer_pub)) + vk.verify(blob, _b58d(signature)) + status = 'valid' + except Exception: + status = 'bad_signature' + else: + status = 'unknown_issuer' + receipts.append({ + 'target_id': target_id, + 'issuer_id': issuer_id, + 'asn': asn, + 'timestamp': timestamp, + 'status': status, + }) + + return response.json({ + 'summary': { + 'n_estimate': n_est, + 'n_estimate_trusted': n_est_trusted, + 'active_trusted': len(active_trusted), + 'members_total': len(active_all), + 'active': len(active_filtered), + 'islands': len(islands), + 'replication_conflicts': { + 'under': conflict_under, + 'over': conflict_over, + }, + 'config': { + 'heartbeat_interval': dht_config.heartbeat_interval, + 'lease_ttl': dht_config.lease_ttl, + 'gossip_interval_sec': dht_config.gossip_interval_sec, + 'gossip_backoff_base_sec': dht_config.gossip_backoff_base_sec, + 'gossip_backoff_cap_sec': dht_config.gossip_backoff_cap_sec, + } + }, + 'members': members_page, + 'per_node_replication': per_node, + 'receipts': receipts, + 'paging': { 'page': page, 'page_size': page_size, 'total': total_members }, + }) + + +async def s_api_v1_admin_network_config(request): + if (unauth := _ensure_admin(request)): + return unauth + cfg = dht_config + async with request.ctx.db_session() as session: + sc = ServiceConfig(session) + out = { + 'heartbeat_interval': cfg.heartbeat_interval, + 'lease_ttl': cfg.lease_ttl, + 'gossip_interval_sec': cfg.gossip_interval_sec, + 'gossip_backoff_base_sec': cfg.gossip_backoff_base_sec, + 'gossip_backoff_cap_sec': cfg.gossip_backoff_cap_sec, + } + # include overrides if present + for k in list(out.keys()): + ov = await sc.get(f'DHT_{k.upper()}', None) + if ov is not None: + out[k] = int(ov) + return response.json({'ok': True, 'config': out}) + + +async def s_api_v1_admin_network_config_set(request): + if (unauth := _ensure_admin(request)): + return unauth + data = request.json or {} + allowed = { + 'heartbeat_interval': (5, 3600), + 'lease_ttl': (60, 86400), + 'gossip_interval_sec': (5, 600), + 'gossip_backoff_base_sec': (1, 300), + 'gossip_backoff_cap_sec': (10, 7200), + } + updates = {} + for key, (lo, hi) in allowed.items(): + if key in data: + try: + val = int(data[key]) + except Exception: + return response.json({'error': f'BAD_{key.upper()}'}, status=400) + if val < lo or val > hi: + return response.json({'error': f'RANGE_{key.upper()}', 'min': lo, 'max': hi}, status=400) + updates[key] = val + async with request.ctx.db_session() as session: + sc = ServiceConfig(session) + for key, val in updates.items(): + await sc.set(f'DHT_{key.upper()}', val) + return response.json({'ok': True, 'updated': updates}) + + async def s_api_v1_admin_sync_setlimits(request): if (unauth := _ensure_admin(request)): return unauth diff --git a/app/api/routes/content.py b/app/api/routes/content.py index a7d3dd2..a0ed089 100644 --- a/app/api/routes/content.py +++ b/app/api/routes/content.py @@ -1,3 +1,4 @@ +from __future__ import annotations from datetime import datetime, timedelta from sanic import response from sqlalchemy import select, and_, func, or_ @@ -86,7 +87,24 @@ async def s_api_v1_content_view(request, content_address: str): 'content_type': content_type, 'content_mime': ctype, } - content = await open_content_async(request.ctx.db_session, r_content) + try: + content = await open_content_async(request.ctx.db_session, r_content) + except AssertionError: + # Fallback: handle plain stored content without encrypted/decrypted pairing + sc = r_content + from mimetypes import guess_type as _guess + _mime, _ = _guess(sc.filename or '') + _mime = _mime or 'application/octet-stream' + try: + _ctype = _mime.split('/')[0] + except Exception: + _ctype = 'application' + content = { + 'encrypted_content': sc, + 'decrypted_content': sc, + 'content_type': _ctype, + 'content_mime': _mime, + } master_address = content['encrypted_content'].meta.get('item_address', '') opts = { @@ -222,6 +240,16 @@ async def s_api_v1_content_view(request, content_address: str): or opts.get('content_mime') or 'application/octet-stream' ) + # Fallback: if stored content reports generic application/*, try guess by filename + try: + if content_mime.startswith('application/'): + from mimetypes import guess_type as _guess + _fn = decrypted_json.get('filename') or encrypted_json.get('filename') or '' + _gm, _ = _guess(_fn) + if _gm: + content_mime = _gm + except Exception: + pass opts['content_mime'] = content_mime try: opts['content_type'] = content_mime.split('/')[0] @@ -249,7 +277,7 @@ async def s_api_v1_content_view(request, content_address: str): if not row or not row.local_path: return None, None file_hash = row.local_path.split('/')[-1] - return file_hash, f"{PROJECT_HOST}/api/v1.5/storage/{file_hash}" + return file_hash, f"{PROJECT_HOST}/api/v1/storage.proxy/{file_hash}" has_preview = bool(derivative_latest.get('decrypted_preview') or converted_meta_map.get('low_preview')) display_options['has_preview'] = has_preview @@ -270,10 +298,28 @@ async def s_api_v1_content_view(request, content_address: str): chosen_row = derivative_latest[key] break + def _make_token_for(hash_value: str, scope: str, user_id: int | None) -> str: + try: + from app.core._crypto.signer import Signer + from app.core._secrets import hot_seed, hot_pubkey + from app.core._utils.b58 import b58encode as _b58e + import time, json + signer = Signer(hot_seed) + exp = int(time.time()) + 600 + uid = int(user_id or 0) + payload = {'hash': hash_value, 'scope': scope, 'exp': exp, 'uid': uid} + blob = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + sig = signer.sign(blob) + pub = _b58e(hot_pubkey).decode() + return f"pub={pub}&exp={exp}&scope={scope}&uid={uid}&sig={sig}" + except Exception: + return "" + if chosen_row: file_hash, url = _row_to_hash_and_url(chosen_row) if url: - display_options['content_url'] = url + token = _make_token_for(file_hash or '', 'full' if have_access else 'preview', getattr(request.ctx.user, 'id', None)) + display_options['content_url'] = f"{url}?{token}" if token else url ext_candidate = None if chosen_row.content_type: ext_candidate = chosen_row.content_type.split('/')[-1] @@ -298,17 +344,31 @@ async def s_api_v1_content_view(request, content_address: str): hash_value = converted_meta_map.get(key) if not hash_value: continue - stored = (await request.ctx.db_session.execute(select(StoredContent).where(StoredContent.hash == hash_value))).scalars().first() - if stored: - display_options['content_url'] = stored.web_url - filename = stored.filename or '' - if '.' in filename: - opts['content_ext'] = filename.split('.')[-1] - elif '/' in content_mime: - opts['content_ext'] = content_mime.split('/')[-1] - if content_kind == 'binary': - display_options['original_available'] = True - break + # Пробуем сразу через прокси (даже если локальной записи нет) + token = _make_token_for(hash_value, 'full' if have_access else 'preview', getattr(request.ctx.user, 'id', None)) + display_options['content_url'] = f"{PROJECT_HOST}/api/v1/storage.proxy/{hash_value}?{token}" if token else f"{PROJECT_HOST}/api/v1/storage.proxy/{hash_value}" + if '/' in content_mime: + opts['content_ext'] = content_mime.split('/')[-1] + if content_kind == 'binary': + display_options['original_available'] = True + break + + # Final fallback: no derivatives known — serve stored content directly for AV + if not display_options['content_url'] and content_kind in ('audio', 'video'): + from app.core._utils.b58 import b58encode as _b58e + scid = decrypted_json.get('cid') or encrypted_json.get('cid') + try: + from app.core.content.content_id import ContentId as _CID + if scid: + _cid = _CID.deserialize(scid) + h = _cid.content_hash_b58 + else: + h = decrypted_json.get('hash') + except Exception: + h = decrypted_json.get('hash') + if h: + token = _make_token_for(h, 'preview' if not have_access else 'full', getattr(request.ctx.user, 'id', None)) + display_options['content_url'] = f"{PROJECT_HOST}/api/v1/storage.proxy/{h}?{token}" if token else f"{PROJECT_HOST}/api/v1/storage.proxy/{h}" # Metadata fallback content_meta = encrypted_json @@ -334,7 +394,8 @@ async def s_api_v1_content_view(request, content_address: str): } cover_cid = content_meta.get('cover_cid') if cover_cid: - content_metadata_json.setdefault('image', f"{PROJECT_HOST}/api/v1.5/storage/{cover_cid}") + token = _make_token_for(cover_cid, 'preview', getattr(request.ctx.user, 'id', None)) + content_metadata_json.setdefault('image', f"{PROJECT_HOST}/api/v1/storage.proxy/{cover_cid}?{token}" if token else f"{PROJECT_HOST}/api/v1/storage.proxy/{cover_cid}") display_options['metadata'] = content_metadata_json diff --git a/app/api/routes/dht.py b/app/api/routes/dht.py new file mode 100644 index 0000000..931b357 --- /dev/null +++ b/app/api/routes/dht.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import json +from typing import Any, Dict, List + +from sanic import response + +from app.core.logger import make_log +from app.core._utils.b58 import b58decode +from app.core.network.dht.records import DHTRecord +from app.core.network.dht.store import DHTStore +from app.core.network.dht.crypto import compute_node_id +from app.core.network.dht.keys import MetaKey, MembershipKey, MetricKey +from sqlalchemy import select +from app.core.models.my_network import KnownNode + + +def _merge_strategy_for(key: str): + # Выбираем правильную стратегию merge по префиксу ключа + from app.core.network.dht.replication import ReplicationState + from app.core.network.dht.membership import MembershipState + from app.core.network.dht.metrics import ContentMetricsState + if key.startswith('meta:'): + return lambda a, b: ReplicationState.from_dict(a).merge_with(ReplicationState.from_dict(b)).to_dict() + if key.startswith('membership:'): + # Для membership нужен node_id, но это только для локального состояния; здесь достаточно CRDT-мерджа + return lambda a, b: MembershipState.from_dict('remote', None, a).merge(MembershipState.from_dict('remote', None, b)).to_dict() + if key.startswith('metric:'): + return lambda a, b: ContentMetricsState.from_dict('remote', a).merge(ContentMetricsState.from_dict('remote', b)).to_dict() + return lambda a, b: b + + +async def s_api_v1_dht_get(request): + """Возвращает запись DHT по fingerprint или key.""" + store: DHTStore = request.app.ctx.memory.dht_store + fp = request.args.get('fingerprint') + key = request.args.get('key') + if fp: + rec = store.get(fp) + if not rec: + return response.json({'error': 'NOT_FOUND'}, status=404) + return response.json({**rec.to_payload(), 'signature': rec.signature}) + if key: + snap = store.snapshot() + for _fp, payload in snap.items(): + if payload.get('key') == key: + return response.json(payload) + return response.json({'error': 'NOT_FOUND'}, status=404) + return response.json({'error': 'BAD_REQUEST'}, status=400) + + +def _verify_publisher(node_id: str, public_key_b58: str) -> bool: + try: + derived = compute_node_id(b58decode(public_key_b58)) + return derived == node_id + except Exception: + return False + + +async def s_api_v1_dht_put(request): + """Принимает запись(и) DHT, проверяет подпись и выполняет merge/persist. + + Поддерживает одиночную запись (record: {...}) и пакет (records: [{...}]). + Требует поле public_key отправителя и соответствие node_id. + """ + mem = request.app.ctx.memory + store: DHTStore = mem.dht_store + data = request.json or {} + public_key = data.get('public_key') + if not public_key: + return response.json({'error': 'MISSING_PUBLIC_KEY'}, status=400) + + # Determine publisher role (trusted/read-only/deny) + role = None + try: + session = request.ctx.db_session + kn = (await session.execute(select(KnownNode).where(KnownNode.public_key == public_key))).scalars().first() + role = (kn.meta or {}).get('role') if kn and kn.meta else None + except Exception: + role = None + + def _process_one(payload: Dict[str, Any]) -> Dict[str, Any]: + try: + rec = DHTRecord.create( + key=payload['key'], + fingerprint=payload['fingerprint'], + value=payload['value'], + node_id=payload['node_id'], + logical_counter=int(payload['logical_counter']), + signature=payload.get('signature'), + timestamp=float(payload.get('timestamp') or 0), + ) + except Exception as e: + return {'error': f'BAD_RECORD: {e}'} + if not _verify_publisher(rec.node_id, public_key): + return {'error': 'NODE_ID_MISMATCH'} + # Подтверждение подписи записи + if not rec.verify(public_key): + return {'error': 'BAD_SIGNATURE'} + # Enforce ACL: untrusted nodes may not mutate meta/metric records + if role != 'trusted': + if rec.key.startswith('meta:') or rec.key.startswith('metric:'): + return {'error': 'FORBIDDEN_NOT_TRUSTED'} + merge_fn = _merge_strategy_for(rec.key) + try: + merged = store.merge_record(rec, merge_fn) + return {'ok': True, 'fingerprint': merged.fingerprint} + except Exception as e: + make_log('DHT.put', f'merge failed: {e}', level='warning') + return {'error': 'MERGE_FAILED'} + + if 'record' in data: + result = _process_one(data['record']) + status = 200 if 'ok' in result else 400 + return response.json(result, status=status) + elif 'records' in data and isinstance(data['records'], list): + results: List[Dict[str, Any]] = [] + ok = True + for item in data['records']: + res = _process_one(item) + if 'error' in res: + ok = False + results.append(res) + return response.json({'ok': ok, 'results': results}, status=200 if ok else 207) + return response.json({'error': 'BAD_REQUEST'}, status=400) diff --git a/app/api/routes/network.py b/app/api/routes/network.py index 9f47b53..73d2d28 100644 --- a/app/api/routes/network.py +++ b/app/api/routes/network.py @@ -109,6 +109,17 @@ async def s_api_v1_network_handshake(request): if not data.get("nonce") or not check_and_remember_nonce(request.app.ctx.memory, data.get("public_key"), data.get("nonce")): return response.json({"error": "NONCE_REPLAY"}, status=400) + # Base schema and identity checks + if data.get("schema_version") != dht_config.schema_version: + return response.json({"error": "UNSUPPORTED_SCHEMA_VERSION"}, status=400) + + try: + expected_node_id = compute_node_id(b58decode(data["public_key"])) + except Exception: + return response.json({"error": "BAD_PUBLIC_KEY"}, status=400) + if data.get("node_id") != expected_node_id: + return response.json({"error": "NODE_ID_MISMATCH"}, status=400) + peer_version = str(data.get("version")) ipfs_meta = _extract_ipfs_meta(data.get("ipfs") or {}) comp = compatibility(peer_version, CURRENT_PROTOCOL_VERSION) @@ -160,9 +171,10 @@ async def s_api_v1_network_handshake(request): membership_mgr = getattr(request.app.ctx.memory, "membership", None) if membership_mgr: remote_ip = (request.headers.get('X-Forwarded-For') or request.remote_addr or request.ip or '').split(',')[0].strip() or None + # Determine caller ASN using advertised value or resolver remote_asn = data.get("asn") if remote_asn is None: - remote_asn = asn_resolver.resolve(remote_ip) + remote_asn = await asn_resolver.resolve_async(remote_ip, request.ctx.db_session) else: if remote_ip: asn_resolver.learn(remote_ip, int(remote_asn)) @@ -181,15 +193,42 @@ async def s_api_v1_network_handshake(request): if not receipt.get("target_id") or not receipt.get("issuer_id"): continue try: - membership_mgr.record_receipt( - ReachabilityReceipt( - target_id=str(receipt.get("target_id")), - issuer_id=str(receipt.get("issuer_id")), - asn=int(receipt["asn"]) if receipt.get("asn") is not None else None, - timestamp=float(receipt.get("timestamp", data.get("timestamp"))), - signature=str(receipt.get("signature", "")), + # Only accept receipts issued by the caller + issuer_id = str(receipt.get("issuer_id")) + if issuer_id != data["node_id"]: + continue + # Canonical message for receipt verification + # schema_version is embedded to avoid replay across versions + rec_asn = receipt.get("asn") + if rec_asn is None: + rec_asn = remote_asn + payload = { + "schema_version": dht_config.schema_version, + "target_id": str(receipt.get("target_id")), + "issuer_id": issuer_id, + "asn": int(rec_asn) if rec_asn is not None else None, + "timestamp": float(receipt.get("timestamp", data.get("timestamp"))), + } + blob = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + try: + import nacl.signing # type: ignore + from app.core._utils.b58 import b58decode as _b58d + vk = nacl.signing.VerifyKey(_b58d(data["public_key"])) + sig_b = _b58d(str(receipt.get("signature", ""))) + vk.verify(blob, sig_b) + # Accept and persist + membership_mgr.record_receipt( + ReachabilityReceipt( + target_id=payload["target_id"], + issuer_id=payload["issuer_id"], + asn=payload["asn"], + timestamp=payload["timestamp"], + signature=str(receipt.get("signature", "")), + ) ) - ) + except Exception: + # Ignore invalid receipts + continue except Exception: continue except Exception as exc: @@ -271,8 +310,3 @@ async def s_api_v1_network_handshake(request): status = 200 resp["warning"] = "MINOR version differs; proceed with caution" return response.json(resp, status=status) - if data.get("schema_version") != dht_config.schema_version: - return response.json({"error": "UNSUPPORTED_SCHEMA_VERSION"}, status=400) - expected_node_id = compute_node_id(b58decode(data["public_key"])) - if data.get("node_id") != expected_node_id: - return response.json({"error": "NODE_ID_MISMATCH"}, status=400) diff --git a/app/api/routes/progressive_storage.py b/app/api/routes/progressive_storage.py index 91ca50e..12e19bd 100644 --- a/app/api/routes/progressive_storage.py +++ b/app/api/routes/progressive_storage.py @@ -16,6 +16,14 @@ from app.core.models.node_storage import StoredContent from app.core._config import UPLOADS_DIR from app.core.models.content_v3 import ContentDerivative from app.core._utils.resolve_content import resolve_content +from app.core.network.nodesig import verify_request +from app.core.models.my_network import KnownNode +from sqlalchemy import select as sa_select +import httpx +from app.core._crypto.signer import Signer +from app.core._secrets import hot_seed +from app.core._utils.b58 import b58encode as _b58e, b58decode as _b58d +import json, time # POST /api/v1.5/storage @@ -305,3 +313,125 @@ async def s_api_v1_5_storage_get(request, file_hash): else: make_log("uploader_v1.5", f"Returning full file for video/audio: {final_path}", level="INFO") return await response.file(final_path, mime_type=mime_type) + + +# GET /api/v1/storage.fetch/ +# Внутренний эндпойнт для межузлового запроса (NodeSig). Возвращает файл, если он есть локально. +async def s_api_v1_storage_fetch(request, file_hash): + ok, node_id, reason = verify_request(request, request.app.ctx.memory) + if not ok: + return response.json({"error": reason or "UNAUTHORIZED"}, status=401) + # Только доверенные узлы + try: + session = request.ctx.db_session + row = (await session.execute(sa_select(KnownNode).where(KnownNode.public_key == node_id))).scalars().first() + role = (row.meta or {}).get('role') if row and row.meta else None + if role != 'trusted': + return response.json({"error": "DENIED_NOT_TRUSTED"}, status=403) + except Exception: + pass + # Переиспользуем реализацию v1.5 + return await s_api_v1_5_storage_get(request, file_hash) + + +# GET /api/v1/storage.proxy/ +# Проксирование для web-клиента: если локально нет файла, попытка получить у доверенных узлов по NodeSig +async def s_api_v1_storage_proxy(request, file_hash): + # Require either valid NodeSig (unlikely for public clients) or a signed access token + # Token fields: pub, exp, scope, uid, sig over json {hash,scope,exp,uid} + def _verify_access_token() -> bool: + try: + pub = (request.args.get('pub') or '').strip() + exp = int(request.args.get('exp') or '0') + scope = (request.args.get('scope') or '').strip() + uid = int(request.args.get('uid') or '0') + sig = (request.args.get('sig') or '').strip() + if not pub or not exp or not scope or not sig: + return False + if exp < int(time.time()): + return False + payload = { + 'hash': file_hash, + 'scope': scope, + 'exp': exp, + 'uid': uid, + } + blob = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + import nacl.signing + vk = nacl.signing.VerifyKey(_b58d(pub)) + vk.verify(blob, _b58d(sig)) + # Note: we do not require a session-bound user for media fetches, + # the short‑lived signature itself is sufficient. + return True + except Exception: + return False + + ok_nodesig, _nid, _reason = verify_request(request, request.app.ctx.memory) + if not ok_nodesig and not _verify_access_token(): + return response.json({'error': 'UNAUTHORIZED'}, status=401) + # Сначала пробуем локально без возврата 404 + try: + from base58 import b58encode as _b58e + try: + # Поддержка как хэша, так и CID + from app.core._utils.resolve_content import resolve_content as _res + cid, _ = _res(file_hash) + file_hash = _b58e(cid.content_hash).decode() + except Exception: + pass + final_path = os.path.join(UPLOADS_DIR, f"{file_hash}") + if os.path.exists(final_path): + return await s_api_v1_5_storage_get(request, file_hash) + except Exception: + pass + # Локально нет — пробуем у доверенных + try: + async with request.app.ctx.memory.transaction("storage.proxy"): + # Соберём список trusted узлов + session = request.ctx.db_session + nodes = (await session.execute(sa_select(KnownNode))).scalars().all() + candidates = [] + for n in nodes: + role = (n.meta or {}).get('role') if n.meta else None + if role != 'trusted': + continue + host = (n.meta or {}).get('public_host') or (n.ip or '') + if not host: + continue + base = host.rstrip('/') + if not base.startswith('http'): + base = f"http://{base}:{n.port or 80}" + candidates.append(base) + # Проксируем с передачей Range, стриминг + range_header = request.headers.get("Range") + timeout = httpx.Timeout(10.0, read=60.0) + for base in candidates: + url = f"{base}/api/v1/storage.fetch/{file_hash}" + try: + # Подпишем NodeSig + from app.core._secrets import hot_seed, hot_pubkey + from app.core.network.nodesig import sign_headers + from app.core._utils.b58 import b58encode as _b58e + pk_b58 = _b58e(hot_pubkey).decode() + headers = sign_headers('GET', f"/api/v1/storage.fetch/{file_hash}", b"", hot_seed, pk_b58) + if range_header: + headers['Range'] = range_header + async with httpx.AsyncClient(timeout=timeout) as client: + r = await client.get(url, headers=headers) + if r.status_code == 404: + continue + if r.status_code not in (200, 206): + continue + # Проксируем заголовки контента + resp = await request.respond(status=r.status_code, headers={ + k: v for k, v in r.headers.items() if k.lower() in ("content-type", "content-length", "content-range", "accept-ranges") + }) + async for chunk in r.aiter_bytes(chunk_size=1024*1024): + await resp.send(chunk) + await resp.eof() + return resp + except Exception as e: + continue + except Exception: + pass + return response.json({"error": "File not found"}, status=404) diff --git a/app/bot/__init__.py b/app/bot/__init__.py index e677de1..9e79e37 100644 --- a/app/bot/__init__.py +++ b/app/bot/__init__.py @@ -7,6 +7,9 @@ from app.bot.middleware import UserDataMiddleware from app.bot.routers.index import main_router -dp = Dispatcher(storage=MemoryStorage()) -dp.update.outer_middleware(UserDataMiddleware()) -dp.include_router(main_router) +def create_dispatcher() -> Dispatcher: + """Create aiogram Dispatcher lazily to avoid event loop issues at import time.""" + dp = Dispatcher(storage=MemoryStorage()) + dp.update.outer_middleware(UserDataMiddleware()) + dp.include_router(main_router) + return dp diff --git a/app/client_bot/__init__.py b/app/client_bot/__init__.py index e7dbdae..602eced 100644 --- a/app/client_bot/__init__.py +++ b/app/client_bot/__init__.py @@ -6,6 +6,9 @@ from aiogram.fsm.storage.memory import MemoryStorage from app.bot.middleware import UserDataMiddleware from app.client_bot.routers.index import main_router -dp = Dispatcher(storage=MemoryStorage()) -dp.update.outer_middleware(UserDataMiddleware()) -dp.include_router(main_router) + +def create_dispatcher() -> Dispatcher: + dp = Dispatcher(storage=MemoryStorage()) + dp.update.outer_middleware(UserDataMiddleware()) + dp.include_router(main_router) + return dp diff --git a/app/core/models/dht.py b/app/core/models/dht.py new file mode 100644 index 0000000..d537fea --- /dev/null +++ b/app/core/models/dht.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from sqlalchemy import Column, String, Integer, Float, JSON, DateTime +from datetime import datetime + +from .base import AlchemyBase + + +class DHTRecordRow(AlchemyBase): + __tablename__ = 'dht_records' + + # fingerprint = blake3(serialized key) + fingerprint = Column(String(128), primary_key=True) + key = Column(String(512), nullable=False, index=True) + schema_version = Column(String(16), nullable=False, default='v1') + logical_counter = Column(Integer, nullable=False, default=0) + timestamp = Column(Float, nullable=False, default=0.0) + node_id = Column(String(128), nullable=False) + signature = Column(String(512), nullable=True) + value = Column(JSON, nullable=False, default=dict) + updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) + diff --git a/app/core/models/memory.py b/app/core/models/memory.py index 55d44c7..84dc197 100644 --- a/app/core/models/memory.py +++ b/app/core/models/memory.py @@ -11,12 +11,12 @@ from app.core._crypto.signer import Signer from app.core._secrets import hot_pubkey, hot_seed from app.core.logger import make_log from app.core.network.dht import ( - DHTStore, MembershipManager, ReplicationManager, MetricsAggregator, compute_node_id, ) +from app.core.network.dht.store import PersistentDHTStore class Memory: @@ -59,7 +59,7 @@ class Memory: # Decentralised storage components self.node_id = compute_node_id(hot_pubkey) self.signer = Signer(hot_seed) - self.dht_store = DHTStore(self.node_id, self.signer) + self.dht_store = PersistentDHTStore(self.node_id, self.signer) self.membership = MembershipManager(self.node_id, self.signer, self.dht_store) self.replication = ReplicationManager(self.node_id, self.signer, self.dht_store) self.metrics = MetricsAggregator(self.node_id, self.signer, self.dht_store) diff --git a/app/core/models/rdap.py b/app/core/models/rdap.py new file mode 100644 index 0000000..3f94dae --- /dev/null +++ b/app/core/models/rdap.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from datetime import datetime +from sqlalchemy import Column, String, Integer, DateTime + +from .base import AlchemyBase + + +class RdapCache(AlchemyBase): + __tablename__ = 'rdap_cache' + + ip = Column(String(64), primary_key=True) + asn = Column(Integer, nullable=True) + source = Column(String(64), nullable=True) + updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) + diff --git a/app/core/network/asn.py b/app/core/network/asn.py index 704ce7f..5a1b388 100644 --- a/app/core/network/asn.py +++ b/app/core/network/asn.py @@ -32,6 +32,63 @@ class ASNResolver: return self.cache[norm] = asn + async def resolve_async(self, ip: str | None, db_session=None) -> Optional[int]: + """Resolve ASN via persistent cache; fallback to RDAP API; store result. + + - Checks in-memory cache first. + - If not found, checks DB table rdap_cache when available. + - If still not found, queries a public API and persists. + """ + norm = self.normalise(ip) + if not norm: + return None + # In-memory cache first + if norm in self.cache: + return self.cache[norm] + # DB lookup if possible + try: + if db_session is not None: + from sqlalchemy import select + from app.core.models.rdap import RdapCache + row = (await db_session.execute(select(RdapCache).where(RdapCache.ip == norm))).scalars().first() + if row and row.asn is not None: + self.cache[norm] = int(row.asn) + return int(row.asn) + except Exception as e: + make_log("ASNResolver", f"DB lookup failed for {norm}: {e}", level="warning") + + # Remote lookup (best-effort) + asn: Optional[int] = None + try: + import httpx + url = f"https://api.iptoasn.com/v1/as/ip/{norm}" + async with httpx.AsyncClient(timeout=5.0) as client: + r = await client.get(url) + if r.status_code == 200: + j = r.json() + num = j.get("as_number") + if isinstance(num, int) and num > 0: + asn = num + except Exception as e: + make_log("ASNResolver", f"RDAP lookup failed for {norm}: {e}", level="warning") + + if asn is not None: + self.cache[norm] = asn + # Persist to DB if possible + try: + if db_session is not None: + from app.core.models.rdap import RdapCache + row = await db_session.get(RdapCache, norm) + if row is None: + row = RdapCache(ip=norm, asn=asn, source="iptoasn") + db_session.add(row) + else: + row.asn = asn + row.source = "iptoasn" + await db_session.commit() + except Exception as e: + make_log("ASNResolver", f"DB persist failed for {norm}: {e}", level="warning") + return asn + resolver = ASNResolver() - diff --git a/app/core/network/dht/__pycache__/config.cpython-310.pyc b/app/core/network/dht/__pycache__/config.cpython-310.pyc index 87c49bc116fc017577f6f142a04fa3dfdd405ea9..1267287e3690b39dfa398ad0150d16d38e9bd126 100644 GIT binary patch delta 344 zcmdlYI8m4{pO=@50SGule`YkYZ{$m7WYnHq!}x$PVsk8$BBM-v zTZ)bi15iXag%v0w4HnS@i|D8D0!3uNA_g`LDTcuenns&jn71?4-x7E62#I(14-WPW zi1+jhaSaM{^ob94b-pE>o?l#?SrDI@SCU#(mY5S?oSJ+~8cDU2qqDcan_Ik-W3Ve& zhd5M6Qetv;ep*_5Qetr`SeFzQUCxdHKut2vx5UtNB_|euH2G=DPPSv)%xw$wevv(h zaGd;>O_5P!GC#XAix8s_(_{noE?y&`OfeIXU}GuroP3$Y0i)99a3)1YwiNAP22GvK zTbQ>qG5cvsO=e@?JoyB>EThuoXY9(8`8hgx^?+s+GXV)UmLliLOE|hj9e|u7VGtn( TA|ybB^JHgEPgW5gMlLo0B3T^J diff --git a/app/core/network/dht/__pycache__/membership.cpython-310.pyc b/app/core/network/dht/__pycache__/membership.cpython-310.pyc index 7e1120855304e5a046046b662288bf5edce4ae63..3acb021545ca0eb20bf177e03f520fffe4a5c376 100644 GIT binary patch delta 1730 zcmZ8gU2IfE6rQ=e_jdR0Zd|^;3bpK6(qc>N+zUuq zvb5W>C4x`}%AeLYQDaF^pjHTNAtpXROw8SoXi^_cd{AS2&=?c(1<$$5U%bhD`^`7! zocYeo?DaJ#H{`bm0y@L*%*(f%Z$>WWf1`~4sAx*+Bh(Ts#(~8Ys0S6a9(T+I&w&?o zWNxp|>x0Fx1ZF~Uj~Y{9DTE-5D;g|=QYb@~2g{)xDv)_#1w^0fqTPcbdnhfo~&= zzBt;e)&}^991p&hH^RksbS{?!!u&6}pr|%D;u0;W2tUCNgb}&7=my^>S59A>n({TZ zwzlt1Kw}G-|9Z@X^}1<^&2a_e?tN|~G+c~va|@C_Wrz(k&N6%(2M$V)r%&0=!3FNa zd?icsgDk@`$~MJ0G+I-JT*Hi3_9>RzQu~l0cO{jSc*4>wEvck=(v?;=vZM!U zXUrbV9Z^vRznm5@jrCUwWKs(w1Yi zBRN5w!0nBPj?F+H_O-iasa`I~absVh${GCWLYwIaPA~=CrZ5)+)-|@a$D3NI%Q2GC zV>dZB<~SRV(*|FljNY)V>ts0kpsP3@*8PtzPQ_6~mlZ+M%#v(#U>{HNAqH%i_px0} zC`o+RVRmex@-`m2pi31og53yFF1m?V5Euk+A`IX#sd|KU*2;;ZlFG)rM_p6zxHlKq z)Z_b!x3_gH-X!*5Bt63Qq7zxJ)?Q*sgs3u=-xRl6b|voB5asuy3}TH4Cvlkc-_)4K zLtMF|@y@4dXV1MX`A1;763vsp7nP*8QrasBQFpGG(?g2X{RGra)Fpa|J4?=v22Q7f zI~o(F)za2v-S=}8bu)Wk7b! zTFNKo#aY|U3ub_6OTpc?tLDwffA#keEVTwfvQ6=|4J|@BGxr~@Wa2DYQ8LMp3oSZJXBob2T zLVl4ySFQ?-P>f?y5d*R=QmOq$$~oB-splu;U}Q((5fbS6h<<`&vb1V;^c2z41n(0J z5)2XKS|sM&`Yw6Y6;hAm{7czaRl$$TPpihT@RsTj@}sU|`CfGaH{@V-g`1^<{{H~> Cg#>y4 delta 880 zcmZ9JT}YE*6vubo?VbB>PIGH+?>?McyU8sx&6fF5nhC`yC`JU)nQ5tuce5-^FC3ZZ z^5ale6zGLP5LS%nrVzS_NC>(KlrFmIwu^3pE~4iQjOe}i@qhm3<2lcH7n5tnAJcKcf=L5Qw6%S)Q?hy&L^fv6oz1W!7O+dY}*Cl`@>UB56 z4O(`O$P>c8UsS4FfpVT0l+%i*-8Cvql?yh_=LgPZvxDheUk23)YKyqY<26E%!rnl! z+gk~aagi34)!kfD&zPZ%5`Y<+SDf&eo+t%7-|ibo1him(t0pibC=EI z0%Ap_)MHzzQAt%;_lOBAOl-g7<2^c4^L6`wqn_l*7$rj?_(WGiS372ev8la0oKqbf zdc-KT=WVG?0$c4Lvf*QR8Ggoszz2%e_UlVEqZBhc^(t@sGrg|uvMzB_X=q6;kUbpG zZ?LmWjp2mYXf8ZtzsbfV;}&CzeuTrJS(bAQV%%XYGX6eV9i>FXD^Ck^h0aENFim5T zmu`NJYG;%)VvIOrTKsBfeAAE^%;4}qx&?%p<+WKf2FU;x@PkFNfL$mHNLo9X%55gc F^*>LY(r^F( diff --git a/app/core/network/dht/__pycache__/prometheus.cpython-310.pyc b/app/core/network/dht/__pycache__/prometheus.cpython-310.pyc index aab32c37c6ef08520e33181db3aed6f6c8f438b2..94fd0c728f1bab350e4841b604eb9a0de7416c68 100644 GIT binary patch delta 1556 zcmah}OHb5L6z*+@3G<*bQl5eqNWg+3(a8J#R&+oSMO;jVIUN|l>EyQKf?#H$OI?sA z?o4EnhDFWlG{7AOGPoA-&TtTYR)h%(+T8sojZQV zbTYG@=ep-uHG`rK$DRD<=2j+`(}BzjxKZm;Q9kCNLS9!bLtRUzwv2Sz335GY8d;~o zcV17IF!hv?1#?H8b@Mp%QN4AQ>ra!JY$m(zNT$V|z((?!?ns%eM=>QtZFw z_1$gQxdRbFVSE%nDdL9@aS4Bug24Cy5W!GygZY6^M=pF=7e zvcHEeN4iP|Q)Ivf?4EEZo3_`&TI703H*3EM|EQcJ`4+)#f_eK@q&?Py)C?jhOks75 zFJPF)f=Oq#giW?90DH~eiTeT*EU6?1K-_0c00Fx$#osgDO%WM_R_4gZ`_5ZPSBp^6 zcopV#q*)}0lpu<%;8b%gK8F({G=#c?0750JVSEXR6Y+KHzlA+Bon2N+M~w5u0!H>F z;?3bvBK$rYxTn{|V(QDZa6E~VIe1aV$?^fgBZN~&rtBthe`eMoE4E;lVH3n+6Wo%E z@%P@oH_%9vN`>{VfbXO$XmG~CJf|77G-(`bSb?wNH?9SFJGtK^ph4li1bqbk2wK3W z_pdgSh3j>+8ui(_lj!Q*ade})GpUf{yny+i-pU=Z6G4N;>btAUOlA;Dt;cPi+H^9h0} zf?0wE0vcDh=X}-v5^Woxf<^^VR9KYBtg=E8#b2@_BS*=7i%e!=DID!`v;_e BWncgR delta 550 zcmZ9Izb`{k6vyA~YZ|?`eM+AwA-tmXl>V+3)gRQ4E*6_bn%);3(Bw8oVUVykch$ja zwODLcqs1U5|A2)-;+!XF+?V&c-}B>}bKd(jI>TDi>!k{u5AxpVaksS*qO>CqIUeI< zeEiJ0qk%Gb5L7RZ^9eo)%EuFYiibe;@g$$+bD;WpbXze~@`BvDGOCFMqiCBQEj{4`IR^eS$6e7|fL`I&|Aj#SGI~rgiOfDkw@`a9u zzndU7)hBJ=s&Px~)*H3MJ*y>flo&+J+x{#YU^5un{sQ_*S{|_oDMH<3(cfBGKC)M~ zf-$V3h>E;q0r~9FPm&O}TmYTuBtS$JOVwA*WwEk^SVoi(>xd1+CIWxH$Ri4fGNLLwz7Uy~ N_dbJCl{)d!oL@|eWwrnS diff --git a/app/core/network/dht/__pycache__/replication.cpython-310.pyc b/app/core/network/dht/__pycache__/replication.cpython-310.pyc index f4924e0021a59bad4e61b5554266ab271f27bf4d..667191cf9dc2a5f1f2139db3d9e050bf2013c089 100644 GIT binary patch delta 2851 zcmZWreQaA-6~FiTy=TXczn%Caah&hQt;ALp)-IGzbif#>S=SDUZB0`*_a$-SIPQHf zS(@F~6eYqhn?k$Y(T;ZAYzYt*0u3Soe;_3O0){F;6KL|#*#6m{FtI-nAg!G9+^|w? z-QPR+eBE=-{oMZQFV94l!r>r+-)qPJIrsHLKZ;B98D4kUAbtQvS zpi+S*&Zx(t2aOVvpHu_>qqt=w_fT_q0RM@-JiY|UP8HFL{YS+-b!DhH8q!+qf6=!nPYTC z6|%L^1r$mIM_tSAkDKi^<7p9ZYtA=qUuvM+mhzpzEt?HrXIsK1mwXrA@kJxP+5sD)KgS z$p+4G#!2EDjfCsNk$8xAZjyCyk!+-z z1moReAnt0C%hY(qps%aP!|l$v$5W+Yju5w z@%UZ&$52nk?$$%!Ji(KjUxI!l@i>Xr2Bh5RgrKlwOJOv>q!DH&gZ6h!zkJ;&^vmq-`yhDqtdBzgKyP1z~U z>0WozPR7Z~w97oBJ)Thy^yziNGctXh-0->ZhxO2#CNd+lu1|LJ>~_G(LFRdx!!Tsd zWiox3$atf#9VBvht0_)UQe~cJsPKGpgW~F1&>+b5ZM(2L1w7|P=HorvKBvcKvga>N8uZc^{?DN=C=ly5R|8a^$$ z2#n`|5?O>C@`8seWy_vkTC5%U3G^)-sZYI=KcM7rhkB>?czgpmzkv23iUMTqpn5NV zzgo%%=>@fxANPxG;0y1d*XM!!t@>mB2K`W3ed9-NYVAXC5LJc%CLopwb}%mS4Fo(9 z?+U2n@T^%iS8L+P@6-oX9!!0yFc`y%i)#qpGV#it!g3E`WG|uMRfGfT-QJYCGWW)S=whPnqFlXaepY1?u-BMS_3SG~l=pq76 znD{lqlkf`5)tKus?~kNbWPpZmkD9i0xd<>dy>7m??L+Te*lR4t6Ts8 delta 1975 zcmZuyOKenC7(VAdX70Qnopv5=XWCLqr-inlR8llVUTO?YgpEAvFmR@{({@_V+(L`@ z+UN|-t)>DVqDCN27aB3J(AfYBBOBu*EY!rsxM8JkB(gJcq5pp;txBBae)s#I=l{<= z=byXBS6=kb`h0Ezza61JCLSO9)PGWP4i8Su&g2GbT)OF@=~{>1LzBnAg~!(+f$SxK zX(So(T;K_MT)YMrrAhQh-}e>J?&JY9EsF1>7wD`w5u2a|@onq?ofq-=oX14tD+q?T9N#*G zIqUM=yw2O#Ep`_hQH%kk<;IG$s4(rtv_x^~qyqBVFAPM3M<#?86A~eoG^gmKM3yCk zRH;Q)>9VRzj2co^UL_^TR6vq+MOUj1T{0Binb%qgqa|sTmH(3DZ` zcgZ060H{t|>LFsO)u08HQ^H)#T?RQHs)kp|UT|KfU~O6@#m_2aoH7sdmbGQMLMpT( zRn&2X`Ix_~nSR4>$(91WV_9nvazz=JSbzn~ngI=$s2M;%BT!nh9rXwr!!3(hyMSr{UrE*DITV-(W1D}G13RO#J(V1 zpywL^M$;1aqp$5N3bZKeSE~qhA-o%JUCJdJL1L{`* z&h8WUy9dQDol*Lh_^UJCj90J`f#ayI@N8k~tJ;>XcWL?rn(R^GMK+FryTIEJ(g+6; zCd6+Wrr_ot?(U{P)?V!%ru~g-Gj&>cv>KduF4)1M1f+$W-DB6|?@4GEKmko(7DGJ= z@k!F_UU%ygUnHLt!yO^<)287j>?g36!GR&B=I%XpgpUB~&biF#?DTxD?wI4b@yP{#Oxu-`kV$q`^d zIVSn3M#JFX7p?v8d9Z$d7~#D5ygw1Qr`RK(6wPq<`geKkfr|e3IEW(Ef z&j3J)w~^lgsJo_f*d7m9(ZOs5KAN732HQud;p zUB%d=%(OUdVVDlXz#m{g_KjgEQ-(?VFUXJVD^KN>mtv+2zwfM9k{xGYHRtF%-?{8L z-}QGknx3{9o;!d0a{14P82dXlCLarp%Z%}O2tqKyeHQQr=R|A17U&H$g8?%a@^5=s2#=N2(|4^{dSOC2sX2ik!M&mG6niX?cY0fLJ>y0CCGjXWS4CWnO zZF5FHjF_Js@s1j?!m5YAptDwaI@JurC}}30D2%H{YQ5C-{pL;IOO0!tR+8#h!qwFJ zVUPNoKE|8%PCNAE7d)GONP4YE3ODd%+jCn{c+1DMs+N|nz53RhsxLk0Hxn;T+?7rk zX9HXG^2x_Q<1z{Rbr2qFaKRdy;0;}z6s9QRu!cA#ri25k;F-6$sE9*i;eMe}RISIw z5pfhPOPm&uiQ^bm5+~}cdM357k9rbGZ|#NG!lwf-h~#Poyo&UC*lM$~(n*#p!b?0E zbiz)YbXpa4m`dyg%@EDFmKMWEcy33edYs7AjFkvv5qoXvsPczs4do>|$pVN0A262< zG}OGqP-}wK*~fgTs>^Bg%LAxV%guxsSM}70J^$9Y?_(zCFl76};&K#ti~XJ-HH9ai zz8NKp&0cR2viBB4FIkD??M1PiEXH)yS`RvNT^uKIU3yP!qB6P3ZN7Fe7f3B1kxt#} zgl$juWG75gQ+i2XhTC(w(yxy+d80+A%O9fY#OkyThym-e4L;zTyw2nd7*$Ok#9ykn zJ(z@?1uaP|>P%TRPtBvKs57P|9lqv_wup(Te%o733wN7--`g&eQC^1f9I%bTz#J4dHJGEeR4vG3n3IX&F`|?moB&ld)yq<| zB|Yq0b-*5AYnhoi*{L#HsTMPF?pci1k*83R8p$#Dh&t7K{RGjD#Uyyc(m4GLD*v`j zZQChM2p>aJs}r^&I7WMyp%-W}ijsiN*?fT^%j` zwk}U52B^Y1;^{!&FiC!!+C5`nY!ora+9bX)G26&Zs-#RxnaqR`OW1=FY+6uKlBbD5 z-UWdxyL*BdN;1V(UrLWGT@BqUB6$WQX|xhixnOYz0&?Iw4tI2-*A9*Bb5bSSN5{rA zrpQchv?yBU579b`5+12RaG~Ab5#c7g#?~RKs=l;q5=|g0tW#(?3xdMQjq*>md;Fe8 z(%WF0$V>yR%RBrk`<-@&?2V@-vY#d#+CxmXlgRBgS1E?b^H>A%Myp*&wOc(&OYXH5 z`=WriLH)f;FQ8j_=UKLPZgl!MZ1&>iD5+iaBRFAvsWzUG`+lL5ctKp{nHDIRjU7L= z@)f?FuRlK-pz_|Os*$-#nWmFgRqO>E$;B5Jv=B)`iH zcZ#TURFSdCXQ&#BMo}s-QdFg##AL!2Gq|nU1+C<4YxB-m37P zHo}^+TMBCfSS9Rr%{t$VSHo7Xy;eHki&tAwul+1uR-{bZY++yWMXWG!PecR!NTZsigR3?z?_oeJfS$JLWRSkjq)3B?#C(Co$GJMWT-gtrQX@u5Ll-9}$8n5^AX$OK(A&)4eG2 z-JbN^90x|8JCX>mup@_A}mZauS3_^%4hBP1j*q5oq9 zE!S=N%{Ye7jD3N;=QR|CWy*Yk&+@g$9@fVQ)nu`O9D92P`pK|pE}uypL*_(I2D{_& z00i0gCi~3=1XgW>CkU@dO^{K%P-1}C5T?P_bpqvemgrprX{~@_q7-`-1ZKT!p_EW; zlqmp$BBi#s0S?Er>+Bg8Kfi)RN56Mal#~GxijRZ{Dr%>tq!R#7H-jEx-8F1D3n~<& z6@rkD6SW5!j9`XZL%xb3d1|B}nesT*B?SZEhP^IYbYWtEVoMkpS5Q$dB+xMPm0=j5 zER^hL{!1&J{pZ@T{S=celBF6FcxotHozw-{DFsKRi$8(b(Zw5MU3B#wZ9GYuQGh~0 z3DAHM-6D#GQbMsIc?+7;Hl_wTL5lD=xX_oI&CuNf+R&Su&D7m&w(e#t=&o7?{WjKc zHld@v`iBnd`{~fi%#W0imgCkk?h@`@3La6IB_L8j=FXIfC8ek8^j>YM^raL@qJQMX zRBs9a+!zHxC&|6)EX^Q?k*`xVPA@9zDwP7@{S19^4VBHyN~O6X^MCV8$F?*JD$aj4 zKmCtKDi}-dx+EazD6pe~&jmE{-~*fp_d+6331owuEaNQDb&1pp%!t11B4Nm@n48)8 z8${8)0+%QiKa`ZdPu!QO8ix$JSFE7Kl-$T-kwACK+QL2-3KP%VW*@My?;Sv$ilAnG zDs>Amfc6Ut`D+7HAz3p&hn}6|9Hz)0qxxSkUha1+n*A<{iiGp(Nc$cKx=>(d7j!2N zqoTG>cxb$J~ws&7#BO{x^8 zQx-|;+z;KQ(>vL$<@tV|(UgrUfsr@L@1Xh~N=&zA!g({mdG<{C=xpg&LG`xKvFjmZ zc9}93VZbp6LY~&t1p36VXT#$VFm@4o^RH22vI%-KVQmG|$Y(M^5>-7$2=`W>ZW1^I z#q88fRtW=0m=cpTUlypMYf5?;Ungqxb)x3BsdwyKQ2NJ2W zB*980rL^?&&s(1Qx-}6YN8(AXiy5fDMB-Md&tXS2M|1cLFYDTKxDu+Ltu5%zOADIz GpZ@|Jo?Q(9 delta 95 zcmcbi(I}*y&&$ij00dHxzGqxzXJB{?;vfTNAjg4$fw4GlqIN7>3TH5bCf6p$lkCiX jn*5Uug-a%X5)zrLD#91L#KS1SD8vi^Q$i2( diff --git a/app/core/network/dht/config.py b/app/core/network/dht/config.py index ac199a2..ee90d63 100644 --- a/app/core/network/dht/config.py +++ b/app/core/network/dht/config.py @@ -41,6 +41,10 @@ class DHTConfig: window_size: int = _env_int("DHT_METRIC_WINDOW_SEC", 3600) default_q: float = _env_float("DHT_MIN_Q", 0.6) seed_refresh_interval: int = _env_int("DHT_SEED_REFRESH_INTERVAL", 30) + # Gossip / backoff tuning + gossip_interval_sec: int = _env_int("DHT_GOSSIP_INTERVAL_SEC", 30) + gossip_backoff_base_sec: int = _env_int("DHT_GOSSIP_BACKOFF_BASE_SEC", 5) + gossip_backoff_cap_sec: int = _env_int("DHT_GOSSIP_BACKOFF_CAP_SEC", 600) @lru_cache @@ -51,4 +55,3 @@ def load_config() -> DHTConfig: dht_config = load_config() - diff --git a/app/core/network/dht/membership.py b/app/core/network/dht/membership.py index 12412d0..bfb5edb 100644 --- a/app/core/network/dht/membership.py +++ b/app/core/network/dht/membership.py @@ -141,6 +141,23 @@ class MembershipState: return max(max(filtered_reports), local_estimate) return local_estimate + def n_estimate_trusted(self, allowed_ids: set[str]) -> float: + """Оценка размера сети только по trusted узлам. + Берём активных участников, пересекаем с allowed_ids и оцениваем по их числу + и по их N_local репортам (если доступны). + """ + self.report_local_population() + active_trusted = {m["node_id"] for m in self.active_members(include_islands=True) if m.get("node_id") in allowed_ids} + filtered_reports = [ + value for node_id, value in self.n_reports.items() + if node_id in active_trusted and self.reachability_ratio(node_id) >= dht_config.default_q + ] + # Для доверенных полагаемся на фактическое количество активных Trusted + local_estimate = float(len(active_trusted)) + if filtered_reports: + return max(max(filtered_reports), local_estimate) + return local_estimate + def to_dict(self) -> Dict[str, Any]: return { "members": self.members.to_dict(), @@ -216,4 +233,3 @@ class MembershipManager: def active_members(self) -> List[Dict[str, Any]]: return self.state.active_members() - diff --git a/app/core/network/dht/prometheus.py b/app/core/network/dht/prometheus.py index d12f34f..ff973bc 100644 --- a/app/core/network/dht/prometheus.py +++ b/app/core/network/dht/prometheus.py @@ -29,6 +29,10 @@ merge_conflicts = Counter("dht_merge_conflicts_total", "Number of DHT merge conf view_count_total = Gauge("dht_view_count_total", "Total content views per window", ["content_id", "window"]) unique_estimate = Gauge("dht_unique_view_estimate", "Estimated unique viewers per window", ["content_id", "window"]) watch_time_seconds = Gauge("dht_watch_time_seconds", "Aggregate watch time per window", ["content_id", "window"]) +gossip_success = Counter("dht_gossip_success_total", "Successful gossip posts", ["peer"]) +gossip_failure = Counter("dht_gossip_failure_total", "Failed gossip posts", ["peer"]) +gossip_skipped = Counter("dht_gossip_skipped_total", "Skipped gossip posts due to backoff", ["peer", "reason"]) +gossip_backoff = Gauge("dht_gossip_backoff_seconds", "Gossip backoff seconds remaining", ["peer"]) def record_replication_under(content_id: str, have: int) -> None: @@ -51,3 +55,17 @@ def update_view_metrics(content_id: str, window_id: str, views: int, unique: flo view_count_total.labels(content_id=content_id, window=window_id).set(views) unique_estimate.labels(content_id=content_id, window=window_id).set(unique) watch_time_seconds.labels(content_id=content_id, window=window_id).set(watch_time) + + +def record_gossip_success(peer: str) -> None: + gossip_success.labels(peer=peer).inc() + gossip_backoff.labels(peer=peer).set(0) + + +def record_gossip_failure(peer: str, backoff_sec: float) -> None: + gossip_failure.labels(peer=peer).inc() + gossip_backoff.labels(peer=peer).set(backoff_sec) + + +def record_gossip_skipped(peer: str, reason: str) -> None: + gossip_skipped.labels(peer=peer, reason=reason).inc() diff --git a/app/core/network/dht/replication.py b/app/core/network/dht/replication.py index cd24457..61b5487 100644 --- a/app/core/network/dht/replication.py +++ b/app/core/network/dht/replication.py @@ -166,15 +166,20 @@ class ReplicationManager: .to_dict(), ) - def ensure_replication(self, content_id: str, membership: MembershipState, now: Optional[float] = None) -> ReplicationState: + def ensure_replication(self, content_id: str, membership: MembershipState, now: Optional[float] = None, allowed_nodes: Optional[set[str]] = None) -> ReplicationState: now = now or _now() state = self._load_state(content_id) - n_estimate = max(1.0, membership.n_estimate()) + if allowed_nodes is not None and len(allowed_nodes) > 0: + n_estimate = max(1.0, membership.n_estimate_trusted(allowed_nodes)) + else: + n_estimate = max(1.0, membership.n_estimate()) p_value = max(0, round(math.log2(max(n_estimate / dht_config.replication_target, 1.0)))) prefix, _ = bits_from_hex(content_id, p_value) active = membership.active_members(include_islands=True) + if allowed_nodes is not None: + active = [m for m in active if m.get("node_id") in allowed_nodes] responsible = [] for member in active: node_prefix, _total = bits_from_hex(member["node_id"], p_value) @@ -265,6 +270,44 @@ class ReplicationManager: rest = [m for m in active if m["node_id"] not in {n for _, n, *_ in rank(responsible)}] assign_with_diversity(rank(rest)) + # Финальный добор по ASN, если всё ещё не достигли диверсификации + if not state.diversity_satisfied(): + current_asn = {lease.asn for lease in state.leases.values() if lease.asn is not None} + by_asn: Dict[int, List[dict]] = {} + for m in active: + a = m.get('asn') + if a is None: + continue + by_asn.setdefault(int(a), []).append(m) + for a, group in by_asn.items(): + if a in current_asn: + continue + # берём лучшего кандидата этой ASN + score, node_id, asn, ip_octet = min( + ( + (rendezvous_score(content_id, g["node_id"]), g["node_id"], g.get("asn"), g.get("ip_first_octet")) + for g in group + ), + key=lambda item: item[0], + ) + if node_id in leases_by_node: + continue + lease = ReplicaLease( + node_id=node_id, + lease_id=f"{content_id}:{node_id}", + issued_at=now, + expires_at=now + dht_config.lease_ttl, + asn=asn, + ip_first_octet=ip_octet, + heartbeat_at=now, + score=score, + ) + state.assign(lease) + leases_by_node[node_id] = lease + current_asn.add(int(a)) + if state.diversity_satisfied(): + break + # Ensure we do not exceed replication target with duplicates if len(state.leases) > dht_config.replication_target: # Drop lowest scoring leases until target satisfied while preserving diversity criteria diff --git a/app/core/network/dht/store.py b/app/core/network/dht/store.py index c9c3f54..9dc4991 100644 --- a/app/core/network/dht/store.py +++ b/app/core/network/dht/store.py @@ -55,3 +55,86 @@ class DHTStore: def snapshot(self) -> Dict[str, Dict[str, Any]]: return {fp: record.to_payload() | {"signature": record.signature} for fp, record in self._records.items()} + + +# ---- Persistent adapter (DB-backed) ---- +try: + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + from app.core._config import DATABASE_URL + from app.core.models.dht import DHTRecordRow + + def _sync_engine_url(url: str) -> str: + return url.replace('+asyncpg', '+psycopg2') if '+asyncpg' in url else url + + class PersistentDHTStore(DHTStore): + """DHT хранилище с простейшей синхронной персистенцией в БД.""" + + def __init__(self, node_id: str, signer: Signer, db_url: str | None = None): + super().__init__(node_id, signer) + self._engine = create_engine(_sync_engine_url(db_url or DATABASE_URL), pool_pre_ping=True) + self._Session = sessionmaker(bind=self._engine) + + def _db_get(self, fingerprint: str) -> DHTRecord | None: + with self._Session() as s: + row = s.get(DHTRecordRow, fingerprint) + if not row: + return None + rec = DHTRecord.create( + key=row.key, + fingerprint=row.fingerprint, + value=row.value or {}, + node_id=row.node_id, + logical_counter=row.logical_counter, + signature=row.signature, + timestamp=row.timestamp, + ) + return rec + + def _db_put(self, record: DHTRecord) -> None: + with self._Session() as s: + row = s.get(DHTRecordRow, record.fingerprint) + if not row: + row = DHTRecordRow( + fingerprint=record.fingerprint, + key=record.key, + schema_version=record.schema_version, + logical_counter=record.logical_counter, + timestamp=record.timestamp, + node_id=record.node_id, + signature=record.signature, + value=record.value, + ) + s.add(row) + else: + row.key = record.key + row.schema_version = record.schema_version + row.logical_counter = record.logical_counter + row.timestamp = record.timestamp + row.node_id = record.node_id + row.signature = record.signature + row.value = record.value + s.commit() + + def get(self, fingerprint: str) -> DHTRecord | None: + rec = super().get(fingerprint) + if rec: + return rec + rec = self._db_get(fingerprint) + if rec: + self._records[fingerprint] = rec + return rec + + def put(self, key: str, fingerprint: str, value: Dict[str, Any], logical_counter: int, merge_strategy=latest_wins_merge) -> DHTRecord: + rec = super().put(key, fingerprint, value, logical_counter, merge_strategy) + self._db_put(rec) + return rec + + def merge_record(self, incoming: DHTRecord, merge_strategy=latest_wins_merge) -> DHTRecord: + merged = super().merge_record(incoming, merge_strategy) + self._db_put(merged) + return merged +except Exception: + # Fallback: без SQLAlchemy используем чисто in-memory (для тестов/минимальных окружений) + class PersistentDHTStore(DHTStore): + pass diff --git a/app/core/network/maintenance.py b/app/core/network/maintenance.py index 2ef49cf..bc3baca 100644 --- a/app/core/network/maintenance.py +++ b/app/core/network/maintenance.py @@ -7,6 +7,8 @@ from sqlalchemy import select from app.core.logger import make_log from app.core.models.node_storage import StoredContent from app.core.network.dht import dht_config +from app.core.network.nodes import list_known_public_nodes +import httpx from app.core.storage import db_session @@ -23,9 +25,25 @@ async def replication_daemon(app): async with db_session(auto_commit=False) as session: rows = await session.execute(select(StoredContent.hash)) content_hashes = [row[0] for row in rows.all()] + # Build allowed (trusted) node_ids set + from app.core.models.my_network import KnownNode + from app.core._utils.b58 import b58decode + from app.core.network.dht.crypto import compute_node_id + trusted_rows = (await session.execute(select(KnownNode))).scalars().all() + allowed_nodes = set() + for kn in trusted_rows: + role = (kn.meta or {}).get('role') if kn.meta else None + if role == 'trusted' and kn.public_key: + try: + nid = compute_node_id(b58decode(kn.public_key)) + allowed_nodes.add(nid) + except Exception: + pass + # Always include ourselves + allowed_nodes.add(memory.node_id) for content_hash in content_hashes: try: - state = memory.replication.ensure_replication(content_hash, membership_state) + state = memory.replication.ensure_replication(content_hash, membership_state, allowed_nodes=allowed_nodes) memory.replication.heartbeat(content_hash, memory.node_id) make_log("Replication", f"Replicated {content_hash} leader={state.leader}", level="debug") except Exception as exc: @@ -50,3 +68,74 @@ async def heartbeat_daemon(app): except Exception as exc: make_log("Replication", f"heartbeat failed: {exc}", level="warning") await asyncio.sleep(dht_config.heartbeat_interval) + + +async def dht_gossip_daemon(app): + # Периодически публикуем снимок DHT на известные публичные ноды + await asyncio.sleep(7) + memory = getattr(app.ctx, "memory", None) + if not memory: + return + while True: + try: + # собираем список публичных хостов доверенных узлов + async with db_session(auto_commit=True) as session: + from sqlalchemy import select + from app.core.models.my_network import KnownNode + nodes = (await session.execute(select(KnownNode))).scalars().all() + urls = [] + peers = [] + for n in nodes: + role = (n.meta or {}).get('role') if n.meta else None + if role != 'trusted': + continue + pub = (n.meta or {}).get('public_host') or n.ip + if not pub: + continue + base = pub.rstrip('/') + if not base.startswith('http'): + base = f"http://{base}:{n.port or 80}" + url = base + "/api/v1/dht.put" + urls.append(url) + peers.append(base) + if urls: + snapshot = memory.dht_store.snapshot() + records = list(snapshot.values()) + payload = { + 'public_key': None, # получатель обязует себя проверять подпись записи, не отправителя тут + 'records': records, + } + # public_key не обязателен в пакетном режиме, записи содержат собственные подписи + timeout = httpx.Timeout(5.0, read=10.0) + async with httpx.AsyncClient(timeout=timeout) as client: + # Throttle/backoff per peer + from time import time as _t + from app.core.network.dht.prometheus import record_gossip_success, record_gossip_failure, record_gossip_skipped + if not hasattr(dht_gossip_daemon, '_backoff'): + dht_gossip_daemon._backoff = {} + backoff = dht_gossip_daemon._backoff # type: ignore + for url, peer in zip(urls, peers): + now = _t() + st = backoff.get(peer) or { 'fail': 0, 'next': 0.0 } + if now < st['next']: + record_gossip_skipped(peer, 'backoff') + continue + try: + r = await client.post(url, json=payload) + if 200 <= r.status_code < 300: + backoff[peer] = { 'fail': 0, 'next': 0.0 } + record_gossip_success(peer) + else: + raise Exception(f"HTTP {r.status_code}") + except Exception as exc: + st['fail'] = st.get('fail', 0) + 1 + base = max(1, int(dht_config.gossip_backoff_base_sec)) + cap = max(base, int(dht_config.gossip_backoff_cap_sec)) + wait = min(cap, base * (2 ** max(0, st['fail'] - 1))) + st['next'] = now + wait + backoff[peer] = st + record_gossip_failure(peer, wait) + make_log('DHT.gossip', f'gossip failed {url}: {exc}; backoff {wait}s', level='debug') + except Exception as exc: + make_log('DHT.gossip', f'iteration error: {exc}', level='warning') + await asyncio.sleep(dht_config.gossip_interval_sec)