relayers new code
This commit is contained in:
1 parent
846e32c5b1
commit
650059b0d3
37 files changed
+2678
-16
No files matched your search
@@ -0,0 +1,2 @@
|
||||
# Network package for MY nodes
|
||||
|
||||
@@ -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"))
|
||||
@@ -0,0 +1,6 @@
|
||||
CURRENT_PROTOCOL_VERSION = "3.0.0"
|
||||
|
||||
# Node roles/types
|
||||
NODE_TYPE_PUBLIC = "public"
|
||||
NODE_TYPE_PRIVATE = "private"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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}"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user