feat(tracker): unified auth boundary — gossip HMAC + validator token + admin-gated reads (alpha 01/02/20, ADR-0017)
Wire server.py handlers to the auth helper: forfeit requires validator service token or admin session (client API keys rejected); billing summary/ settlements/registry-wallets and benchmark endpoints require admin/service; the three gossip mutation endpoints require a fresh hive HMAC signature and outgoing gossip pushes are signed. Dashboard sends its session token on panel fetches. Existing tests updated for the new gates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
Status: ready-for-agent
|
Status: done
|
||||||
|
|
||||||
# 01 — C1: Authenticate hive gossip endpoints
|
# 01 — C1: Authenticate hive gossip endpoints
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
Status: ready-for-agent
|
Status: done
|
||||||
|
|
||||||
# 02 — A2: Unified auth boundary for privileged and financial reads
|
# 02 — A2: Unified auth boundary for privileged and financial reads
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
Status: ready-for-agent
|
Status: done
|
||||||
|
|
||||||
# 20 — Validator service token for `/v1/billing/forfeit`
|
# 20 — Validator service token for `/v1/billing/forfeit`
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,12 @@ import urllib.request
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from meshnet_tracker.accounts import AccountStore
|
from meshnet_tracker.accounts import AccountStore
|
||||||
|
from meshnet_tracker.auth import sign_hive_request
|
||||||
from meshnet_tracker.billing import BillingLedger
|
from meshnet_tracker.billing import BillingLedger
|
||||||
from meshnet_tracker.server import TrackerServer
|
from meshnet_tracker.server import TrackerServer
|
||||||
|
|
||||||
|
HIVE_SECRET = "test-hive-secret"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- unit tests
|
# ---------------------------------------------------------------- unit tests
|
||||||
|
|
||||||
@@ -123,7 +126,7 @@ def _call(url, method="GET", body=None, token=None):
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def account_tracker():
|
def account_tracker():
|
||||||
ledger = BillingLedger(starting_credit=0.5, default_price_per_1k=0.02)
|
ledger = BillingLedger(starting_credit=0.5, default_price_per_1k=0.02)
|
||||||
tracker = TrackerServer(billing=ledger, accounts=AccountStore())
|
tracker = TrackerServer(billing=ledger, accounts=AccountStore(), hive_secret=HIVE_SECRET)
|
||||||
port = tracker.start()
|
port = tracker.start()
|
||||||
yield f"http://127.0.0.1:{port}", ledger
|
yield f"http://127.0.0.1:{port}", ledger
|
||||||
tracker.stop()
|
tracker.stop()
|
||||||
@@ -202,7 +205,14 @@ def test_accounts_gossip_endpoint_applies_events(account_tracker):
|
|||||||
peer = AccountStore()
|
peer = AccountStore()
|
||||||
peer.register(email="remote@example.com", password="secret-123")
|
peer.register(email="remote@example.com", password="secret-123")
|
||||||
events, _ = peer.events_since(0)
|
events, _ = peer.events_since(0)
|
||||||
result = _call(f"{url}/v1/accounts/gossip", "POST", {"events": events})
|
body = json.dumps({"events": events}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{url}/v1/accounts/gossip", data=body,
|
||||||
|
headers={"Content-Type": "application/json", **sign_hive_request(HIVE_SECRET, body)},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req) as r:
|
||||||
|
result = json.loads(r.read())
|
||||||
assert result["applied"] == len(events)
|
assert result["applied"] == len(events)
|
||||||
login = _call(f"{url}/v1/auth/login", "POST",
|
login = _call(f"{url}/v1/auth/login", "POST",
|
||||||
{"identifier": "remote@example.com", "password": "secret-123"})
|
{"identifier": "remote@example.com", "password": "secret-123"})
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ import urllib.request
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from meshnet_tracker.auth import sign_hive_request
|
||||||
from meshnet_tracker.billing import BillingLedger
|
from meshnet_tracker.billing import BillingLedger
|
||||||
from meshnet_tracker.server import TrackerServer
|
from meshnet_tracker.server import TrackerServer
|
||||||
|
|
||||||
MODEL = "openai-community/gpt2"
|
MODEL = "openai-community/gpt2"
|
||||||
|
HIVE_SECRET = "test-hive-secret"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- unit tests
|
# ---------------------------------------------------------------- unit tests
|
||||||
@@ -110,12 +112,14 @@ def test_tracker_enables_billing_with_default_db_when_requested(tmp_path, monkey
|
|||||||
tracker = TrackerServer(enable_billing=True)
|
tracker = TrackerServer(enable_billing=True)
|
||||||
port = tracker.start()
|
port = tracker.start()
|
||||||
try:
|
try:
|
||||||
summary = json.loads(
|
# /v1/billing/summary is admin-gated now; just confirm the server is up.
|
||||||
urllib.request.urlopen(f"http://127.0.0.1:{port}/v1/billing/summary").read()
|
health = json.loads(
|
||||||
|
urllib.request.urlopen(f"http://127.0.0.1:{port}/v1/health").read()
|
||||||
)
|
)
|
||||||
assert "protocol_cut" in summary
|
assert health["status"] == "ok"
|
||||||
finally:
|
finally:
|
||||||
tracker.stop()
|
tracker.stop()
|
||||||
|
# enabling billing creates the ledger DB in the working directory
|
||||||
assert (tmp_path / DEFAULT_BILLING_DB_PATH).exists()
|
assert (tmp_path / DEFAULT_BILLING_DB_PATH).exists()
|
||||||
|
|
||||||
|
|
||||||
@@ -209,6 +213,7 @@ def billed_tracker():
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
billing=ledger,
|
billing=ledger,
|
||||||
|
hive_secret=HIVE_SECRET,
|
||||||
)
|
)
|
||||||
port = tracker.start()
|
port = tracker.start()
|
||||||
tracker_url = f"http://127.0.0.1:{port}"
|
tracker_url = f"http://127.0.0.1:{port}"
|
||||||
@@ -272,9 +277,9 @@ def test_proxy_chat_bills_client_and_credits_node(billed_tracker):
|
|||||||
assert ledger.get_client_balance("client-1") == pytest.approx(0.03 - 0.02)
|
assert ledger.get_client_balance("client-1") == pytest.approx(0.03 - 0.02)
|
||||||
assert ledger.get_node_pending("wallet-head") == pytest.approx(0.02 * 0.90)
|
assert ledger.get_node_pending("wallet-head") == pytest.approx(0.02 * 0.90)
|
||||||
|
|
||||||
summary = json.loads(
|
# /v1/billing/summary is admin-gated now (ADR-0017); auth is covered in
|
||||||
urllib.request.urlopen(f"{tracker_url}/v1/billing/summary").read()
|
# test_auth_boundary.py, so verify the numbers via the ledger snapshot.
|
||||||
)
|
summary = ledger.snapshot()
|
||||||
assert summary["node_pending"]["wallet-head"] == pytest.approx(0.02 * 0.90)
|
assert summary["node_pending"]["wallet-head"] == pytest.approx(0.02 * 0.90)
|
||||||
assert summary["protocol_cut"] == pytest.approx(0.02 * 0.10)
|
assert summary["protocol_cut"] == pytest.approx(0.02 * 0.10)
|
||||||
|
|
||||||
@@ -305,7 +310,7 @@ def test_billing_gossip_endpoint_applies_events(billed_tracker):
|
|||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
f"{tracker_url}/v1/billing/gossip",
|
f"{tracker_url}/v1/billing/gossip",
|
||||||
data=body,
|
data=body,
|
||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json", **sign_hive_request(HIVE_SECRET, body)},
|
||||||
method="POST",
|
method="POST",
|
||||||
)
|
)
|
||||||
with urllib.request.urlopen(req) as r:
|
with urllib.request.urlopen(req) as r:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import json
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
from meshnet_contracts import LocalSolanaContracts
|
from meshnet_contracts import LocalSolanaContracts
|
||||||
|
from meshnet_tracker.accounts import AccountStore
|
||||||
from meshnet_tracker.billing import BillingLedger
|
from meshnet_tracker.billing import BillingLedger
|
||||||
from meshnet_tracker.server import TrackerServer
|
from meshnet_tracker.server import TrackerServer
|
||||||
|
|
||||||
@@ -48,12 +49,17 @@ def test_registry_wallets_endpoint():
|
|||||||
contracts = LocalSolanaContracts()
|
contracts = LocalSolanaContracts()
|
||||||
contracts.registry.submit_stake("wallet-a", 100)
|
contracts.registry.submit_stake("wallet-a", 100)
|
||||||
contracts.registry.record_strike("wallet-a")
|
contracts.registry.record_strike("wallet-a")
|
||||||
tracker = TrackerServer(contracts=contracts)
|
accounts = AccountStore()
|
||||||
|
admin = accounts.register(email="admin@example.com", password="admin-pass-123")
|
||||||
|
session = accounts.create_session(admin["account_id"])
|
||||||
|
tracker = TrackerServer(contracts=contracts, accounts=accounts)
|
||||||
port = tracker.start()
|
port = tracker.start()
|
||||||
try:
|
try:
|
||||||
data = json.loads(urllib.request.urlopen(
|
req = urllib.request.Request(
|
||||||
f"http://127.0.0.1:{port}/v1/registry/wallets"
|
f"http://127.0.0.1:{port}/v1/registry/wallets",
|
||||||
).read())
|
headers={"Authorization": f"Bearer {session}"},
|
||||||
|
)
|
||||||
|
data = json.loads(urllib.request.urlopen(req).read())
|
||||||
assert data["wallets"]["wallet-a"]["strike_count"] == 1
|
assert data["wallets"]["wallet-a"]["strike_count"] == 1
|
||||||
assert data["wallets"]["wallet-a"]["banned"] is False
|
assert data["wallets"]["wallet-a"]["banned"] is False
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -157,7 +157,9 @@ def test_forfeit_endpoint_requires_auth_and_forfeits():
|
|||||||
ledger.charge_request("client", MODEL, 1000, [("wallet-x", 12)])
|
ledger.charge_request("client", MODEL, 1000, [("wallet-x", 12)])
|
||||||
pending = ledger.get_node_pending("wallet-x")
|
pending = ledger.get_node_pending("wallet-x")
|
||||||
|
|
||||||
tracker = TrackerServer(contracts=contracts, billing=ledger)
|
tracker = TrackerServer(
|
||||||
|
contracts=contracts, billing=ledger, validator_service_token="test-svc-token",
|
||||||
|
)
|
||||||
port = tracker.start()
|
port = tracker.start()
|
||||||
url = f"http://127.0.0.1:{port}/v1/billing/forfeit"
|
url = f"http://127.0.0.1:{port}/v1/billing/forfeit"
|
||||||
body = json.dumps({"wallet": "wallet-x", "reason": "fraud"}).encode()
|
body = json.dumps({"wallet": "wallet-x", "reason": "fraud"}).encode()
|
||||||
@@ -171,7 +173,7 @@ def test_forfeit_endpoint_requires_auth_and_forfeits():
|
|||||||
|
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
url, data=body,
|
url, data=body,
|
||||||
headers={"Content-Type": "application/json", "Authorization": "Bearer validator"},
|
headers={"Content-Type": "application/json", "Authorization": "Bearer test-svc-token"},
|
||||||
method="POST",
|
method="POST",
|
||||||
)
|
)
|
||||||
with urllib.request.urlopen(req) as r:
|
with urllib.request.urlopen(req) as r:
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ def benchmark_setup(tmp_path):
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
benchmark_results_path=results_path,
|
benchmark_results_path=results_path,
|
||||||
|
validator_service_token="bench",
|
||||||
)
|
)
|
||||||
port = tracker.start()
|
port = tracker.start()
|
||||||
tracker_url = f"http://127.0.0.1:{port}"
|
tracker_url = f"http://127.0.0.1:{port}"
|
||||||
|
|||||||
@@ -73,9 +73,9 @@ def test_threshold_triggers_payout_and_zeroes_pending():
|
|||||||
assert treasury.batches[0] == [("wallet-a", pytest.approx(0.018))]
|
assert treasury.batches[0] == [("wallet-a", pytest.approx(0.018))]
|
||||||
assert ledger.get_node_pending("wallet-a") == pytest.approx(0.0)
|
assert ledger.get_node_pending("wallet-a") == pytest.approx(0.0)
|
||||||
|
|
||||||
history = json.loads(urllib.request.urlopen(
|
# /v1/billing/settlements is admin-gated now (ADR-0017, covered in
|
||||||
f"http://127.0.0.1:{port}/v1/billing/settlements"
|
# test_auth_boundary.py); verify content via the ledger directly.
|
||||||
).read())["settlements"]
|
history = ledger.settlement_history()
|
||||||
assert len(history) == 1
|
assert len(history) == 1
|
||||||
assert history[0]["signature"] == "fake-tx-1"
|
assert history[0]["signature"] == "fake-tx-1"
|
||||||
assert history[0]["payouts"] == [
|
assert history[0]["payouts"] == [
|
||||||
|
|||||||
@@ -12,8 +12,11 @@ import pytest
|
|||||||
from meshnet_gateway.server import GatewayServer, _banned_route_wallet
|
from meshnet_gateway.server import GatewayServer, _banned_route_wallet
|
||||||
from meshnet_node.server import StubNodeServer
|
from meshnet_node.server import StubNodeServer
|
||||||
from meshnet_contracts import LocalSolanaContracts
|
from meshnet_contracts import LocalSolanaContracts
|
||||||
|
from meshnet_tracker.auth import sign_hive_request
|
||||||
from meshnet_tracker.server import TrackerServer, _NodeEntry, _registration_ban_error
|
from meshnet_tracker.server import TrackerServer, _NodeEntry, _registration_ban_error
|
||||||
|
|
||||||
|
_TEST_HIVE_SECRET = "test-hive-secret"
|
||||||
|
|
||||||
|
|
||||||
def _post_json(url: str, payload: dict) -> dict:
|
def _post_json(url: str, payload: dict) -> dict:
|
||||||
data = json.dumps(payload).encode()
|
data = json.dumps(payload).encode()
|
||||||
@@ -1558,7 +1561,7 @@ def test_stats_sqlite_persistence_survives_restart(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
def test_stats_gossip_endpoint_merges_peer_slice():
|
def test_stats_gossip_endpoint_merges_peer_slice():
|
||||||
tracker = TrackerServer()
|
tracker = TrackerServer(hive_secret=_TEST_HIVE_SECRET)
|
||||||
port = tracker.start()
|
port = tracker.start()
|
||||||
try:
|
try:
|
||||||
peer_payload = {
|
peer_payload = {
|
||||||
@@ -1571,7 +1574,14 @@ def test_stats_gossip_endpoint_merges_peer_slice():
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
_post_json(f"http://127.0.0.1:{port}/v1/stats/gossip", peer_payload)
|
body = json.dumps(peer_payload).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"http://127.0.0.1:{port}/v1/stats/gossip", data=body,
|
||||||
|
headers={"Content-Type": "application/json", **sign_hive_request(_TEST_HIVE_SECRET, body)},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req) as r:
|
||||||
|
r.read()
|
||||||
|
|
||||||
combined = _get_json(f"http://127.0.0.1:{port}/v1/stats")
|
combined = _get_json(f"http://127.0.0.1:{port}/v1/stats")
|
||||||
assert "remote-model" in combined["models"]
|
assert "remote-model" in combined["models"]
|
||||||
|
|||||||
Reference in New Issue
Block a user