validator edits

This commit is contained in:
Dobromir Popov
2026-07-04 23:03:03 +02:00
parent 69b0e726b8
commit 9bd15de65b
6 changed files with 425 additions and 53 deletions

View File

@@ -4,3 +4,4 @@
- [User profile](user-profile.md) — who Dobromir is and how to work with him - [User profile](user-profile.md) — who Dobromir is and how to work with him
- [Project status](project-status.md) — 35/35 stories done; alpha hardening next - [Project status](project-status.md) — 35/35 stories done; alpha hardening next
- **Alpha hardening** — `.scratch/alpha-hardening/` (22 issues, ADRs 00160019, [README](../.scratch/alpha-hardening/README.md), [handoff](../.scratch/alpha-hardening/handoff.md)) - **Alpha hardening** — `.scratch/alpha-hardening/` (22 issues, ADRs 00160019, [README](../.scratch/alpha-hardening/README.md), [handoff](../.scratch/alpha-hardening/handoff.md))
- [Alpha hardening navigation](alpha-hardening-navigation.md) — locked fraud/auth decisions, Bucket-1 order, handoff pointers

View File

@@ -0,0 +1,75 @@
"""Unified tracker auth primitives (ADR-0017, alpha issues 01/02/20).
Two mechanisms live here:
- **Hive peer HMAC** — gossip mutation endpoints require a signature computed
as HMAC-SHA256(hive_secret, "<timestamp>." + raw_body). All trackers in a
hive share one secret (``--hive-secret`` / MESHNET_HIVE_SECRET). Timestamps
outside the skew window are rejected to blunt replay.
- **Validator service token** — a dedicated bearer credential distinct from
client API keys and admin sessions (``--validator-service-token`` /
MESHNET_VALIDATOR_SERVICE_TOKEN), resolved to the ``validator`` role.
Role resolution itself happens in the request handler (it needs the account
store); these are the shared, dependency-free pieces.
"""
from __future__ import annotations
import hmac
import hashlib
import time
HIVE_SIGNATURE_HEADER = "X-Meshnet-Hive-Signature"
HIVE_TIMESTAMP_HEADER = "X-Meshnet-Hive-Timestamp"
HIVE_MAX_CLOCK_SKEW_SECONDS = 300.0
def _hive_digest(secret: str, timestamp: str, body: bytes) -> str:
return hmac.new(
secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256
).hexdigest()
def sign_hive_request(secret: str, body: bytes, *, timestamp: float | None = None) -> dict[str, str]:
"""Headers a tracker attaches when pushing gossip to a hive peer."""
ts = f"{timestamp if timestamp is not None else time.time():.3f}"
return {
HIVE_SIGNATURE_HEADER: _hive_digest(secret, ts, body),
HIVE_TIMESTAMP_HEADER: ts,
}
def verify_hive_request(
secret: str | None,
headers,
body: bytes,
*,
now: float | None = None,
) -> bool:
"""True only when the request carries a fresh, valid hive signature.
Fails closed: no configured secret means no gossip is accepted.
"""
if not secret:
return False
signature = headers.get(HIVE_SIGNATURE_HEADER)
timestamp = headers.get(HIVE_TIMESTAMP_HEADER)
if not signature or not timestamp:
return False
try:
ts_value = float(timestamp)
except ValueError:
return False
current = now if now is not None else time.time()
if abs(current - ts_value) > HIVE_MAX_CLOCK_SKEW_SECONDS:
return False
return hmac.compare_digest(signature, _hive_digest(secret, timestamp, body))
def is_validator_token(token: str | None, configured: str | None) -> bool:
"""Constant-time check of a presented bearer token against the configured
validator service token."""
if not token or not configured:
return False
return hmac.compare_digest(token, configured)

View File

