Add community node socket: devices, tickets, consent gate, kill switch
Phase 3 of plan.md. Device registration with one-time secrets, scoped single-use socket tickets, the lock-owner WS /nodes/ws with handshake and heartbeat eviction, a single batch ConsentGate, and NodesService which disconnects nodes within one tick when consent, device or the nodes_enabled kill switch changes. A reconcile of 2000 sessions measured about 110 ms after replacing per-session queries with batches. Devii may withdraw but never grant bandwidth_sharing. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from devplacepy.database import get_setting, get_table, set_consent, set_setting
|
||||
from devplacepy.database.nodes import save_node_settings
|
||||
from devplacepy.main import app
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy.utils import register_account
|
||||
|
||||
_counter = [0]
|
||||
|
||||
|
||||
def unique(prefix: str) -> str:
|
||||
_counter[0] += 1
|
||||
return f"{prefix}{int(time.time() * 1000)}{_counter[0]}"
|
||||
|
||||
|
||||
def make_member(*, consent=True, enabled=True, age_band="adult", role="Member") -> dict:
|
||||
name = unique("nodemb")
|
||||
uid, _, _ = register_account(
|
||||
name, f"{name}@t.dev", "secret123", age_band=age_band, accepted_terms=True
|
||||
)
|
||||
if role != "Member":
|
||||
get_table("users").update({"uid": uid, "role": role}, ["uid"])
|
||||
if consent:
|
||||
set_consent("user", uid, "bandwidth_sharing", True)
|
||||
if enabled:
|
||||
save_node_settings(uid, {"enabled": True})
|
||||
row = get_table("users").find_one(uid=uid)
|
||||
return {"uid": uid, "username": name, "headers": {"X-API-KEY": row["api_key"]}}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
previous = get_setting("nodes_enabled", "0")
|
||||
set_setting("nodes_enabled", "1")
|
||||
with TestClient(app) as test_client:
|
||||
service_manager.set_lock_owner(True)
|
||||
yield test_client
|
||||
service_manager.set_lock_owner(False)
|
||||
set_setting("nodes_enabled", previous)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def member(client):
|
||||
return make_member()
|
||||
@@ -0,0 +1,105 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.database import get_setting, set_setting
|
||||
from devplacepy.database.nodes import LIMITS, find_device_by_secret
|
||||
from tests.api.nodes.conftest import make_member
|
||||
|
||||
|
||||
def create(client, who, **body):
|
||||
payload = {"label": "Home laptop", "kind": "native", **body}
|
||||
return client.post("/nodes/devices", json=payload, headers=who["headers"])
|
||||
|
||||
|
||||
def test_guests_get_401(client):
|
||||
assert client.get("/nodes/devices").status_code == 401
|
||||
assert client.post("/nodes/devices", json={"label": "x"}).status_code == 401
|
||||
assert client.post("/nodes/devices/x/rotate").status_code == 401
|
||||
assert client.post("/nodes/devices/x/delete").status_code == 401
|
||||
|
||||
|
||||
def test_registration_returns_the_secret_once_and_never_caches_it(client, member):
|
||||
response = create(client, member)
|
||||
assert response.status_code == 201
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
body = response.json()
|
||||
assert body["secret"].startswith("dpn_")
|
||||
assert body["device"]["label"] == "Home laptop"
|
||||
assert "secret_hash" not in body["device"] and "ip_hash" not in body["device"]
|
||||
assert find_device_by_secret(body["secret"])["uid"] == body["device"]["uid"]
|
||||
listing = client.get("/nodes/devices", headers=member["headers"]).json()
|
||||
assert [item["uid"] for item in listing["devices"]] == [body["device"]["uid"]]
|
||||
assert listing["max_devices"] == LIMITS.max_devices_per_user
|
||||
assert "secret" not in str(listing)
|
||||
|
||||
|
||||
def test_registration_needs_consent_and_enablement(client):
|
||||
no_consent = make_member(consent=False)
|
||||
refused = create(client, no_consent)
|
||||
assert refused.status_code == 403
|
||||
assert refused.json()["error"]["reason"] == "no_consent"
|
||||
not_enabled = make_member(enabled=False)
|
||||
assert create(client, not_enabled).json()["error"]["reason"] == "not_enabled"
|
||||
minor = make_member(age_band="16_17")
|
||||
assert create(client, minor).json()["error"]["reason"] == "not_adult"
|
||||
|
||||
|
||||
def test_kill_switch_blocks_registration(client, member):
|
||||
previous = get_setting("nodes_enabled", "0")
|
||||
set_setting("nodes_enabled", "0")
|
||||
try:
|
||||
response = create(client, member)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["error"]["reason"] == "kill_switch"
|
||||
finally:
|
||||
set_setting("nodes_enabled", previous)
|
||||
|
||||
|
||||
def test_device_cap_is_a_409(client, member):
|
||||
for _ in range(LIMITS.max_devices_per_user):
|
||||
assert create(client, member).status_code == 201
|
||||
assert create(client, member).status_code == 409
|
||||
|
||||
|
||||
def test_unknown_kind_is_a_validation_error(client, member):
|
||||
assert create(client, member, kind="browser").status_code == 422
|
||||
|
||||
|
||||
def test_rotate_replaces_the_secret_and_is_owner_only(client, member):
|
||||
created = create(client, member).json()
|
||||
uid, old = created["device"]["uid"], created["secret"]
|
||||
other = make_member()
|
||||
assert (
|
||||
client.post(f"/nodes/devices/{uid}/rotate", headers=other["headers"]).status_code
|
||||
== 404
|
||||
)
|
||||
rotated = client.post(f"/nodes/devices/{uid}/rotate", headers=member["headers"])
|
||||
assert rotated.status_code == 200
|
||||
new = rotated.json()["secret"]
|
||||
assert new != old
|
||||
assert find_device_by_secret(old) is None
|
||||
assert find_device_by_secret(new)["uid"] == uid
|
||||
|
||||
|
||||
def test_delete_is_owner_or_admin(client, member):
|
||||
uid = create(client, member).json()["device"]["uid"]
|
||||
other = make_member()
|
||||
assert (
|
||||
client.post(f"/nodes/devices/{uid}/delete", headers=other["headers"]).status_code
|
||||
== 403
|
||||
)
|
||||
admin = make_member(role="Admin")
|
||||
assert (
|
||||
client.post(f"/nodes/devices/{uid}/delete", headers=admin["headers"]).status_code
|
||||
== 200
|
||||
)
|
||||
assert client.get("/nodes/devices", headers=member["headers"]).json()["devices"] == []
|
||||
assert (
|
||||
client.post(f"/nodes/devices/{uid}/delete", headers=member["headers"]).status_code
|
||||
== 404
|
||||
)
|
||||
|
||||
|
||||
def test_owner_can_delete_their_own_device(client, member):
|
||||
uid = create(client, member).json()["device"]["uid"]
|
||||
response = client.post(f"/nodes/devices/{uid}/delete", headers=member["headers"])
|
||||
assert response.status_code == 200 and response.json() == {"ok": True}
|
||||
@@ -0,0 +1,67 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.database import set_consent
|
||||
from devplacepy.database.nodes import LIMITS, suspend_device
|
||||
from devplacepy.services.messaging.tickets import redeem_ticket, redeem_ticket_row
|
||||
from tests.api.nodes.devices import create
|
||||
|
||||
|
||||
def bearer(secret):
|
||||
return {"Authorization": f"Bearer {secret}"}
|
||||
|
||||
|
||||
def registered(client, member):
|
||||
body = create(client, member).json()
|
||||
return body["device"]["uid"], body["secret"]
|
||||
|
||||
|
||||
def test_no_credentials_or_a_wrong_secret_is_401(client):
|
||||
assert client.post("/nodes/ticket").status_code == 401
|
||||
assert client.post("/nodes/ticket", headers=bearer("nope")).status_code == 401
|
||||
assert client.post("/nodes/ticket", headers=bearer("dpn_unknown")).status_code == 401
|
||||
|
||||
|
||||
def test_a_session_api_key_is_not_a_device_secret(client, member):
|
||||
assert client.post("/nodes/ticket", headers=member["headers"]).status_code == 401
|
||||
|
||||
|
||||
def test_a_valid_secret_gets_a_short_single_use_ticket(client, member):
|
||||
device_uid, secret = registered(client, member)
|
||||
response = client.post("/nodes/ticket", headers=bearer(secret))
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
body = response.json()
|
||||
assert body["expires_in"] == LIMITS.ticket_ttl_seconds
|
||||
assert body["ws_path"] == "/nodes/ws"
|
||||
row = redeem_ticket_row(body["ticket"], "nodes")
|
||||
assert row["device_uid"] == device_uid and row["user_uid"] == member["uid"]
|
||||
assert redeem_ticket_row(body["ticket"], "nodes") is None
|
||||
|
||||
|
||||
def test_a_node_ticket_cannot_open_a_chat_socket(client, member):
|
||||
_, secret = registered(client, member)
|
||||
ticket = client.post("/nodes/ticket", headers=bearer(secret)).json()["ticket"]
|
||||
assert redeem_ticket(ticket) is None
|
||||
assert redeem_ticket_row(ticket, "nodes") is not None
|
||||
|
||||
|
||||
def test_withdrawing_consent_stops_new_tickets(client, member):
|
||||
_, secret = registered(client, member)
|
||||
set_consent("user", member["uid"], "bandwidth_sharing", False)
|
||||
response = client.post("/nodes/ticket", headers=bearer(secret))
|
||||
assert response.status_code == 403
|
||||
assert response.json()["error"]["reason"] == "no_consent"
|
||||
|
||||
|
||||
def test_a_suspended_device_gets_no_ticket(client, member):
|
||||
device_uid, secret = registered(client, member)
|
||||
suspend_device(device_uid, "test", 30)
|
||||
response = client.post("/nodes/ticket", headers=bearer(secret))
|
||||
assert response.status_code == 403
|
||||
assert response.json()["error"]["reason"] == "suspended"
|
||||
|
||||
|
||||
def test_a_deleted_device_secret_stops_working(client, member):
|
||||
device_uid, secret = registered(client, member)
|
||||
client.post(f"/nodes/devices/{device_uid}/delete", headers=member["headers"])
|
||||
assert client.post("/nodes/ticket", headers=bearer(secret)).status_code == 401
|
||||
@@ -0,0 +1,155 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy.services.nodes.spec import (
|
||||
CLOSE_CAPACITY,
|
||||
CLOSE_KICKED,
|
||||
CLOSE_POLICY,
|
||||
CLOSE_PROTOCOL,
|
||||
CLOSE_RETRY_OWNER,
|
||||
SPEC,
|
||||
)
|
||||
from tests.api.nodes.devices import create
|
||||
from tests.api.nodes.ticket import bearer
|
||||
|
||||
|
||||
def hello(**overrides):
|
||||
frame = {
|
||||
"type": "hello",
|
||||
"protocol": SPEC.version,
|
||||
"client_version": "0.1.0",
|
||||
"kind": "native",
|
||||
"platform": "linux/amd64",
|
||||
"modes": ["tunnel"],
|
||||
}
|
||||
frame.update(overrides)
|
||||
return json.dumps(frame)
|
||||
|
||||
|
||||
def ticket_for(client, member):
|
||||
body = create(client, member).json()
|
||||
ticket = client.post("/nodes/ticket", headers=bearer(body["secret"])).json()["ticket"]
|
||||
return body["device"]["uid"], body["secret"], ticket
|
||||
|
||||
|
||||
def closed_with(connection):
|
||||
with pytest.raises(WebSocketDisconnect) as info:
|
||||
connection.receive_text()
|
||||
return info.value.code
|
||||
|
||||
|
||||
def test_handshake_welcomes_with_the_published_numbers(client, member):
|
||||
device_uid, _, ticket = ticket_for(client, member)
|
||||
with client.websocket_connect(f"/nodes/ws?ticket={ticket}") as ws:
|
||||
ws.send_text(hello())
|
||||
welcome = json.loads(ws.receive_text())
|
||||
assert welcome["type"] == "welcome"
|
||||
assert welcome["device_uid"] == device_uid
|
||||
assert welcome["limits"] == SPEC.limits.describe()
|
||||
assert {item["suffix"] for item in welcome["destinations"]} == {
|
||||
item.suffix for item in SPEC.destinations
|
||||
}
|
||||
assert welcome["settings"]["enabled"] is True
|
||||
service = service_manager.get_service("nodes")
|
||||
assert service.registry.get(device_uid) is not None
|
||||
ws.send_text(json.dumps({"type": "pong"}))
|
||||
ws.send_text(json.dumps({"type": "usage", "bytes_up": 5, "bytes_down": 7}))
|
||||
assert service.registry.get(device_uid) is None
|
||||
|
||||
|
||||
def test_a_ticket_works_once(client, member):
|
||||
_, _, ticket = ticket_for(client, member)
|
||||
with client.websocket_connect(f"/nodes/ws?ticket={ticket}") as ws:
|
||||
ws.send_text(hello())
|
||||
ws.receive_text()
|
||||
with client.websocket_connect(f"/nodes/ws?ticket={ticket}") as again:
|
||||
assert closed_with(again) == CLOSE_POLICY
|
||||
|
||||
|
||||
def test_no_ticket_and_a_chat_ticket_are_refused(client, member):
|
||||
with client.websocket_connect("/nodes/ws") as ws:
|
||||
assert closed_with(ws) == CLOSE_POLICY
|
||||
from devplacepy.services.messaging.tickets import issue_ticket
|
||||
|
||||
chat_ticket = issue_ticket(member["uid"])
|
||||
with client.websocket_connect(f"/nodes/ws?ticket={chat_ticket}") as ws:
|
||||
assert closed_with(ws) == CLOSE_POLICY
|
||||
|
||||
|
||||
def test_a_non_owner_worker_says_retry(client, member):
|
||||
_, _, ticket = ticket_for(client, member)
|
||||
service_manager.set_lock_owner(False)
|
||||
try:
|
||||
with client.websocket_connect(f"/nodes/ws?ticket={ticket}") as ws:
|
||||
assert closed_with(ws) == CLOSE_RETRY_OWNER
|
||||
finally:
|
||||
service_manager.set_lock_owner(True)
|
||||
|
||||
|
||||
def test_protocol_violations_close_loudly(client, member):
|
||||
cases = [
|
||||
("not json", CLOSE_PROTOCOL),
|
||||
(json.dumps({"type": "pong"}), CLOSE_PROTOCOL),
|
||||
(hello(protocol=SPEC.version + 5), CLOSE_POLICY),
|
||||
(hello(kind="extension"), CLOSE_POLICY),
|
||||
(hello(modes=["bogus"]), CLOSE_PROTOCOL),
|
||||
(hello(extra="x"), CLOSE_PROTOCOL),
|
||||
]
|
||||
for frame, expected in cases:
|
||||
_, _, ticket = ticket_for(client, make_fresh(member))
|
||||
with client.websocket_connect(f"/nodes/ws?ticket={ticket}") as ws:
|
||||
ws.send_text(frame)
|
||||
assert closed_with(ws) == expected, frame
|
||||
|
||||
|
||||
def make_fresh(member):
|
||||
from tests.api.nodes.conftest import make_member
|
||||
|
||||
return make_member()
|
||||
|
||||
|
||||
def test_an_unsolicited_job_result_is_a_protocol_error(client, member):
|
||||
_, _, ticket = ticket_for(client, member)
|
||||
with client.websocket_connect(f"/nodes/ws?ticket={ticket}") as ws:
|
||||
ws.send_text(hello())
|
||||
ws.receive_text()
|
||||
ws.send_text(
|
||||
json.dumps(
|
||||
{"type": "job_result", "job_id": "j", "status": 200, "body_b64": ""}
|
||||
)
|
||||
)
|
||||
assert closed_with(ws) == CLOSE_PROTOCOL
|
||||
|
||||
|
||||
def test_a_second_connection_of_the_same_device_replaces_the_first(client, member):
|
||||
device_uid, secret, first_ticket = ticket_for(client, member)
|
||||
second_ticket = client.post("/nodes/ticket", headers=bearer(secret)).json()["ticket"]
|
||||
with client.websocket_connect(f"/nodes/ws?ticket={first_ticket}") as first:
|
||||
first.send_text(hello())
|
||||
first.receive_text()
|
||||
with client.websocket_connect(f"/nodes/ws?ticket={second_ticket}") as second:
|
||||
second.send_text(hello())
|
||||
assert json.loads(second.receive_text())["type"] == "welcome"
|
||||
kicked = json.loads(first.receive_text())
|
||||
assert kicked == {"type": "kick", "reason": "replaced"}
|
||||
assert closed_with(first) == CLOSE_KICKED
|
||||
|
||||
|
||||
def test_capacity_is_a_hard_ceiling(client, member):
|
||||
from devplacepy.database import get_setting, set_setting
|
||||
|
||||
service = service_manager.get_service("nodes")
|
||||
_, _, ticket = ticket_for(client, member)
|
||||
previous = get_setting("nodes_max_total", "")
|
||||
set_setting("nodes_max_total", "0")
|
||||
try:
|
||||
with client.websocket_connect(f"/nodes/ws?ticket={ticket}") as ws:
|
||||
assert closed_with(ws) == CLOSE_CAPACITY
|
||||
assert service.counters.snapshot()["rejected_capacity"] >= 1
|
||||
finally:
|
||||
set_setting("nodes_max_total", previous)
|
||||
@@ -208,3 +208,39 @@ def test_the_mature_preference_round_trips(app_server):
|
||||
)
|
||||
refresh_snapshot()
|
||||
assert get_table("users").find_one(uid=user["uid"])["mature_opt_in"] in (0, False)
|
||||
|
||||
|
||||
def test_an_agent_cannot_grant_a_member_only_consent_but_can_withdraw_it(app_server):
|
||||
session, name, user = _member()
|
||||
agent = {**JSON, "X-Devii-Agent": "1"}
|
||||
url = f"{BASE_URL}/profile/{name}/consent"
|
||||
refused = session.post(
|
||||
url, data={"kind": "bandwidth_sharing", "granted": "1"}, headers=agent
|
||||
)
|
||||
assert refused.status_code == 403
|
||||
refresh_snapshot()
|
||||
assert consent_granted("user", user["uid"], "bandwidth_sharing") is False
|
||||
|
||||
own = session.post(
|
||||
url, data={"kind": "bandwidth_sharing", "granted": "1"}, headers=JSON
|
||||
)
|
||||
assert own.status_code == 200
|
||||
refresh_snapshot()
|
||||
assert consent_granted("user", user["uid"], "bandwidth_sharing") is True
|
||||
|
||||
withdrawn = session.post(
|
||||
url, data={"kind": "bandwidth_sharing", "granted": "0"}, headers=agent
|
||||
)
|
||||
assert withdrawn.status_code == 200
|
||||
refresh_snapshot()
|
||||
assert consent_granted("user", user["uid"], "bandwidth_sharing") is False
|
||||
|
||||
|
||||
def test_an_agent_can_still_grant_an_ordinary_consent(app_server):
|
||||
session, name, user = _member()
|
||||
response = session.post(
|
||||
f"{BASE_URL}/profile/{name}/consent",
|
||||
data={"kind": "ai_third_party", "granted": "1"},
|
||||
headers={**JSON, "X-Devii-Agent": "1"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
Reference in New Issue
Block a user