relayers new code

This commit is contained in:
user committed 2025-09-13 14:48:57 +03:00
1 parent 846e32c5b1
commit 650059b0d3
37 files changed
+2678 -16

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
# Network package for MY nodes
+38
View File
@@ -0,0 +1,38 @@
import os
from typing import List
from app.core._config import PROJECT_HOST
from .constants import NODE_TYPE_PUBLIC, NODE_TYPE_PRIVATE
def _csv_list(val: str) -> List[str]:
return [x.strip() for x in (val or "").split(",") if x.strip()]
# Handshake / network config driven by env
NODE_PRIVACY = os.getenv("NODE_PRIVACY", NODE_TYPE_PUBLIC).strip().lower()
if NODE_PRIVACY not in (NODE_TYPE_PUBLIC, NODE_TYPE_PRIVATE):
NODE_PRIVACY = NODE_TYPE_PUBLIC
# Public endpoint for network (can be empty for private nodes)
_env_public_host = os.getenv("PUBLIC_HOST")
PUBLIC_HOST = _env_public_host if (_env_public_host is not None and _env_public_host.strip() != "") else None
HANDSHAKE_INTERVAL_SEC = int(os.getenv("HANDSHAKE_INTERVAL_SEC", "5"))
UNSUPPORTED_RECHECK_INTERVAL_SEC = int(os.getenv("UNSUPPORTED_RECHECK_INTERVAL_SEC", str(24 * 3600)))
BOOTSTRAP_SEEDS = _csv_list(os.getenv("BOOTSTRAP_SEEDS", ""))
BOOTSTRAP_REQUIRED = int(os.getenv("BOOTSTRAP_REQUIRED", "1")) == 1
BOOTSTRAP_TIMEOUT_SEC = int(os.getenv("BOOTSTRAP_TIMEOUT_SEC", "20"))
# Security knobs
NETWORK_TLS_VERIFY = int(os.getenv("NETWORK_TLS_VERIFY", "1")) == 1
HANDSHAKE_TS_TOLERANCE_SEC = int(os.getenv("HANDSHAKE_TS_TOLERANCE_SEC", "300"))
HANDSHAKE_RATE_LIMIT_PER_MIN = int(os.getenv("HANDSHAKE_RATE_LIMIT_PER_MIN", "60"))
# Capabilities
NODE_IS_BOOTSTRAP = int(os.getenv("NODE_IS_BOOTSTRAP", "0")) == 1
MAX_CONTENT_SIZE_MB = int(os.getenv("MAX_CONTENT_SIZE_MB", "512"))
# Privacy allowlist (for NODE_PRIVACY=private)
PRIVATE_ALLOWLIST = _csv_list(os.getenv("PRIVATE_ALLOWLIST", "/api/system.version"))
+6
View File
@@ -0,0 +1,6 @@
CURRENT_PROTOCOL_VERSION = "3.0.0"
# Node roles/types
NODE_TYPE_PUBLIC = "public"
NODE_TYPE_PRIVATE = "private"
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
import time
from typing import Dict, Set
from app.core.network.config import HANDSHAKE_RATE_LIMIT_PER_MIN, HANDSHAKE_TS_TOLERANCE_SEC
def check_rate_limit(memory, remote_ip: str) -> bool:
"""Simple per-IP rate limit within current minute window.
Returns True if allowed, False if limited.
"""
now = int(time.time())
minute = now // 60
rl = getattr(memory, "_handshake_rl", None)
if rl is None or rl.get("minute") != minute:
rl = {"minute": minute, "counts": {}}
memory._handshake_rl = rl
counts = rl["counts"]
cnt = counts.get(remote_ip, 0)
if cnt >= HANDSHAKE_RATE_LIMIT_PER_MIN:
return False
counts[remote_ip] = cnt + 1
return True
def check_timestamp_fresh(ts: int) -> bool:
now = int(time.time())
return abs(now - int(ts)) <= HANDSHAKE_TS_TOLERANCE_SEC
def check_and_remember_nonce(memory, pubkey_b58: str, nonce: str) -> bool:
"""Return True if nonce is new; remember nonce with TTL ~ tolerance window.
We keep a compact in-memory set per pubkey.
"""
now = int(time.time())
store = getattr(memory, "_handshake_nonces", None)
if store is None:
store = {}
memory._handshake_nonces = store
entry = store.get(pubkey_b58)
if entry is None:
entry = {"nonces": {}, "updated": now}
store[pubkey_b58] = entry
nonces: Dict[str, int] = entry["nonces"]
# prune old nonces
to_delete = [k for k, t in nonces.items() if now - int(t) > HANDSHAKE_TS_TOLERANCE_SEC]
for k in to_delete:
nonces.pop(k, None)
if nonce in nonces:
return False
# prevent unbounded growth
if len(nonces) > 2048:
# drop half oldest
for k, _ in sorted(nonces.items(), key=lambda kv: kv[1])[:1024]:
nonces.pop(k, None)
nonces[nonce] = now
entry["updated"] = now
return True
+113
View File
@@ -0,0 +1,113 @@
from __future__ import annotations
import json
from datetime import datetime
import os
import time
import shutil
import secrets
from typing import Dict, Any
from base58 import b58encode
from sqlalchemy import select
from app.core._secrets import hot_pubkey, hot_seed
from app.core._crypto.signer import Signer
from app.core.logger import make_log
from app.core.models.my_network import KnownNode
from app.core.models.node_storage import StoredContent
from app.core.storage import db_session
from .constants import CURRENT_PROTOCOL_VERSION
from .nodes import list_known_public_nodes
from .config import PUBLIC_HOST, NODE_PRIVACY, NODE_IS_BOOTSTRAP, MAX_CONTENT_SIZE_MB
from app.core._config import ALLOWED_CONTENT_TYPES
from .constants import NODE_TYPE_PUBLIC
START_TS = time.time()
async def _metrics(session) -> Dict[str, Any]:
# Lightweight metrics for handshake
# Count total content (any type)
total_contents = (await session.execute(select(StoredContent))).scalars().all()
content_count = len(total_contents)
# Basic system metrics
try:
load1, load5, load15 = os.getloadavg()
except Exception:
load1 = load5 = load15 = 0.0
try:
from app.core._config import UPLOADS_DIR
du = shutil.disk_usage(UPLOADS_DIR)
disk_total_gb = round(du.total / (1024 ** 3), 2)
disk_free_gb = round(du.free / (1024 ** 3), 2)
except Exception:
disk_total_gb = disk_free_gb = -1
uptime_sec = int(time.time() - START_TS)
return {
"content_count": content_count,
"uptime_sec": uptime_sec,
"loadavg": [load1, load5, load15],
"disk_total_gb": disk_total_gb,
"disk_free_gb": disk_free_gb,
}
def _sign(obj: Dict[str, Any]) -> str:
signer = Signer(hot_seed)
blob = json.dumps(obj, sort_keys=True, separators=(",", ":")).encode()
return signer.sign(blob)
async def build_handshake_payload(session) -> Dict[str, Any]:
payload = {
"version": CURRENT_PROTOCOL_VERSION,
"public_key": b58encode(hot_pubkey).decode(),
# public_host is optional for private nodes
**({"public_host": PUBLIC_HOST} if PUBLIC_HOST else {}),
"node_type": NODE_PRIVACY if NODE_PRIVACY != NODE_TYPE_PUBLIC else NODE_TYPE_PUBLIC,
"metrics": await _metrics(session),
"capabilities": {
"accepts_inbound": NODE_PRIVACY == NODE_TYPE_PUBLIC,
"is_bootstrap": NODE_IS_BOOTSTRAP,
"supported_types": ALLOWED_CONTENT_TYPES,
"max_content_size_mb": MAX_CONTENT_SIZE_MB,
},
"timestamp": int(datetime.utcnow().timestamp()),
"nonce": secrets.token_hex(16),
}
try:
payload["known_public_nodes"] = await list_known_public_nodes(session)
except Exception:
payload["known_public_nodes"] = []
payload["signature"] = _sign(payload)
return payload
async def compute_node_info(session) -> Dict[str, Any]:
node_info = {
"id": b58encode(hot_pubkey).decode(),
"public_key": b58encode(hot_pubkey).decode(),
**({"public_host": PUBLIC_HOST} if PUBLIC_HOST else {}),
"version": CURRENT_PROTOCOL_VERSION,
"node_type": NODE_PRIVACY,
"metrics": await _metrics(session),
"capabilities": {
"accepts_inbound": NODE_PRIVACY == NODE_TYPE_PUBLIC,
"is_bootstrap": NODE_IS_BOOTSTRAP,
"supported_types": ALLOWED_CONTENT_TYPES,
"max_content_size_mb": MAX_CONTENT_SIZE_MB,
},
}
return node_info
def sign_response(data: Dict[str, Any]) -> Dict[str, Any]:
body = {
**data,
"timestamp": int(datetime.utcnow().timestamp()),
}
sig = _sign(body)
body["server_public_key"] = b58encode(hot_pubkey).decode()
body["server_signature"] = sig
return body
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
import base64
from typing import Optional
import httpx
from base58 import b58encode
from app.core._secrets import hot_seed, hot_pubkey
from app.core.crypto.x25519 import ed25519_to_x25519
from app.core.logger import make_log
from app.core.network.nodesig import sign_headers
async def request_key_from_peer(base_url: str, encrypted_cid: str) -> Optional[bytes]:
"""
Request a sealed key from peer and decrypt it using our X25519 private key.
Returns plaintext DEK bytes or None on failure.
"""
try:
sk_x, pk_x = ed25519_to_x25519(hot_seed)
node_id = b58encode(hot_pubkey).decode()
body = {
"encrypted_cid": encrypted_cid,
"requestor_node_id": node_id,
"recipient_box_pub": base64.b64encode(bytes(pk_x)).decode(),
}
path = "/api/v1/keys.request"
headers = sign_headers("POST", path, json.dumps(body).encode(), hot_seed, b58encode(hot_pubkey).decode())
async with httpx.AsyncClient(timeout=15) as client:
r = await client.post(f"{base_url.rstrip('/')}{path}", json=body, headers=headers)
if r.status_code != 200:
make_log('key_client', f"{base_url} returned {r.status_code}: {r.text}", level='warning')
return None
j = r.json()
sealed_b64 = j.get('sealed_key_b64')
if not sealed_b64:
return None
sealed = base64.b64decode(sealed_b64)
from nacl.public import SealedBox
sb = SealedBox(sk_x)
dek = sb.decrypt(sealed)
return dek
except Exception as e:
make_log('key_client', f"request/decrypt failed: {e}", level='error')
return None
+261
View File
@@ -0,0 +1,261 @@
from __future__ import annotations
import asyncio
from datetime import datetime, timedelta
import json
from typing import Dict, Any, Optional, List
import httpx
from base58 import b58encode
from sqlalchemy import select, update
from app.core.logger import make_log
from app.core.models.my_network import KnownNode
from app.core.storage import db_session
from app.core._secrets import hot_pubkey
from .config import (
HANDSHAKE_INTERVAL_SEC,
UNSUPPORTED_RECHECK_INTERVAL_SEC,
BOOTSTRAP_SEEDS,
BOOTSTRAP_REQUIRED,
BOOTSTRAP_TIMEOUT_SEC,
NODE_PRIVACY,
NETWORK_TLS_VERIFY,
)
from .constants import NODE_TYPE_PRIVATE
from .semver import compatibility
from .constants import CURRENT_PROTOCOL_VERSION
def _now() -> datetime:
return datetime.utcnow()
async def upsert_known_node(session, host: str, port: int, public_key: str, meta: Dict[str, Any]) -> KnownNode:
# Host can be full URL; normalize host/ip and port if available
host = (host or "").replace("http://", "").replace("https://", "").strip("/")
h_only = host
if ":" in host:
h_only, port_str = host.rsplit(":", 1)
try:
port = int(port_str)
except Exception:
pass
# Prefer match by public_key (stable identity)
if public_key:
result = await session.execute(select(KnownNode).where(KnownNode.public_key == public_key))
row = result.scalars().first()
if row:
row.ip = h_only or row.ip
row.port = port or row.port
row.public_key = public_key or row.public_key
row.meta = {**(row.meta or {}), **(meta or {})}
row.last_sync = _now()
await session.commit()
return row
# Fallback by IP/host
result = await session.execute(select(KnownNode).where(KnownNode.ip == h_only))
row = result.scalars().first()
if row:
row.port = port or row.port
row.public_key = public_key or row.public_key
row.meta = {**(row.meta or {}), **(meta or {})}
row.last_sync = _now()
await session.commit()
return row
node = KnownNode(
ip=h_only,
port=port or 80,
public_key=public_key,
reputation=0,
last_sync=_now(),
meta=meta or {},
located_at=_now(),
)
session.add(node)
await session.commit()
return node
def _compatibility_for_meta(remote_version: str) -> str:
if not remote_version or remote_version == "0.0.0":
return "warning"
return compatibility(remote_version, CURRENT_PROTOCOL_VERSION)
async def list_known_public_nodes(session) -> List[Dict[str, Any]]:
rows = (await session.execute(select(KnownNode))).scalars().all()
result = []
for r in rows:
meta = r.meta or {}
if not meta.get("is_public", True):
continue
result.append({
"host": r.ip,
"port": r.port,
"public_key": r.public_key,
"version": meta.get("version"),
"compatibility": _compatibility_for_meta(meta.get("version", "0.0.0")),
"last_seen": (r.last_sync.isoformat() + "Z") if r.last_sync else None,
"public_host": meta.get("public_host"),
"capabilities": meta.get("capabilities") or {},
})
return result
async def _handshake_with(session, base_url: str) -> Optional[Dict[str, Any]]:
url = base_url.rstrip("/") + "/api/v1/network.handshake"
from .handshake import build_handshake_payload
payload = await build_handshake_payload(session)
timeout = httpx.Timeout(5.0, read=10.0)
async with httpx.AsyncClient(timeout=timeout, verify=NETWORK_TLS_VERIFY) as client:
r = await client.post(url, json=payload)
if r.status_code == 403 and NODE_PRIVACY == NODE_TYPE_PRIVATE:
# We are private; outbound is allowed, inbound denied by peers is fine
pass
r.raise_for_status()
data = r.json()
# Verify server signature if present
try:
import nacl.signing
from base58 import b58decode
required = ["server_signature", "server_public_key", "timestamp"]
if all(k in data for k in required):
signed_fields = {k: data[k] for k in data if k not in ("server_signature", "server_public_key")}
blob = json.dumps(signed_fields, sort_keys=True, separators=(",", ":")).encode()
vk = nacl.signing.VerifyKey(b58decode(data["server_public_key"]))
vk.verify(blob, b58decode(data["server_signature"]))
except Exception as e:
make_log("Handshake", f"Server signature verification failed for {base_url}: {e}", level='warning')
return data
async def pick_next_node(session) -> Optional[KnownNode]:
rows = (await session.execute(select(KnownNode))).scalars().all()
if not rows:
return None
# Prefer nodes with oldest last_sync
rows.sort(key=lambda r: (r.last_sync or datetime.fromtimestamp(0)))
now = _now()
for r in rows:
meta = r.meta or {}
compat = _compatibility_for_meta(meta.get("version", "0.0.0"))
if compat == "blocked":
last = datetime.fromisoformat(meta.get("unsupported_last_checked_at")) if meta.get("unsupported_last_checked_at") else None
if last and (now - last) < timedelta(seconds=UNSUPPORTED_RECHECK_INTERVAL_SEC):
continue
# Backoff after failures
if meta.get("last_failure_at"):
try:
last_fail = datetime.fromisoformat(meta.get("last_failure_at"))
fail_count = int(meta.get("fail_count", 1))
# Exponential backoff: 30s * 2^fail_count, capped 2h
wait = min(7200, 30 * (2 ** max(0, fail_count)))
if (now - last_fail) < timedelta(seconds=wait):
continue
except Exception:
pass
return r
# If we only have unsupported nodes and all are within cooldown, skip this round
return None
async def perform_handshake_round():
async with db_session(auto_commit=True) as session:
# Private nodes still do outbound handshakes; inbound typically unreachable without public endpoint
node = await pick_next_node(session)
if not node:
return
base_url = node.meta.get("public_host") or f"http://{node.ip}:{node.port}"
try:
resp = await _handshake_with(session, base_url)
# Merge known nodes received
for peer in (resp or {}).get("known_public_nodes", []):
try:
await upsert_known_node(
session,
host=peer.get("host") or peer.get("public_host") or "",
port=int(peer.get("port") or 80),
public_key=peer.get("public_key") or "",
meta={
"is_public": True,
"version": peer.get("version") or "0.0.0",
"public_host": peer.get("public_host") or (f"http://{peer.get('host')}:{peer.get('port')}" if peer.get('host') else None),
}
)
except Exception as e:
make_log("Handshake", f"Ignore bad peer from {base_url}: {e}", level='warning')
# Update last_sync and meta for node
node.last_sync = _now()
node.meta = {**(node.meta or {}), "last_response": resp, "fail_count": 0}
await session.commit()
make_log("Handshake", f"Handshake OK with {base_url}")
except Exception as e:
make_log("Handshake", f"Handshake failed with {base_url}: {e}", level='warning')
# Record incident-lite in meta
meta = node.meta or {}
meta["last_error"] = str(e)
meta["last_failure_at"] = _now().isoformat()
meta["fail_count"] = int(meta.get("fail_count", 0)) + 1
node.meta = meta
await session.commit()
async def network_handshake_daemon(app):
# Stagger start a bit to allow HTTP server to come up
await asyncio.sleep(3)
make_log("Handshake", f"Daemon started; interval={HANDSHAKE_INTERVAL_SEC}s")
while True:
try:
await perform_handshake_round()
except Exception as e:
make_log("Handshake", f"Round error: {e}", level='error')
await asyncio.sleep(HANDSHAKE_INTERVAL_SEC)
async def bootstrap_once_and_exit_if_failed():
# Do not try to bootstrap private nodes as inbound is blocked, but outbound required for seeds discovery
seeds = BOOTSTRAP_SEEDS or []
if not seeds:
return # Nothing to do
async with db_session(auto_commit=True) as session:
# If we already know nodes, skip bootstrap
have_any = (await session.execute(select(KnownNode))).scalars().first()
if have_any:
return
make_log("Bootstrap", f"Starting bootstrap with seeds={seeds}; required={BOOTSTRAP_REQUIRED}")
deadline = _now() + timedelta(seconds=BOOTSTRAP_TIMEOUT_SEC)
ok = False
for seed in seeds:
try:
async with db_session(auto_commit=True) as session:
resp = await _handshake_with(session, seed)
if resp:
ok = True
# Seed itself gets inserted by handshake handling route; also insert it explicitly
try:
await upsert_known_node(
session,
host=seed,
port=80,
public_key=resp.get("node", {}).get("public_key", ""),
meta={
"is_public": True,
"version": resp.get("node", {}).get("version", "0.0.0"),
"public_host": resp.get("node", {}).get("public_host") or seed,
}
)
except Exception:
pass
break
except Exception as e:
make_log("Bootstrap", f"Seed failed {seed}: {e}", level='warning')
if _now() > deadline:
break
if BOOTSTRAP_REQUIRED and not ok:
make_log("Bootstrap", "Failed to reach any bootstrap seeds; exiting", level='error')
# Hard exit; Sanic won't stop otherwise
import os
os._exit(2)
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
import base64
import hashlib
import json
import secrets
import time
from typing import Dict, Tuple
from base58 import b58decode, b58encode
from app.core.network.guard import check_timestamp_fresh, check_and_remember_nonce
def _body_sha256(body: bytes) -> str:
h = hashlib.sha256()
h.update(body or b"")
return h.hexdigest()
def canonical_string(method: str, path: str, body: bytes, ts: int, nonce: str, node_id: str) -> bytes:
parts = [
method.upper(),
path,
_body_sha256(body),
str(int(ts)),
str(nonce),
node_id,
]
return ("\n".join(parts)).encode()
def sign_headers(method: str, path: str, body: bytes, sk_bytes: bytes, pk_b58: str) -> Dict[str, str]:
import nacl.signing
ts = int(time.time())
nonce = secrets.token_hex(16)
msg = canonical_string(method, path, body, ts, nonce, pk_b58)
sig = nacl.signing.SigningKey(sk_bytes).sign(msg).signature
return {
"X-Node-Id": pk_b58,
"X-Node-Ts": str(ts),
"X-Node-Nonce": nonce,
"X-Node-Sig": b58encode(sig).decode(),
}
def verify_request(request, memory) -> Tuple[bool, str, str]:
"""Verify NodeSig headers of an incoming Sanic request.
Returns (ok, node_id, error). ok==True if signature valid, timestamp fresh, nonce unused.
"""
try:
node_id = request.headers.get("X-Node-Id", "").strip()
ts = int(request.headers.get("X-Node-Ts", "0").strip() or 0)
nonce = request.headers.get("X-Node-Nonce", "").strip()
sig_b58 = request.headers.get("X-Node-Sig", "").strip()
if not node_id or not ts or not nonce or not sig_b58:
return False, "", "MISSING_HEADERS"
if not check_timestamp_fresh(ts):
return False, node_id, "STALE_TS"
if not check_and_remember_nonce(memory, node_id, nonce):
return False, node_id, "NONCE_REPLAY"
import nacl.signing
vk = nacl.signing.VerifyKey(b58decode(node_id))
sig = b58decode(sig_b58)
msg = canonical_string(request.method, request.path, request.body or b"", ts, nonce, node_id)
vk.verify(msg, sig)
return True, node_id, ""
except Exception as e:
return False, "", f"BAD_SIGNATURE: {e}"
+26
View File
@@ -0,0 +1,26 @@
from typing import Tuple
def parse_semver(v: str) -> Tuple[int, int, int]:
try:
parts = v.split(".")
major = int(parts[0])
minor = int(parts[1]) if len(parts) > 1 else 0
patch = int(parts[2]) if len(parts) > 2 else 0
return major, minor, patch
except Exception:
return 0, 0, 0
def compatibility(peer: str, current: str) -> str:
"""Return one of: compatible, warning, blocked"""
pM, pm, pp = parse_semver(peer)
cM, cm, cp = parse_semver(current)
if pM != cM:
return "blocked"
# Same major
if pm == cm:
return "compatible"
# Different minor within same major => warning
return "warning"