@@ -102,6 +102,22 @@ def main() -> None:
default=0.01, default=0.01,
help="Never pay out less than this many USDT", help="Never pay out less than this many USDT",
) )
common.add_argument(
"--validator-service-token",
default=None,
help=(
"Service token the validator uses on POST /v1/billing/forfeit "
"(default: MESHNET_VALIDATOR_SERVICE_TOKEN env; ADR-0017)"
),
)
common.add_argument(
"--hive-secret",
default=None,
help=(
"Shared secret authenticating gossip between tracker peers "
"(default: MESHNET_HIVE_SECRET env; required for multi-tracker replication)"
),
)
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="meshnet-tracker", prog="meshnet-tracker",
@@ -139,6 +155,8 @@ def main() -> None:
settle_period=args.settle_period, settle_period=args.settle_period,
payout_threshold=args.payout_threshold, payout_threshold=args.payout_threshold,
payout_dust_floor=args.payout_dust_floor, payout_dust_floor=args.payout_dust_floor,
validator_service_token=args.validator_service_token,
hive_secret=args.hive_secret,
) )
port = server.start() port = server.start()
print(f"meshnet-tracker listening on http://{args.host}:{port}", flush=True) print(f"meshnet-tracker listening on http://{args.host}:{port}", flush=True)

View File

@@ -75,7 +75,10 @@ const short = (s, n=14) => { s = String(s); return s.length > n ? s.slice(0, 6)
async function fetchJson(path) { async function fetchJson(path) {
try { try {
const r = await fetch(path); const headers = {};
const token = localStorage.getItem("meshnet_session");
if (token) headers["Authorization"] = "Bearer " + token;
const r = await fetch(path, { headers });
if (!r.ok) return null; if (!r.ok) return null;
return await r.json(); return await r.json();
} catch { return null; } } catch { return null; }

View File

@@ -37,6 +37,7 @@ from importlib.resources import files
from typing import Any from typing import Any
from .accounts import DEFAULT_ACCOUNTS_DB_PATH, AccountStore from .accounts import DEFAULT_ACCOUNTS_DB_PATH, AccountStore
from .auth import is_validator_token, sign_hive_request, verify_hive_request
from .billing import DEFAULT_BILLING_DB_PATH, BillingLedger from .billing import DEFAULT_BILLING_DB_PATH, BillingLedger
from .gossip import NodeGossip from .gossip import NodeGossip
from .raft import RaftNode from .raft import RaftNode
@@ -1264,6 +1265,8 @@ class _TrackerHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
billing: "BillingLedger | None" = None, billing: "BillingLedger | None" = None,
accounts: "AccountStore | None" = None, accounts: "AccountStore | None" = None,
benchmark_results_path: str | None = None, benchmark_results_path: str | None = None,
validator_service_token: str | None = None,
hive_secret: str | None = None,
) -> None: ) -> None:
super().__init__(addr, handler) super().__init__(addr, handler)
self.registry = registry self.registry = registry
@@ -1282,6 +1285,8 @@ class _TrackerHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
os.getcwd(), "benchmark_results.json" os.getcwd(), "benchmark_results.json"
) )
self.benchmark_lock = threading.Lock() self.benchmark_lock = threading.Lock()
self.validator_service_token = validator_service_token
self.hive_secret = hive_secret
class _TrackerHandler(http.server.BaseHTTPRequestHandler): class _TrackerHandler(http.server.BaseHTTPRequestHandler):
@@ -1299,6 +1304,65 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
except BrokenPipeError: except BrokenPipeError:
pass pass
# ---- unified auth boundary (ADR-0017) ----
def _resolve_identity(self) -> tuple[str | None, dict | None]:
"""Resolve the caller to (role, account).
Roles: "validator" (service token), "admin"/"user" (session token).
Client API keys resolve to no privileged role — they authorize
inference and wallet binding only, never operator endpoints.
"""
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
token = _api_key_from_headers(self.headers)
if not token:
return None, None
if is_validator_token(token, server.validator_service_token):
return "validator", None
if server.accounts is not None:
account = server.accounts.session_account(token)
if account is not None:
return account.get("role", "user"), account
return None, None
def _require_role(self, *allowed: str) -> bool:
"""Gate a privileged handler; sends 401/403 and returns False on failure.
401 when no credential was presented; 403 when a credential was
presented but does not resolve to an allowed role — this covers
client API keys and garbage bearer strings on operator endpoints.
"""
role, _account = self._resolve_identity()
if role in allowed:
return True
if _api_key_from_headers(self.headers) is None:
self._send_json(401, {"error": "authentication required (admin session or service token)"})
else:
self._send_json(403, {"error": "this endpoint requires an admin session or service token"})
return False
def _read_hive_authenticated_body(self) -> dict | None:
"""Read + verify a hive gossip body (HMAC per ADR-0017 §3).
Fails closed: without a configured --hive-secret no gossip is
accepted. Sends the error response itself and returns None on failure.
"""
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(length) if length else b"{}"
if not verify_hive_request(server.hive_secret, self.headers, raw):
self._send_json(401, {"error": "valid hive signature required"})
return None
try:
body = json.loads(raw or b"{}")
except json.JSONDecodeError:
self._send_json(400, {"error": "invalid JSON body"})
return None
if not isinstance(body, dict):
self._send_json(400, {"error": "JSON body must be an object"})
return None
return body
def _read_json_body(self) -> dict | None: def _read_json_body(self) -> dict | None:
length = int(self.headers.get("Content-Length", 0)) length = int(self.headers.get("Content-Length", 0))
try: try:
@@ -2354,7 +2418,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
def _handle_stats_gossip(self): def _handle_stats_gossip(self):
server: _TrackerHTTPServer = self.server # type: ignore[assignment] server: _TrackerHTTPServer = self.server # type: ignore[assignment]
body = self._read_json_body() body = self._read_hive_authenticated_body()
if body is None: if body is None:
return return
tracker_url = body.get("tracker_url", "") tracker_url = body.get("tracker_url", "")
@@ -2365,6 +2429,8 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
def _handle_billing_summary(self): def _handle_billing_summary(self):
server: _TrackerHTTPServer = self.server # type: ignore[assignment] server: _TrackerHTTPServer = self.server # type: ignore[assignment]
if not self._require_role("admin"):
return
if server.billing is None: if server.billing is None:
self._send_json(404, {"error": "billing is not enabled on this tracker"}) self._send_json(404, {"error": "billing is not enabled on this tracker"})
return return
@@ -2390,6 +2456,8 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
def _handle_registry_wallets(self): def _handle_registry_wallets(self):
server: _TrackerHTTPServer = self.server # type: ignore[assignment] server: _TrackerHTTPServer = self.server # type: ignore[assignment]
if not self._require_role("admin"):
return
if server.contracts is None: if server.contracts is None:
self._send_json(200, {"wallets": {}}) self._send_json(200, {"wallets": {}})
return return
@@ -2406,6 +2474,8 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
def _handle_billing_settlements(self): def _handle_billing_settlements(self):
server: _TrackerHTTPServer = self.server # type: ignore[assignment] server: _TrackerHTTPServer = self.server # type: ignore[assignment]
if not self._require_role("admin"):
return
if server.billing is None: if server.billing is None:
self._send_json(404, {"error": "billing is not enabled on this tracker"}) self._send_json(404, {"error": "billing is not enabled on this tracker"})
return return
@@ -2413,7 +2483,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
def _handle_billing_gossip(self): def _handle_billing_gossip(self):
server: _TrackerHTTPServer = self.server # type: ignore[assignment] server: _TrackerHTTPServer = self.server # type: ignore[assignment]
body = self._read_json_body() body = self._read_hive_authenticated_body()
if body is None: if body is None:
return return
if server.billing is None: if server.billing is None:
@@ -2429,12 +2499,11 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
def _handle_billing_forfeit(self): def _handle_billing_forfeit(self):
"""Privileged: forfeit a node's pending balance + record a strike (US-034). """Privileged: forfeit a node's pending balance + record a strike (US-034).
Auth is a header-presence stub (non-empty Authorization), matching the ADR-0017 §4: validator service token or admin session only. Client
benchmark endpoints — real validator auth arrives with on-chain keys. API keys — valid or not — are rejected.
""" """
server: _TrackerHTTPServer = self.server # type: ignore[assignment] server: _TrackerHTTPServer = self.server # type: ignore[assignment]
if not self.headers.get("Authorization"): if not self._require_role("validator", "admin"):
self._send_json(401, {"error": "Authorization header required"})
return return
if server.billing is None: if server.billing is None:
self._send_json(404, {"error": "billing is not enabled on this tracker"}) self._send_json(404, {"error": "billing is not enabled on this tracker"})
@@ -2609,7 +2678,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
def _handle_accounts_gossip(self): def _handle_accounts_gossip(self):
server: _TrackerHTTPServer = self.server # type: ignore[assignment] server: _TrackerHTTPServer = self.server # type: ignore[assignment]
body = self._read_json_body() body = self._read_hive_authenticated_body()
if body is None: if body is None:
return return
if server.accounts is None: if server.accounts is None:
@@ -2655,10 +2724,9 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
is its total minus the (k-1)-node route's total. is its total minus the (k-1)-node route's total.
""" """
server: _TrackerHTTPServer = self.server # type: ignore[assignment] server: _TrackerHTTPServer = self.server # type: ignore[assignment]
auth = self.headers.get("Authorization") if not self._require_role("admin", "validator"):
if not auth:
self._send_json(401, {"error": "Authorization header required"})
return return
auth = self.headers.get("Authorization")
body = self._read_json_body() body = self._read_json_body()
if body is None: if body is None:
return return
@@ -2744,8 +2812,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
def _handle_benchmark_results(self): def _handle_benchmark_results(self):
server: _TrackerHTTPServer = self.server # type: ignore[assignment] server: _TrackerHTTPServer = self.server # type: ignore[assignment]
if not self.headers.get("Authorization"): if not self._require_role("admin", "validator"):
self._send_json(401, {"error": "Authorization header required"})
return return
results: list = [] results: list = []
with server.benchmark_lock: with server.benchmark_lock:
@@ -3198,6 +3265,8 @@ class TrackerServer:
payout_threshold: float = 5.0, payout_threshold: float = 5.0,
payout_dust_floor: float = 0.01, payout_dust_floor: float = 0.01,
settlement_check_interval: float | None = None, settlement_check_interval: float | None = None,
validator_service_token: str | None = None,
hive_secret: str | None = None,
) -> None: ) -> None:
self._host = host self._host = host
self._requested_port = port self._requested_port = port
@@ -3256,6 +3325,16 @@ class TrackerServer:
) )
self._settlement_stop = threading.Event() self._settlement_stop = threading.Event()
self._settlement_thread: threading.Thread | None = None self._settlement_thread: threading.Thread | None = None
self._validator_service_token = (
validator_service_token
if validator_service_token is not None
else os.environ.get("MESHNET_VALIDATOR_SERVICE_TOKEN") or None
)
self._hive_secret = (
hive_secret
if hive_secret is not None
else os.environ.get("MESHNET_HIVE_SECRET") or None
)
self.port: int | None = None self.port: int | None = None
def start(self) -> int: def start(self) -> int:
@@ -3282,6 +3361,8 @@ class TrackerServer:
billing=self._billing, billing=self._billing,
accounts=self._accounts, accounts=self._accounts,
benchmark_results_path=self._benchmark_results_path, benchmark_results_path=self._benchmark_results_path,
validator_service_token=self._validator_service_token,
hive_secret=self._hive_secret,
) )
self.port = self._server.server_address[1] self.port = self._server.server_address[1]
@@ -3450,6 +3531,23 @@ class TrackerServer:
_purge_expired_nodes_locked(server) _purge_expired_nodes_locked(server)
_rebalance_all_locked(server) _rebalance_all_locked(server)
def _push_to_peers(self, path: str, body: bytes) -> bool:
"""POST a hive-signed payload to every cluster peer; True if all succeeded."""
headers = {"Content-Type": "application/json"}
if self._hive_secret:
headers.update(sign_hive_request(self._hive_secret, body))
delivered_all = True
for peer in self._cluster_peers:
try:
req = urllib.request.Request(
f"{peer}{path}", data=body, headers=headers, method="POST",
)
with urllib.request.urlopen(req, timeout=2.0) as r:
r.read()
except Exception:
delivered_all = False
return delivered_all
def _stats_loop(self) -> None: def _stats_loop(self) -> None:
"""Periodically save stats/billing to DB and push local slices to cluster peers.""" """Periodically save stats/billing to DB and push local slices to cluster peers."""
while not self._stats_stop.wait(_StatsCollector.SAVE_INTERVAL): while not self._stats_stop.wait(_StatsCollector.SAVE_INTERVAL):
@@ -3459,62 +3557,31 @@ class TrackerServer:
self._billing.save_to_db() self._billing.save_to_db()
if self._accounts is not None: if self._accounts is not None:
self._accounts.save_to_db() self._accounts.save_to_db()
if self._cluster_peers and not self._hive_secret:
print(
"[tracker] WARNING: cluster peers configured without --hive-secret — "
"gossip pushes will be rejected (ADR-0017)",
flush=True,
)
if self._stats is not None and self._cluster_peers: if self._stats is not None and self._cluster_peers:
self_url = self._cluster_self_url or f"http://{self._host}:{self.port}" self_url = self._cluster_self_url or f"http://{self._host}:{self.port}"
local_rpms = self._stats.get_local_rpms() local_rpms = self._stats.get_local_rpms()
body = json.dumps({"tracker_url": self_url, "stats": local_rpms}).encode() body = json.dumps({"tracker_url": self_url, "stats": local_rpms}).encode()
for peer in self._cluster_peers: self._push_to_peers("/v1/stats/gossip", body)
try:
req = urllib.request.Request(
f"{peer}/v1/stats/gossip",
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=2.0) as r:
r.read()
except Exception:
pass
if self._billing is not None and self._cluster_peers: if self._billing is not None and self._cluster_peers:
events, cursor = self._billing.events_since(self._billing_gossip_cursor) events, cursor = self._billing.events_since(self._billing_gossip_cursor)
if events: if events:
delivered_all = True
body = json.dumps({"events": events}).encode() body = json.dumps({"events": events}).encode()
for peer in self._cluster_peers:
try:
req = urllib.request.Request(
f"{peer}/v1/billing/gossip",
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=2.0) as r:
r.read()
except Exception:
delivered_all = False
# Only advance past events every peer has seen; unreachable # Only advance past events every peer has seen; unreachable
# peers get the full backlog on the next tick (idempotent # peers get the full backlog on the next tick (idempotent
# via event-id dedupe on the receiving side). # via event-id dedupe on the receiving side).
if delivered_all: if self._push_to_peers("/v1/billing/gossip", body):
self._billing_gossip_cursor = cursor self._billing_gossip_cursor = cursor
if self._accounts is not None and self._cluster_peers: if self._accounts is not None and self._cluster_peers:
events, cursor = self._accounts.events_since(self._accounts_gossip_cursor) events, cursor = self._accounts.events_since(self._accounts_gossip_cursor)
if events: if events:
delivered_all = True
body = json.dumps({"events": events}).encode() body = json.dumps({"events": events}).encode()
for peer in self._cluster_peers: if self._push_to_peers("/v1/accounts/gossip", body):
try:
req = urllib.request.Request(
f"{peer}/v1/accounts/gossip",
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=2.0) as r:
r.read()
except Exception:
delivered_all = False
if delivered_all:
self._accounts_gossip_cursor = cursor self._accounts_gossip_cursor = cursor
def stop(self) -> None: def stop(self) -> None:

208
tests/test_auth_boundary.py Normal file
View File

@@ -0,0 +1,208 @@
"""Alpha issues 01/02/20 (ADR-0017): unified auth boundary.
Forfeit accepts only the validator service token or an admin session; client
API keys and garbage bearers are rejected. Financial reads require an admin
session. Hive gossip mutations require a fresh HMAC signature — unsigned
requests apply nothing.
"""
import json
import time
import urllib.error
import urllib.request
import pytest
from meshnet_tracker.accounts import AccountStore
from meshnet_tracker.auth import sign_hive_request, verify_hive_request
from meshnet_tracker.billing import BillingLedger
from meshnet_tracker.server import TrackerServer
MODEL = "stub-model"
SERVICE_TOKEN = "svc-validator-secret-token"
HIVE_SECRET = "hive-shared-secret"
def _request(url, *, method="GET", payload=None, token=None, extra_headers=None):
headers = {"Content-Type": "application/json", **(extra_headers or {})}
if token:
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(
url,
data=json.dumps(payload).encode() if payload is not None else None,
headers=headers,
method=method,
)
with urllib.request.urlopen(req) as r:
return json.loads(r.read())
def _status_of(callable_):
try:
callable_()
except urllib.error.HTTPError as exc:
return exc.code
return 200
@pytest.fixture
def secured_tracker():
accounts = AccountStore()
admin = accounts.register(email="admin@example.com", password="admin-pass-123")
user = accounts.register(email="user@example.com", password="user-pass-1234")
admin_session = accounts.create_session(admin["account_id"])
user_session = accounts.create_session(user["account_id"])
client_api_key = accounts.create_api_key(user["account_id"])
ledger = BillingLedger(starting_credit=1.0, default_price_per_1k=0.02)
ledger.charge_request("client", MODEL, 1000, [("wallet-x", 12)])
tracker = TrackerServer(
billing=ledger,
accounts=accounts,
validator_service_token=SERVICE_TOKEN,
hive_secret=HIVE_SECRET,
)
port = tracker.start()
yield {
"url": f"http://127.0.0.1:{port}",
"ledger": ledger,
"admin_session": admin_session,
"user_session": user_session,
"client_api_key": client_api_key,
}
tracker.stop()
# ---------------------------------------------------------------- forfeit
def test_forfeit_rejects_missing_garbage_and_api_key(secured_tracker):
url = f"{secured_tracker['url']}/v1/billing/forfeit"
payload = {"wallet": "wallet-x", "reason": "fraud"}
assert _status_of(lambda: _request(url, method="POST", payload=payload)) == 401
assert _status_of(lambda: _request(url, method="POST", payload=payload, token="garbage")) == 403
# a *valid* client API key must still be rejected on operator endpoints
assert _status_of(lambda: _request(
url, method="POST", payload=payload, token=secured_tracker["client_api_key"],
)) == 403
# nothing was forfeited by any of the rejected attempts
assert secured_tracker["ledger"].get_node_pending("wallet-x") > 0
def test_forfeit_accepts_service_token_and_admin_session(secured_tracker):
url = f"{secured_tracker['url']}/v1/billing/forfeit"
result = _request(
url, method="POST", payload={"wallet": "wallet-x"}, token=SERVICE_TOKEN,
)
assert result["forfeited"] == pytest.approx(0.02 * 0.90)
assert secured_tracker["ledger"].get_node_pending("wallet-x") == pytest.approx(0.0)
# admin session is the operator override (idempotent second call: 0 pending)
result = _request(
url, method="POST", payload={"wallet": "wallet-x"},
token=secured_tracker["admin_session"],
)
assert result["forfeited"] == pytest.approx(0.0)
# ------------------------------------------------------- financial reads
@pytest.mark.parametrize("path", [
"/v1/billing/summary",
"/v1/billing/settlements",
"/v1/registry/wallets",
])
def test_financial_reads_require_admin_session(secured_tracker, path):
url = f"{secured_tracker['url']}{path}"
assert _status_of(lambda: _request(url)) == 401
assert _status_of(lambda: _request(url, token=secured_tracker["user_session"])) == 403
assert _status_of(lambda: _request(url, token=secured_tracker["client_api_key"])) == 403
assert _status_of(lambda: _request(url, token=secured_tracker["admin_session"])) == 200
def test_benchmark_endpoints_require_admin_or_service(secured_tracker):
url = f"{secured_tracker['url']}/v1/benchmark/results"
assert _status_of(lambda: _request(url)) == 401
assert _status_of(lambda: _request(url, token=secured_tracker["user_session"])) == 403
assert _status_of(lambda: _request(url, token=SERVICE_TOKEN)) == 200
assert _status_of(lambda: _request(url, token=secured_tracker["admin_session"])) == 200
def test_dashboard_stays_public(secured_tracker):
assert _status_of(lambda: urllib.request.urlopen(
f"{secured_tracker['url']}/dashboard"
)) == 200
# ---------------------------------------------------------------- gossip
def _gossip_events():
peer = BillingLedger(starting_credit=0.0)
peer.credit_client("injected-client", 999.0)
events, _ = peer.events_since(0)
return events
def test_unsigned_gossip_is_rejected_and_applies_nothing(secured_tracker):
url = f"{secured_tracker['url']}/v1/billing/gossip"
payload = {"events": _gossip_events()}
assert _status_of(lambda: _request(url, method="POST", payload=payload)) == 401
# a bearer token is not a hive signature either
assert _status_of(lambda: _request(
url, method="POST", payload=payload, token=secured_tracker["admin_session"],
)) == 401
assert secured_tracker["ledger"].get_client_balance("injected-client") == 0.0
def test_signed_gossip_applies_and_wrong_secret_rejected(secured_tracker):
url = f"{secured_tracker['url']}/v1/billing/gossip"
body = json.dumps({"events": _gossip_events()}).encode()
bad = sign_hive_request("not-the-hive-secret", body)
assert _status_of(lambda: _request(
url, method="POST", payload=json.loads(body), extra_headers=bad,
)) == 401
good = sign_hive_request(HIVE_SECRET, body)
req = urllib.request.Request(
url, data=body,
headers={"Content-Type": "application/json", **good},
method="POST",
)
with urllib.request.urlopen(req) as r:
assert json.loads(r.read())["applied"] == 1
assert secured_tracker["ledger"].get_client_balance("injected-client") == pytest.approx(999.0)
def test_stale_signature_rejected():
body = b'{"events": []}'
stale = sign_hive_request(HIVE_SECRET, body, timestamp=time.time() - 3600)
assert not verify_hive_request(HIVE_SECRET, stale, body)
fresh = sign_hive_request(HIVE_SECRET, body)
assert verify_hive_request(HIVE_SECRET, fresh, body)
# signature must cover the body
assert not verify_hive_request(HIVE_SECRET, fresh, b'{"events": [1]}')
# fail closed without a configured secret
assert not verify_hive_request(None, fresh, body)
def test_accounts_and_stats_gossip_also_gated(secured_tracker):
for path in ("/v1/accounts/gossip", "/v1/stats/gossip"):
url = f"{secured_tracker['url']}{path}"
assert _status_of(lambda: _request(url, method="POST", payload={})) == 401
def test_push_to_peers_signs_so_peers_accept(secured_tracker):
"""Outgoing gossip from a tracker with the shared secret lands on a peer."""
sender = TrackerServer(
billing=BillingLedger(starting_credit=0.0),
cluster_peers=[secured_tracker["url"]],
hive_secret=HIVE_SECRET,
)
body = json.dumps({"events": _gossip_events()}).encode()
assert sender._push_to_peers("/v1/billing/gossip", body) is True
assert secured_tracker["ledger"].get_client_balance("injected-client") == pytest.approx(999.0)