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:
2026-10-14 09:14:53 +02:00
co-authored by Claude Sonnet 5.5
parent fad41d0fbe
commit 42b81271b8
41 changed files with 2166 additions and 22 deletions
+3 -3
View File
@@ -65,7 +65,7 @@ devplacepy/
| `/feed` | Post feed with topic/tab filtering and free-text `search` (title, content, and author username) in the left panel (public). Each page interleaves authors so no two consecutive posts share an author. |
| `/topics` | Crawlable per-topic category pages (public): `/topics` hub links every topic with a live post count, `/topics/{topic}` lists that topic's posts with its own canonical URL, title, and breadcrumbs |
| `/skills` | Per-user Agent Skills: list, detail, install from a URL, enable/disable, delete. Private to the owner, `noindex` |
| `/nodes` | Community node network. Today only the public protocol specification `GET /nodes/spec.json` exists; the socket, devices, consent and clients are planned (see `plan.md`) |
| `/nodes` | Community node network: `GET /nodes/spec.json`, `GET/POST /nodes/devices` (+ `/rotate`, `/delete`), `POST /nodes/ticket`, `WS /nodes/ws`. Server side only, off by default, no client yet (see `plan.md`) |
| `/news` | Developer news listing, detail page with comments |
| `/posts` | Post detail, creation |
| `/gists` | Code gist listing, detail, creation, and editing; left panel offers language filtering and free-text `search` (title, description, and author username), public read. A plain Markdown gist has a **View rendered / View raw** toggle beside its Copy button, switching between the raw source and the same rendered view a `Markdown Rendered` gist always shows |
@@ -252,9 +252,9 @@ DevPlace is open in the sense that it does not editorialise technical opinion. I
Members can install [Agent Skills](https://agentskills.io) from a URL at `/skills` so Devii can use them. A skill is a `SKILL.md` (YAML frontmatter plus markdown instructions) with optional text files under `scripts/`, `references/` and `assets/`. Skills are private to their owner, count against a per-user cap, and only the name and a one-line description of enabled skills are added to Devii's system prompt; Devii loads the full text on demand. A skill is untrusted third-party text: it can never grant Devii a tool, bypass a confirmation or run a script. Deleting a skill is a soft delete and needs confirmation when Devii does it. Documented in the API docs under *Skills* and in `devplacepy/services/skills/CLAUDE.md`.
## Community nodes (planned, specification only)
## Community nodes (server side built, no client yet)
Members will be able to opt in to donate a little of their connection to the DevPlace community, so search can reach sites that block datacenter addresses. **Today only the specification exists:** `GET /nodes/spec.json` and the [Node network protocol](/docs/nodes-protocol.html) docs page, both generated from `devplacepy/services/nodes/spec.py`. No account can donate yet and nothing connects.
Members will be able to opt in to donate a little of their connection to the DevPlace community, so search can reach sites that block datacenter addresses. **Today the server side exists:** the specification (`GET /nodes/spec.json` and the [Node network protocol](/docs/nodes-protocol.html) docs page, both generated from `devplacepy/services/nodes/spec.py`), device registration (`/nodes/devices`), the socket ticket (`POST /nodes/ticket`), the node socket (`WS /nodes/ws`), the consent gate and the administrator kill switch (`nodes_enabled`, off by default). There is no client and no search integration yet, so nothing donates and nothing is relayed.
What is fixed already, because the specification enforces it: nobody is enrolled by default; a node is not an open proxy and only ever contacts a short published allowlist of websites, on ports 80 and 443, never a private address; the refusal is enforced on the donor's machine as well as on the server; every limit is a published hard cap. The implementation plan, the measured and unmeasured claims, and the legal gate are in `plan.md`.
+4
View File
@@ -43,6 +43,7 @@ from .moderation import (
CONSENTS_TABLE,
CONSENT_KINDS,
CONSENT_STATES,
MEMBER_ONLY_CONSENTS,
MATURITY_LEVELS,
MATURITY_SOURCES,
MATURITY_TABLE,
@@ -62,6 +63,7 @@ from .moderation import (
band_allows_mature,
band_allows_restricted,
consent_granted,
consent_granted_many,
consent_state,
get_maturity,
get_maturity_by_targets,
@@ -261,6 +263,7 @@ __all__ = [
"AGE_BANDS",
"CONSENTS_TABLE",
"CONSENT_KINDS",
"MEMBER_ONLY_CONSENTS",
"CONSENT_STATES",
"MATURITY_LEVELS",
"MATURITY_SOURCES",
@@ -281,6 +284,7 @@ __all__ = [
"band_allows_mature",
"band_allows_restricted",
"consent_granted",
"consent_granted_many",
"consent_state",
"get_maturity",
"get_maturity_by_targets",
+18
View File
@@ -144,6 +144,7 @@ CONSENT_KINDS: dict[str, str] = {
),
}
CONSENT_STATES: tuple[str, ...] = ("granted", "withdrawn")
MEMBER_ONLY_CONSENTS: frozenset[str] = frozenset({"bandwidth_sharing"})
AGE_BANDS: tuple[str, ...] = ("under_min", "13_15", "16_17", "adult")
@@ -300,6 +301,23 @@ def consent_granted(owner_kind: str, owner_id: str, kind: str) -> bool:
return bool(row and row.get("state") == "granted")
def consent_granted_many(owner_kind: str, owner_ids, kind: str) -> set[str]:
unique = list(dict.fromkeys(owner_id for owner_id in owner_ids if owner_id))
latest: dict[str, str] = {}
for start in range(0, len(unique), 500):
placeholders, params = _in_clause(unique[start : start + 500])
for row in db.query(
f"SELECT owner_id, state FROM {CONSENTS_TABLE} WHERE owner_kind = :owner_kind "
f"AND kind = :kind AND deleted_at IS NULL AND owner_id IN ({placeholders}) "
"ORDER BY created_at, id",
owner_kind=owner_kind,
kind=kind,
**params,
):
latest[row["owner_id"]] = row["state"]
return {owner_id for owner_id, state in latest.items() if state == "granted"}
def set_consent(
owner_kind: str, owner_id: str, kind: str, granted: bool, version: str = "1"
) -> dict | None:
+33 -1
View File
@@ -12,7 +12,7 @@ from sqlalchemy import text
from devplacepy.config import SECRET_KEY
from devplacepy.services.nodes.spec import DEVICE_KINDS, SPEC
from .core import _now_iso, db, get_table
from .core import _in_clause, _now_iso, db, get_table
from .soft_delete import soft_delete
LIMITS = SPEC.limits
@@ -23,6 +23,7 @@ SECRET_BYTES = 32
LABEL_MAX = 60
EVENT_DETAIL_MAX = 500
MEGABYTE = 1024 * 1024
BATCH_SIZE = 500
HOURS_PATTERN = re.compile(r"^([01]\d|2[0-3]):[0-5]\d$")
PLATFORMS = tuple(sorted({item.adapter for item in SPEC.destinations}))
FLAG_OPEN = "open"
@@ -125,6 +126,37 @@ def get_device(device_uid: str) -> dict | None:
return get_table("node_devices").find_one(uid=device_uid, deleted_at=None)
def _batches(values) -> list[list[str]]:
unique = list(dict.fromkeys(value for value in values if value))
return [unique[i : i + BATCH_SIZE] for i in range(0, len(unique), BATCH_SIZE)]
def get_devices_by_uids(device_uids) -> dict[str, dict]:
found: dict[str, dict] = {}
for batch in _batches(device_uids):
placeholders, params = _in_clause(batch)
for row in db.query(
f"SELECT * FROM node_devices WHERE uid IN ({placeholders}) "
"AND deleted_at IS NULL",
**params,
):
found[row["uid"]] = dict(row)
return found
def get_enabled_by_users(user_uids) -> dict[str, bool]:
found: dict[str, bool] = {}
for batch in _batches(user_uids):
placeholders, params = _in_clause(batch)
for row in db.query(
f"SELECT user_uid, enabled FROM node_settings WHERE user_uid IN ({placeholders}) "
"AND deleted_at IS NULL",
**params,
):
found[row["user_uid"]] = bool(row["enabled"])
return found
def list_devices(user_uid: str) -> list[dict]:
return list(
get_table("node_devices").find(
+2
View File
@@ -498,6 +498,8 @@ def init_db():
("uid", ""),
("token", ""),
("user_uid", ""),
("scope", ""),
("device_uid", ""),
("created_at", ""),
("expires_at", ""),
("used_at", ""),
+90 -1
View File
@@ -1,6 +1,20 @@
# retoor <retoor@molodetz.nl>
from .._shared import endpoint
from .._shared import endpoint, field
DEVICE_SAMPLE = {
"uid": "DEVICE_UID",
"label": "Home laptop",
"kind": "native",
"platform": "linux/amd64",
"client_version": "0.1.0",
"status": "active",
"suspended_until": None,
"country": "NL",
"asn_class": "residential",
"last_seen_at": "2026-01-01T00:00:00+00:00",
"created_at": "2026-01-01T00:00:00+00:00",
}
GROUP = {
"slug": "nodes",
@@ -65,5 +79,80 @@ page. Every endpoint follows the shared [Conventions & Errors](/docs/conventions
"Clients and the server read the same numbers from here; a contract test fails when they drift.",
],
),
endpoint(
id="nodes-devices-list",
method="GET",
path="/nodes/devices",
title="List your devices",
summary="The devices you registered, with their status and when they last connected. Secrets are never listed.",
auth="user",
interactive=True,
sample_response={"devices": [DEVICE_SAMPLE], "max_devices": 5},
),
endpoint(
id="nodes-devices-create",
method="POST",
path="/nodes/devices",
title="Register a device",
summary="Register one client install. The device secret is returned once and cannot be read again.",
auth="user",
ajax=True,
encoding="form",
params=[
field("label", "form", "string", False, "Home laptop", "A name only you see, up to 60 characters."),
field("kind", "form", "string", False, "native", "`native`, `extension` or `container`.", options=["native", "extension", "container"]),
field("platform", "form", "string", False, "linux/amd64", "Operating system and architecture of the client."),
field("client_version", "form", "string", False, "0.1.0", "Version of the client."),
],
sample_response={"device": DEVICE_SAMPLE, "secret": "dpn_EXAMPLE_SECRET_SHOWN_ONCE"},
notes=[
"Refused with `403` and a `reason` (`kill_switch`, `no_consent`, `not_enabled`, `not_adult`, `deletion_pending`, `suspended`, `account_inactive`) unless donation is switched on, you are an adult, you have given the `bandwidth_sharing` consent and enabled donation for yourself.",
"Refused with `409` when you already have the maximum number of devices.",
"The response is `201` and is never cached.",
],
),
endpoint(
id="nodes-devices-rotate",
method="POST",
path="/nodes/devices/{device_uid}/rotate",
title="Rotate a device secret",
summary="Issue a new secret for one of your devices. The old secret stops working immediately.",
auth="user",
ajax=True,
params=[
field("device_uid", "path", "string", True, "DEVICE_UID", "Device uid."),
],
sample_response={"uid": "DEVICE_UID", "secret": "dpn_EXAMPLE_SECRET_SHOWN_ONCE"},
notes=["`404` for a device that is not yours."],
),
endpoint(
id="nodes-devices-delete",
method="POST",
path="/nodes/devices/{device_uid}/delete",
title="Delete a device",
summary="Remove a device. A connected node is disconnected on the next service tick. An administrator may remove any device.",
auth="user",
ajax=True,
destructive=True,
params=[
field("device_uid", "path", "string", True, "DEVICE_UID", "Device uid."),
],
sample_response={"ok": True},
),
endpoint(
id="nodes-ticket",
method="POST",
path="/nodes/ticket",
title="Exchange a device secret for a socket ticket",
summary="The node client calls this with its device secret to get a single-use ticket for `WS /nodes/ws?ticket=`.",
auth="user",
interactive=False,
sample_response={"ticket": "TICKET", "expires_in": 30, "ws_path": "/nodes/ws"},
notes=[
"Authenticated by `Authorization: Bearer dpn_...` with the device secret, not by a session. Anything else is `401`.",
"The ticket is valid for 30 seconds and can be redeemed once. The socket then speaks the frames published in the [protocol specification](/docs/nodes-protocol.html).",
"`403` with a `reason` when the device is suspended or its owner no longer passes the consent gate.",
],
),
],
}
+2
View File
@@ -128,6 +128,7 @@ from devplacepy.services.audit import AuditService
from devplacepy.services.moderation.service import ModerationService
from devplacepy.services.moderation.screening import ContentRefused
from devplacepy.services.acceptance.service import AcceptanceService
from devplacepy.services.nodes.service import NodesService
from devplacepy.services.audit import record as audit
from devplacepy.services.push import PushService
from devplacepy.services.telegram import TelegramService
@@ -297,6 +298,7 @@ async def lifespan(app: FastAPI):
service_manager.register(AuditService())
service_manager.register(ModerationService())
service_manager.register(AcceptanceService())
service_manager.register(NodesService())
service_manager.register(PushService())
service_manager.register(TelegramService())
service_manager.register(TelegramOutboxService())
+16
View File
@@ -1124,6 +1124,22 @@ class BanForm(BaseModel):
reason: str = Field(default="", max_length=200)
class NodeDeviceForm(BaseModel):
label: str = Field(default="", max_length=60)
kind: str = Field(default="native", max_length=20)
platform: str = Field(default="", max_length=40)
client_version: str = Field(default="", max_length=40)
@field_validator("kind")
@classmethod
def known_kind(cls, value):
from devplacepy.services.nodes.spec import DEVICE_KINDS
if value not in DEVICE_KINDS:
raise ValueError("Unknown device kind")
return value
class ConsentForm(BaseModel):
kind: str = Field(min_length=1, max_length=40)
granted: str = Field(default="0", max_length=5)
+1 -1
View File
@@ -53,7 +53,7 @@ Prefixes are wired in `main.py`:
| (none) | push.py - push + PWA: `GET /push.json` (VAPID public key + the providers accepting registrations; `apns` includes `environment` when active), `POST /push.json` (register with any active provider; a body without `provider` is a `webpush` body; an APNs body is `token` plus optional `client_id` for device-stable upsert; created/revived rows are probed and the JSON may include `delivered`/`error`; an unknown, disabled or unconfigured provider is a 400), `DELETE /push.json` (unregister exactly the one registration named by `endpoint`/`token`/`client_id`, same identity priority as registration; idempotent, always 200 `{unregistered}`; wired into `PushManager.js`'s logout-link interceptor so a webpush subscription is dropped before the browser navigates to `/auth/logout`), `GET /service-worker.js`, `GET /manifest.json`. Provider protocol and delivery live in `devplacepy/push/` - see `devplacepy/push/CLAUDE.md` |
| (none) | docs.py (docs/ package) - the documentation site (prose pages + API reference). See `routers/docs/CLAUDE.md` for the deep detail on this tree (`DOCS_PAGES`, audience tiers, prose rendering pipeline) |
| `/skills` | skills.py - per-user Agent Skills: `GET ""` (list), `GET /{name}` (detail), `POST /install` (fetch a `SKILL.md` from a URL, update in place), `POST /{name}/enabled`, `POST /{name}/delete` (owner, or an administrator naming `owner`). Private to the owner, `noindex`. See `devplacepy/services/skills/CLAUDE.md` |
| `/nodes` | nodes/ package - community node network. Today only `spec.py` (`GET /nodes/spec.json`, public, returns `SPEC.describe()`); the socket, device and consent routes are planned. See `devplacepy/services/nodes/CLAUDE.md` |
| `/nodes` | nodes/ package - community node network. `spec.py` (`GET /nodes/spec.json`, public, `SPEC.describe()`), `devices.py` (`GET/POST /nodes/devices`, `POST /nodes/devices/{uid}/rotate|delete`, `require_user_api`, secret returned once, `no-store`), `ticket.py` (`POST /nodes/ticket`, authenticated by `Authorization: Bearer dpn_...`, never a session), `ws.py` (`WS /nodes/ws?ticket=`, lock-owner gated, `4013` retry). Needs its own nginx upgrade block. See `devplacepy/services/nodes/CLAUDE.md` |
| `/pubsub` | pubsub.py - **database-free** publish/subscribe bus. `WS /pubsub/ws` (subscribe/unsubscribe/publish frames, `foo.*` wildcards), `POST /pubsub/publish` + `GET /pubsub/topics` (admin/internal). Lock-owner-gated WS (`4013` retry) so subscribers converge on one worker; topic authz in `services/pubsub/policy.py` (`user.{uid}.*` private, `public.*` shared, admin/internal anywhere, guests opt-in). In-process `services.pubsub.publish(topic, data)` for backends; frontend `app.pubsub`. See `devplacepy/services/pubsub/CLAUDE.md` |
| (none) | seo.py - `/robots.txt`, `/sitemap.xml` |
+4 -1
View File
@@ -2,7 +2,10 @@
from fastapi import APIRouter
from . import spec
from . import devices, spec, ticket, ws
router = APIRouter()
router.include_router(spec.router)
router.include_router(devices.router)
router.include_router(ticket.router)
router.include_router(ws.router)
+25
View File
@@ -0,0 +1,25 @@
# retoor <retoor@molodetz.nl>
from devplacepy.database.nodes import effective_status
from devplacepy.schemas.nodes import NodeDeviceOut
from devplacepy.services.nodes.consent import ConsentGate
GATE = ConsentGate()
NODES_SCOPE = "nodes"
NO_STORE = {"Cache-Control": "no-store"}
def device_view(row: dict) -> NodeDeviceOut:
return NodeDeviceOut(
uid=row["uid"],
label=row.get("label") or "",
kind=row.get("kind") or "",
platform=row.get("platform") or "",
client_version=row.get("client_version") or "",
status=effective_status(row),
suspended_until=row.get("suspended_until") or None,
country=row.get("country") or "",
asn_class=row.get("asn_class") or "unknown",
last_seen_at=row.get("last_seen_at") or None,
created_at=row.get("created_at"),
)
+142
View File
@@ -0,0 +1,142 @@
# retoor <retoor@molodetz.nl>
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from devplacepy.database.nodes import (
LIMITS,
NodeLimitError,
create_device,
get_device,
list_devices,
revoke_device,
rotate_device_secret,
)
from devplacepy.dependencies import json_or_form
from devplacepy.models import NodeDeviceForm
from devplacepy.responses import json_error
from devplacepy.routers.nodes._shared import GATE, NO_STORE, device_view
from devplacepy.schemas.nodes import (
NodeDeviceCreatedOut,
NodeDevicesOut,
NodeSecretOut,
)
from devplacepy.services.audit import record as audit
from devplacepy.utils import is_admin, require_user_api
logger = logging.getLogger(__name__)
router = APIRouter()
def _json(model, status_code: int = 200) -> JSONResponse:
return JSONResponse(
model.model_dump(mode="json"), status_code=status_code, headers=NO_STORE
)
@router.get("/devices")
async def list_my_devices(request: Request):
user = require_user_api(request)
return _json(
NodeDevicesOut(
devices=[device_view(row) for row in list_devices(user["uid"])],
max_devices=LIMITS.max_devices_per_user,
)
)
@router.post("/devices")
async def create_my_device(
request: Request,
data: Annotated[NodeDeviceForm, Depends(json_or_form(NodeDeviceForm))],
):
user = require_user_api(request)
verdict = GATE.allows(user["uid"])
if not verdict.allowed:
audit.record(
request,
"node.device.create",
user=user,
result="denied",
target_type="node_device",
metadata={"reason": verdict.reason},
summary=f"device registration refused: {verdict.reason}",
)
return json_error(403, verdict.message, reason=verdict.reason)
try:
row, secret = create_device(
user["uid"],
data.label,
data.kind,
platform=data.platform,
client_version=data.client_version,
)
except NodeLimitError as exc:
return json_error(409, str(exc))
audit.record(
request,
"node.device.create",
user=user,
target_type="node_device",
target_uid=row["uid"],
target_label=row["label"],
metadata={"kind": row["kind"]},
summary=f"registered {row['kind']} device {row['label'] or row['uid']}",
)
return _json(
NodeDeviceCreatedOut(device=device_view(row), secret=secret), status_code=201
)
@router.post("/devices/{device_uid}/rotate")
async def rotate_my_device(request: Request, device_uid: str):
user = require_user_api(request)
row = get_device(device_uid)
if row is None or row["user_uid"] != user["uid"]:
return json_error(404, "Device not found")
secret = rotate_device_secret(device_uid, user["uid"])
if secret is None:
return json_error(404, "Device not found")
audit.record(
request,
"node.device.rotate",
user=user,
target_type="node_device",
target_uid=device_uid,
target_label=row["label"],
summary=f"rotated the secret of device {row['label'] or device_uid}",
)
return _json(NodeSecretOut(uid=device_uid, secret=secret))
@router.post("/devices/{device_uid}/delete")
async def delete_my_device(request: Request, device_uid: str):
user = require_user_api(request)
row = get_device(device_uid)
if row is None:
return json_error(404, "Device not found")
if row["user_uid"] != user["uid"] and not is_admin(user):
audit.record(
request,
"security.authz.denied",
user=user,
result="denied",
target_type="node_device",
target_uid=device_uid,
summary="non-owner denied deleting a node device",
)
return json_error(403, "Only the owner or an administrator can delete a device")
revoke_device(device_uid, user["uid"])
audit.record(
request,
"node.device.delete",
user=user,
target_type="node_device",
target_uid=device_uid,
target_label=row["label"],
summary=f"deleted device {row['label'] or device_uid}",
)
return JSONResponse({"ok": True}, headers=NO_STORE)
+52
View File
@@ -0,0 +1,52 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from devplacepy.database.nodes import (
LIMITS,
SECRET_PREFIX,
STATUS_ACTIVE,
effective_status,
find_device_by_secret,
)
from devplacepy.responses import json_error
from devplacepy.routers.nodes._shared import GATE, NO_STORE, NODES_SCOPE
from devplacepy.schemas.nodes import NodeTicketOut
from devplacepy.services.messaging.tickets import issue_ticket
router = APIRouter()
BEARER = "bearer "
def _secret(request: Request) -> str:
header = request.headers.get("authorization", "")
if header.lower().startswith(BEARER):
return header[len(BEARER):].strip()
return ""
@router.post("/ticket")
async def node_ticket(request: Request):
secret = _secret(request)
if not secret.startswith(SECRET_PREFIX):
return json_error(401, "A device secret is required")
device = find_device_by_secret(secret)
if device is None:
return json_error(401, "Unknown device secret")
if effective_status(device) != STATUS_ACTIVE:
return json_error(403, "This device is suspended", reason="suspended")
verdict = GATE.allows(device["user_uid"])
if not verdict.allowed:
return json_error(403, verdict.message, reason=verdict.reason)
token = issue_ticket(
device["user_uid"],
scope=NODES_SCOPE,
device_uid=device["uid"],
ttl_seconds=LIMITS.ticket_ttl_seconds,
)
body = NodeTicketOut(
ticket=token, expires_in=LIMITS.ticket_ttl_seconds, ws_path="/nodes/ws"
)
return JSONResponse(body.model_dump(mode="json"), headers=NO_STORE)
+107
View File
@@ -0,0 +1,107 @@
# retoor <retoor@molodetz.nl>
import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from devplacepy.database.nodes import (
STATUS_ACTIVE,
add_event,
effective_status,
get_device,
hash_ip,
record_device_connection,
)
from devplacepy.routers.nodes._shared import GATE, NODES_SCOPE
from devplacepy.services.manager import service_manager
from devplacepy.services.messaging.tickets import redeem_ticket_row
from devplacepy.services.nodes.frames import FrameError
from devplacepy.services.nodes.session import NodeSession
from devplacepy.services.nodes.spec import (
CLOSE_CAPACITY,
CLOSE_POLICY,
CLOSE_RETRY_OWNER,
)
logger = logging.getLogger(__name__)
router = APIRouter()
TRY_AGAIN_LATER = 1013
def _address(websocket: WebSocket) -> str:
forwarded = websocket.headers.get("x-real-ip", "")
if forwarded:
return forwarded
return websocket.client.host if websocket.client else ""
@router.websocket("/ws")
async def node_ws(websocket: WebSocket, ticket: str = ""):
await websocket.accept()
service = service_manager.get_service("nodes")
if service is None or not service.is_enabled():
await websocket.close(code=TRY_AGAIN_LATER)
return
if not service_manager.owns_lock():
await websocket.close(code=CLOSE_RETRY_OWNER)
return
registry, counters = service.registry, service.counters
row = redeem_ticket_row(ticket, NODES_SCOPE)
if row is None:
counters.inc("rejected_ticket")
await websocket.close(code=CLOSE_POLICY, reason="ticket")
return
device = get_device(row.get("device_uid") or "")
if (
device is None
or device["user_uid"] != row["user_uid"]
or effective_status(device) != STATUS_ACTIVE
):
counters.inc("rejected_device")
await websocket.close(code=CLOSE_POLICY, reason="device")
return
verdict = GATE.allows(device["user_uid"])
if not verdict.allowed:
counters.inc("rejected_consent")
await websocket.close(code=CLOSE_POLICY, reason=verdict.reason)
return
session = NodeSession(websocket, device)
admitted, previous = registry.admit(session)
if not admitted:
await websocket.close(code=CLOSE_CAPACITY, reason="capacity")
return
if previous is not None:
await previous.kick("replaced")
ended = "disconnect"
try:
hello = await session.handshake()
record_device_connection(
device["uid"],
platform=hello["platform"],
client_version=hello["client_version"],
protocol_version=hello["protocol"],
ip_hash=hash_ip(_address(websocket)),
)
add_event("connect", device_uid=device["uid"], user_uid=device["user_uid"])
ended = await session.run()
except FrameError as exc:
counters.inc("protocol_errors")
ended = exc.code
await session.close(exc.close_code, exc.code)
except WebSocketDisconnect:
pass
except Exception:
logger.exception("node socket failed")
ended = "error"
await session.close(CLOSE_POLICY, "error")
finally:
if ended == "heartbeat":
counters.inc("evicted_heartbeat")
registry.release(session)
add_event(
"disconnect",
device_uid=device["uid"],
user_uid=device["user_uid"],
detail=ended,
)
+22 -1
View File
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, Request
from devplacepy.database import (
CONSENT_KINDS,
MEMBER_ONLY_CONSENTS,
consent_state,
get_setting,
get_table,
@@ -15,7 +16,7 @@ from devplacepy.database import (
)
from devplacepy.dependencies import json_or_form
from devplacepy.models import ConsentForm, MaturePreferenceForm
from devplacepy.responses import action_result
from devplacepy.responses import action_result, json_error
from devplacepy.routers.profile.delete import _owner_only
from devplacepy.services.audit import record as audit
from devplacepy.utils import clear_user_cache
@@ -24,6 +25,7 @@ logger = logging.getLogger(__name__)
router = APIRouter()
TRUTHY = ("1", "on", "true", "yes")
AGENT_HEADER = "x-devii-agent"
VERSION_KEYS = {"terms": "terms_version", "privacy": "privacy_version"}
@@ -62,6 +64,25 @@ async def set_user_consent(
if denied is not None:
return denied
granted = data.granted.strip().lower() in TRUTHY
if (
granted
and data.kind in MEMBER_ONLY_CONSENTS
and request.headers.get(AGENT_HEADER) == "1"
):
audit.record(
request,
"security.authz.denied",
result="denied",
target_type="user",
target_uid=target["uid"],
target_label=target["username"],
metadata={"kind": data.kind, "reason": "agent cannot grant"},
summary=f"agent denied granting {data.kind} consent",
)
return json_error(
403,
"This consent can only be given by the member in their own privacy settings",
)
before = consent_state("user", target["uid"], data.kind)
set_consent(
"user", target["uid"], data.kind, granted, version=consent_version(data.kind)
+7
View File
@@ -205,6 +205,13 @@ from devplacepy.schemas.game import (
GameQuestOut,
GameStateOut,
)
from devplacepy.schemas.nodes import (
NodeDeviceCreatedOut,
NodeDeviceOut,
NodeDevicesOut,
NodeSecretOut,
NodeTicketOut,
)
from devplacepy.schemas.skills import (
SkillDetailOut,
SkillInstallOut,
+42
View File
@@ -0,0 +1,42 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from typing import Optional
from devplacepy.schemas.base import _Out
class NodeDeviceOut(_Out):
uid: str = ""
label: str = ""
kind: str = ""
platform: str = ""
client_version: str = ""
status: str = "active"
suspended_until: Optional[str] = None
country: str = ""
asn_class: str = "unknown"
last_seen_at: Optional[str] = None
created_at: Optional[str] = None
class NodeDevicesOut(_Out):
devices: list[NodeDeviceOut] = []
max_devices: int = 0
class NodeDeviceCreatedOut(_Out):
device: NodeDeviceOut
secret: str
class NodeSecretOut(_Out):
uid: str
secret: str
class NodeTicketOut(_Out):
ticket: str
expires_in: int
ws_path: str
+1
View File
@@ -49,6 +49,7 @@ CATEGORY_BY_PREFIX: dict[str, str] = {
"telegram": "telegram",
"devii": "devii",
"skill": "devii",
"node": "nodes",
"email": "email",
"cli": "cli",
"reward": "reward",
@@ -166,7 +166,10 @@ MODERATION_ACTIONS: tuple[Action, ...] = (
summary="Grant or withdraw one consent on your own account",
description=(
f"Kinds: {CONSENT_CHOICES}. Withdrawal takes effect immediately. Only the "
"account holder can change a consent, administrators included."
"account holder can change a consent, administrators included. Consents "
"that let other people use the member's own resources (bandwidth_sharing) "
"can be withdrawn here but are only granted by the member in their own "
"privacy settings."
),
params=(
path("username", "Your own username."),
+1
View File
@@ -31,6 +31,7 @@ Lives on the existing messages router (no new router/prefix; already mounted at
Short-lived, single-use tokens that let a browser-based WebSocket authenticate without ever needing to set a request header on the handshake. `POST /messages/ws-ticket` (`require_user`, so it accepts the same HTTP-reachable auth as any other guarded route - session cookie, `X-API-KEY`, or `Authorization: Bearer` - over a normal fetch/XHR, where headers work fine) calls `issue_ticket(user_uid)` and returns `{"ticket": "<opaque hex token>", "expires_in": 30}`. The client then opens `wss://.../messages/ws?ticket=<ticket>` - a query parameter, which a WS handshake CAN carry.
- `issue_ticket(user_uid) -> str` inserts a `ws_tickets` row (`uid` uuid7, `token` = `secrets.token_hex(24)`, `user_uid`, `created_at`, `expires_at` = now + 30s, `used_at=None`) and returns the token.
- `ws_tickets` also carries `scope` (empty or `messages` for chat, `nodes` for the node socket) and `device_uid`. `redeem_ticket_row(token, scope)` is the one conditional `UPDATE` (single use, unexpired, `COALESCE(scope, 'messages') = :scope`), so a node ticket can never open a chat socket and the reverse. `issue_ticket(user_uid, scope=, device_uid=, ttl_seconds=)`.
- `redeem_ticket(token) -> Optional[str]` looks the row up by `token`; returns `None` if missing, already used (`used_at` set), or past `expires_at`; otherwise stamps `used_at` and returns the `user_uid`. Redemption is single-use by construction - a ticket leaked via referrer or browser history cannot be replayed once redeemed, and it self-expires after 30 seconds even if never used.
- `ws_tickets` is NOT in `SOFT_DELETE_TABLES` - it is an ephemeral, single-use artifact, garbage-collected hard via `devplace messaging prune-tickets` (deletes rows where `expires_at < now`), the same shape as the zip/fork/backup `prune` CLI commands. `database/schema.py` `init_db()` ensures its columns (`uid`, `token`, `user_uid`, `created_at`, `expires_at`, `used_at`) and a unique index on `token` (the hot lookup path) plus an index on `expires_at` (the prune sweep).
- This is strictly additive to `_resolve_ws_user` - the existing session-cookie and header paths are completely unchanged, so `mode="page"` behavior and every existing non-browser API consumer are unaffected.
+18 -6
View File
@@ -9,18 +9,26 @@ from devplacepy.utils import generate_uid
TICKET_KEY_BYTES = 24
TICKET_TTL_SECONDS = 30
MESSAGES_SCOPE = "messages"
def issue_ticket(user_uid: str) -> str:
def issue_ticket(
user_uid: str,
scope: str = MESSAGES_SCOPE,
device_uid: str = "",
ttl_seconds: int = TICKET_TTL_SECONDS,
) -> str:
tickets = get_table("ws_tickets")
token = secrets.token_hex(TICKET_KEY_BYTES)
now = datetime.now(timezone.utc)
expires_at = (now + timedelta(seconds=TICKET_TTL_SECONDS)).isoformat()
expires_at = (now + timedelta(seconds=ttl_seconds)).isoformat()
tickets.insert(
{
"uid": generate_uid(),
"token": token,
"user_uid": user_uid,
"scope": scope,
"device_uid": device_uid,
"created_at": now.isoformat(),
"expires_at": expires_at,
"used_at": None,
@@ -29,7 +37,7 @@ def issue_ticket(user_uid: str) -> str:
return token
def redeem_ticket(token: str) -> Optional[str]:
def redeem_ticket_row(token: str, scope: str = MESSAGES_SCOPE) -> Optional[dict]:
if not token:
return None
from sqlalchemy import text
@@ -40,11 +48,15 @@ def redeem_ticket(token: str) -> Optional[str]:
text(
"UPDATE ws_tickets SET used_at = :now"
" WHERE token = :token AND used_at IS NULL AND expires_at >= :now"
" AND COALESCE(scope, 'messages') = :scope"
),
{"now": now, "token": token},
{"now": now, "token": token, "scope": scope},
)
if result.rowcount != 1:
return None
tickets = get_table("ws_tickets")
row = tickets.find_one(token=token)
return get_table("ws_tickets").find_one(token=token)
def redeem_ticket(token: str) -> Optional[str]:
row = redeem_ticket_row(token, MESSAGES_SCOPE)
return row.get("user_uid") if row else None
+27 -3
View File
@@ -2,7 +2,7 @@ This file documents the community node network (`devplacepy/services/nodes/`, `d
## What exists today
Phase 1 (the specification) and Phase 2 (the data layer and usage ledger) are built. Everything else is planned and listed under "Gaps" next to the phase that closes it. Nothing here is enabled for any account.
Phase 1 (the specification), Phase 2 (the data layer and usage ledger) and Phase 3 (registry, session, consent gate, kill switch, device and ticket routes, the lock-owner socket) are built. No job traffic flows yet. Everything else is planned and listed under "Gaps" next to the phase that closes it. Nothing here is enabled for any account.
| Piece | File | State |
|-------|------|-------|
@@ -12,7 +12,14 @@ Phase 1 (the specification) and Phase 2 (the data layer and usage ledger) are bu
| API docs group | `docs_api/groups/nodes.py` | built, one endpoint |
| Tables and helpers | `database/nodes.py`, `NODES_COLUMNS` in `database/schema.py` | built, tested, no route uses them yet |
| Usage ledger | `ledger.py` (`UsageLedger`, `DonationSummary`) | built, tested |
| `bandwidth_sharing` consent kind | `CONSENT_KINDS` in `database/moderation.py` | registered, never granted by default, enforced nowhere yet |
| `bandwidth_sharing` consent kind | `CONSENT_KINDS` in `database/moderation.py` | registered, never granted by default, enforced by `ConsentGate` |
| Frame codec | `frames.py` (`decode`, `encode`, `FrameError`) | built, tested, validates every frame against `spec.py` |
| Consent choke point | `consent.py` (`ConsentGate`, `Verdict`) | built, tested, single and batch |
| Socket session | `session.py` (`NodeSession`) | built, tested with a fake socket and a real one |
| Registry and counters | `registry.py` (`NodeRegistry`, `NodeCounters`) | built, tested |
| Supervisor | `service.py` (`NodesService`) | built, registered in `main.py`, default enabled |
| Device, ticket and socket routes | `routers/nodes/devices.py`, `ticket.py`, `ws.py` | built, tested |
| nginx upgrade block for `/nodes/ws` | `nginx/nginx.conf.template` | added |
## The specification is the contract
@@ -49,6 +56,23 @@ Seven tables, all ensured with their full column set in `init_db` (`_ensure_node
- **Settings are clamped on write and on read** (`normalise_settings`): bandwidth to `1..max_donor_mb_per_day`, concurrency to `1..max_concurrent_per_node`, hours must be `HH:MM`, platforms are intersected with the spec's adapters. A member with no row is opted out.
- Site settings seeded in `init_db`: `nodes_enabled` (`0`), `nodes_max_total`, `nodes_daily_quota_user`, `nodes_daily_quota_guest`, `nodes_cache_ttl_seconds`, `nodes_social_via_nodes` (`0`). Like every seed, consumers pass the default to `get_setting` because the seed does not run on an empty database.
## Connecting a node (Phase 3)
1. A member consents to `bandwidth_sharing` and turns donation on for themselves (the profile UI for this is Phase 9; today it is `POST /profile/{username}/consent` and `save_node_settings`).
2. `POST /nodes/devices` registers a device and returns its `dpn_` secret once. `GET /nodes/devices`, `POST /nodes/devices/{uid}/rotate` and `/delete` manage it. All are `require_user_api` (401, never a redirect) and answer `Cache-Control: no-store`.
3. The node calls `POST /nodes/ticket` with `Authorization: Bearer dpn_...`. A session cookie or a member API key is not a device secret and gets 401. The ticket is a `ws_tickets` row with `scope="nodes"` and the device uid, valid `ticket_ttl_seconds`, redeemed by one conditional `UPDATE` (16 processes racing one ticket gave exactly one winner; a ticket of the wrong scope never redeems).
4. The node opens `WS /nodes/ws?ticket=...`. The route only runs on the service-lock owner (`4013` elsewhere, the client retries fast, the `/pubsub/ws` precedent). Order of refusal: service disabled `1013`, not the owner `4013`, bad ticket or device or status `1008`, consent gate `1008` with the reason, capacity `4029`, then the handshake.
5. `hello` must arrive within `hello_timeout_seconds`, with a supported `protocol`, a `kind` equal to the registered device kind and a non-empty list of known `modes`. The server answers `welcome` with the full limits and allowlist from the spec. A second connection of the same device kicks the first with `replaced`.
6. The session pings every `ping_seconds`; any frame from the client resets the missed counter and `missed_pings_before_evict` unanswered pings close it with `heartbeat`. `usage` frames are advisory and only summed on the session. Every other client frame is a protocol error until Phase 4 assigns jobs.
## The consent choke point
`ConsentGate` is the only place that decides whether a member's node may exist. `allows_many(user_uids)` decides a whole set with a fixed number of queries and `allows(uid)` is the one-element case of it, so the single and batch answers cannot diverge (a test compares them). Order of reasons: `kill_switch`, `no_account`, `deletion_pending`, `account_inactive`, `suspended`, `not_adult`, `no_consent`, `not_enabled`. It is called at device registration, ticket issue, socket accept and every supervisor tick. An account whose `age_band` is empty (created before age bands existed) is refused as not adult, loudly, rather than assumed adult.
`NodesService.reconcile` runs every `reconcile_seconds`, asks the gate once for all connected owners and loads all devices in one query, then kicks with a reason: `kill_switch`, `revoked`, `suspended`, or the gate's reason. This is why withdrawing the consent, switching donation off, deleting the device or flipping `nodes_enabled` takes effect within one tick even when the request landed on a different worker than the one holding the sockets. **Measured** (scratch SQLite, this machine, 2000 synthetic sessions): one tick costs about 110 ms; the per-session version of the same check cost about 1 ms per session, so 488 ms at 500 sessions and an extrapolated 2 s at 2000, which would have blocked the event loop for the whole tick. Do not reintroduce a per-session query in `reconcile`.
Withdrawal is member-only: `MEMBER_ONLY_CONSENTS` in `database/moderation.py` lists consents that Devii (header `X-Devii-Agent: 1`, resolved in `routers/profile/consent.py`) may withdraw but never grant, and the acceptance service lists `bandwidth_sharing` in `EXCLUDED_CONSENTS`.
## Two transports, two trust levels
`tunnel` (native client) relays raw TCP so TLS runs end to end between DevPlace and the destination: the donor cannot read or forge content. `fetch` (browser extension, which has no raw sockets) lets the donor read and forge the response, so every `fetch` result is verified and labelled. A plain web page or a service worker cannot do either (CORS, no sockets) and is out of scope for good.
@@ -56,7 +80,7 @@ Seven tables, all ensured with their full column set in `init_db` (`_ensure_node
## Gaps (what the next phases close)
- Phase 2 leftovers: the `bandwidth_sharing` consent can currently be granted through `POST /profile/{username}/consent` and by Devii's `set_consent` (Devii calls that route as the user). Nothing reads it yet, so it is inert, but Phase 3 must make Devii unable to grant it (Devii may read and withdraw) at the same time as `ConsentGate` lands. No XP or badge is awarded yet (`UsageLedger.claim_xp` only reserves the daily budget); the privacy page and app-store declarations are updated with the first phase that lets a device connect, because until then no personal data is collected.
- Phase 3: registry, session, the consent choke point, the kill switch, device and ticket routes, the lock-owner WebSocket, the nginx upgrade location.
- Phase 3 leftovers: `country`, `asn` and `asn_class` on a device are never filled (they stay `unknown`), so the datacenter exclusion (D6) is not enforced yet; it needs the offline ASN database from Phase 4. `last_seen_at` is written on connect only, not per heartbeat, so a device list cannot yet show who is online right now. The socket counters are per process and are shown in the service metrics only. The `devplace.net` front proxy idle timeout for a long-lived socket is unmeasured.
- Phase 4: broker, scheduler, dispatcher, destination policy, circuit breakers, result cache.
- Phase 5 onward: the reference client, adapters, the Go client, the extension, the profile UI, Devii tools, the CLI, the privacy and donor-terms pages, the legal gate.
- Not measured yet: Reddit and X success rates per address class, WebSocket idle timeouts through `pravda.education` and `devplace.net`, dispatcher tick cost against SQLite, socket capacity of one worker. The caps in `LimitSpec` are starting ceilings, not measurements.
+84
View File
@@ -0,0 +1,84 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from dataclasses import dataclass
from devplacepy.database import (
consent_granted_many,
get_setting,
get_users_by_uids,
is_account_active,
suspension_active,
)
from devplacepy.database.nodes import BATCH_SIZE, get_enabled_by_users
CONSENT_KIND = "bandwidth_sharing"
ADULT_BAND = "adult"
REASONS: dict[str, str] = {
"kill_switch": "Community donation is switched off",
"no_account": "Account not found",
"account_inactive": "Account is not active",
"deletion_pending": "Account is being deleted",
"suspended": "Account is suspended",
"not_adult": "Only adults can donate bandwidth",
"no_consent": "Bandwidth sharing consent has not been given",
"not_enabled": "Donation is turned off in the member's settings",
}
@dataclass(frozen=True)
class Verdict:
allowed: bool
reason: str = ""
@property
def message(self) -> str:
return REASONS.get(self.reason, "") if not self.allowed else ""
ALLOWED = Verdict(True)
class ConsentGate:
def switched_on(self) -> bool:
return get_setting("nodes_enabled", "0") == "1"
def decide(self, user: dict | None, consented: bool, enabled: bool) -> Verdict:
if not user:
return Verdict(False, "no_account")
if (user.get("deletion_requested_at") or "").strip():
return Verdict(False, "deletion_pending")
if not is_account_active(user):
return Verdict(False, "account_inactive")
if suspension_active(user):
return Verdict(False, "suspended")
if (user.get("age_band") or "") != ADULT_BAND:
return Verdict(False, "not_adult")
if not consented:
return Verdict(False, "no_consent")
if not enabled:
return Verdict(False, "not_enabled")
return ALLOWED
def allows_many(self, user_uids) -> dict[str, Verdict]:
unique = list(dict.fromkeys(uid for uid in user_uids))
if not self.switched_on():
return {uid: Verdict(False, "kill_switch") for uid in unique}
verdicts: dict[str, Verdict] = {}
for start in range(0, len(unique), BATCH_SIZE):
batch = unique[start : start + BATCH_SIZE]
users = get_users_by_uids(batch)
consented = consent_granted_many("user", batch, CONSENT_KIND)
enabled = get_enabled_by_users(batch)
for uid in batch:
verdicts[uid] = self.decide(
users.get(uid), uid in consented, enabled.get(uid, False)
)
return verdicts
def allows(self, user_uid: str) -> Verdict:
if not user_uid:
return Verdict(False, "kill_switch" if not self.switched_on() else "no_account")
return self.allows_many([user_uid])[user_uid]
+90
View File
@@ -0,0 +1,90 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import json
from typing import Any
from devplacepy.services.nodes.spec import (
CLIENT_TO_SERVER,
CLOSE_FRAME_TOO_LARGE,
CLOSE_PROTOCOL,
SPEC,
)
FIELD_TYPES: dict[str, type] = {
"str": str,
"int": int,
"bool": bool,
"object": dict,
"list": list,
}
class FrameError(ValueError):
def __init__(self, code: str, reason: str, close_code: int = CLOSE_PROTOCOL):
super().__init__(reason)
self.code = code
self.reason = reason
self.close_code = close_code
def ceiling(direction: str) -> int:
return max(item.max_bytes for item in SPEC.frames if item.direction == direction)
def _matches(value: Any, type_name: str) -> bool:
expected = FIELD_TYPES[type_name]
if expected is int:
return isinstance(value, int) and not isinstance(value, bool)
return isinstance(value, expected)
def validate(frame: dict, direction: str) -> dict:
kind = frame.get("type")
spec = SPEC.frame(kind, direction) if isinstance(kind, str) else None
if spec is None:
raise FrameError("unknown_frame", f"unknown frame type {kind!r}")
unexpected = set(frame) - spec.allowed()
if unexpected:
raise FrameError("unexpected_field", f"unexpected field {sorted(unexpected)[0]}")
for item in spec.fields:
if item.name not in frame:
if item.required:
raise FrameError("missing_field", f"missing field {item.name}")
continue
if not _matches(frame[item.name], item.type):
raise FrameError("bad_field_type", f"field {item.name} must be {item.type}")
return frame
def decode(raw: str | bytes, direction: str = CLIENT_TO_SERVER) -> dict:
size = len(raw.encode()) if isinstance(raw, str) else len(raw)
if size > ceiling(direction):
raise FrameError(
"frame_too_large", "frame exceeds the size cap", CLOSE_FRAME_TOO_LARGE
)
try:
frame = json.loads(raw)
except ValueError:
raise FrameError("bad_json", "frame is not valid JSON")
if not isinstance(frame, dict):
raise FrameError("bad_json", "frame must be a JSON object")
validate(frame, direction)
limit = SPEC.frame(frame["type"], direction).max_bytes
if size > limit:
raise FrameError(
"frame_too_large", "frame exceeds its size cap", CLOSE_FRAME_TOO_LARGE
)
return frame
def encode(frame_type: str, direction: str, **fields: Any) -> str:
frame = {"type": frame_type, **fields}
validate(frame, direction)
text = json.dumps(frame, separators=(",", ":"))
if len(text.encode()) > SPEC.frame(frame_type, direction).max_bytes:
raise FrameError(
"frame_too_large", "frame exceeds its size cap", CLOSE_FRAME_TOO_LARGE
)
return text
+84
View File
@@ -0,0 +1,84 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from collections import Counter
from devplacepy.database import get_int_setting
from devplacepy.services.nodes.session import NodeSession
from devplacepy.services.nodes.spec import SPEC
LIMITS = SPEC.limits
COUNTER_NAMES: tuple[str, ...] = (
"connected_total",
"rejected_capacity",
"rejected_ticket",
"rejected_consent",
"rejected_device",
"protocol_errors",
"evicted_heartbeat",
"kicked",
"replaced",
)
class NodeCounters:
def __init__(self) -> None:
self._counts: Counter[str] = Counter({name: 0 for name in COUNTER_NAMES})
def inc(self, name: str) -> None:
if name not in COUNTER_NAMES:
raise KeyError(name)
self._counts[name] += 1
def snapshot(self) -> dict[str, int]:
return {name: self._counts[name] for name in COUNTER_NAMES}
class NodeRegistry:
def __init__(self, counters: NodeCounters) -> None:
self._sessions: dict[str, NodeSession] = {}
self.counters = counters
def capacity(self) -> int:
configured = get_int_setting("nodes_max_total", LIMITS.max_connected_nodes)
return max(0, min(configured, LIMITS.max_connected_nodes))
def count(self) -> int:
return len(self._sessions)
def sessions(self) -> list[NodeSession]:
return list(self._sessions.values())
def get(self, device_uid: str) -> NodeSession | None:
return self._sessions.get(device_uid)
def by_user(self, user_uid: str) -> list[NodeSession]:
return [item for item in self._sessions.values() if item.user_uid == user_uid]
def admit(self, session: NodeSession) -> tuple[bool, NodeSession | None]:
previous = self._sessions.get(session.device_uid)
if previous is None and self.count() >= self.capacity():
self.counters.inc("rejected_capacity")
return False, None
self._sessions[session.device_uid] = session
self.counters.inc("connected_total")
if previous is not None:
self.counters.inc("replaced")
return True, previous
def release(self, session: NodeSession) -> None:
if self._sessions.get(session.device_uid) is session:
del self._sessions[session.device_uid]
async def kick(self, session: NodeSession, reason: str) -> None:
self.release(session)
self.counters.inc("kicked")
await session.kick(reason)
async def kick_all(self, reason: str) -> int:
sessions = self.sessions()
for session in sessions:
await self.kick(session, reason)
return len(sessions)
+131
View File
@@ -0,0 +1,131 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import logging
import time
from devplacepy.database.nodes import (
STATUS_ACTIVE,
add_event,
effective_status,
get_devices_by_uids,
)
from devplacepy.services.base import BaseService, ConfigField
from devplacepy.services.nodes.consent import ConsentGate
from devplacepy.services.nodes.ledger import UsageLedger
from devplacepy.services.nodes.registry import NodeCounters, NodeRegistry
from devplacepy.services.nodes.spec import SPEC
logger = logging.getLogger(__name__)
LIMITS = SPEC.limits
SERVICE_NAME = "nodes"
class NodesService(BaseService):
title = "Community nodes"
description = (
"Holds the connected donor nodes, enforces the consent gate and the kill "
"switch within one tick, and prunes the donation counters."
)
details = (
"Donation is off until an administrator turns it on here and a member has "
"consented, enabled it for themselves and registered a device. Switching it "
"off disconnects every node on the next tick. The service never bans a "
"member and never deletes a ledger row on its own."
)
min_interval = 1
METRICS_SECONDS = 5
config_fields = [
ConfigField(
"nodes_enabled",
"Community donation",
type="bool",
default=False,
help="Master kill switch. Off disconnects every node on the next tick.",
group="Safety",
),
ConfigField(
"nodes_max_total",
"Maximum connected nodes",
type="int",
default=LIMITS.max_connected_nodes,
minimum=0,
maximum=LIMITS.max_connected_nodes,
help="Hard ceiling on simultaneous sockets in this process.",
group="Safety",
),
]
def __init__(self) -> None:
super().__init__(SERVICE_NAME, interval_seconds=LIMITS.reconcile_seconds)
self.counters = NodeCounters()
self.registry = NodeRegistry(self.counters)
self.gate = ConsentGate()
self.ledger = UsageLedger()
self._last_prune = 0.0
async def reconcile(self) -> int:
sessions = self.registry.sessions()
if not sessions:
return 0
switched_on = self.gate.switched_on()
devices = get_devices_by_uids(item.device_uid for item in sessions)
verdicts = self.gate.allows_many(item.user_uid for item in sessions)
kicked = 0
for session in sessions:
reason = self._reason_to_kick(
switched_on, devices.get(session.device_uid), verdicts[session.user_uid]
)
if reason:
await self.registry.kick(session, reason)
add_event(
"kick",
device_uid=session.device_uid,
user_uid=session.user_uid,
detail=reason,
)
kicked += 1
return kicked
@staticmethod
def _reason_to_kick(switched_on: bool, device: dict | None, verdict) -> str:
if not switched_on:
return "kill_switch"
if device is None:
return "revoked"
if effective_status(device) != STATUS_ACTIVE:
return "suspended"
return "" if verdict.allowed else verdict.reason
def _prune_due(self) -> bool:
return time.monotonic() - self._last_prune >= LIMITS.prune_interval_seconds
async def run_once(self) -> None:
kicked = await self.reconcile()
if kicked:
self.log(f"disconnected {kicked} node(s)")
if self._prune_due():
self._last_prune = time.monotonic()
pruned = self.ledger.prune()
if any(pruned.values()):
self.log(f"pruned {pruned}")
async def on_disable(self) -> None:
await self.registry.kick_all("service_stopped")
def collect_metrics(self) -> dict:
counts = self.counters.snapshot()
return {
"stats": [
{"label": "connected", "value": self.registry.count()},
{"label": "capacity", "value": self.registry.capacity()},
{"label": "accepted", "value": counts["connected_total"]},
{"label": "kicked", "value": counts["kicked"]},
],
"table": {
"columns": ["counter", "value"],
"rows": [[name, value] for name, value in counts.items()],
},
}
+113
View File
@@ -0,0 +1,113 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import asyncio
import time
from devplacepy.database.nodes import get_node_settings
from devplacepy.services.nodes.frames import FrameError, decode, encode
from devplacepy.services.nodes.spec import (
CLIENT_TO_SERVER,
CLOSE_KICKED,
CLOSE_POLICY,
MODES,
SERVER_TO_CLIENT,
SPEC,
)
LIMITS = SPEC.limits
END_HEARTBEAT = "heartbeat"
END_UNEXPECTED_FRAME = "unexpected_frame"
class NodeSession:
def __init__(self, websocket, device: dict) -> None:
self.websocket = websocket
self.device = device
self.device_uid: str = device["uid"]
self.user_uid: str = device["user_uid"]
self.connected_at = time.monotonic()
self.missed_pings = 0
self.hello: dict = {}
self.reported_up = 0
self.reported_down = 0
self.closed = False
async def send(self, frame_type: str, **fields) -> None:
await self.websocket.send_text(encode(frame_type, SERVER_TO_CLIENT, **fields))
async def close(self, code: int, reason: str = "") -> None:
if self.closed:
return
self.closed = True
try:
await self.websocket.close(code=code, reason=reason[:120])
except Exception:
pass
async def kick(self, reason: str) -> None:
if self.closed:
return
try:
await self.send("kick", reason=reason)
except Exception:
pass
await self.close(CLOSE_KICKED, reason)
def _check_hello(self, frame: dict) -> None:
if not SPEC.min_supported <= frame["protocol"] <= SPEC.version:
raise FrameError("protocol_unsupported", "protocol version is not supported", CLOSE_POLICY)
if frame["kind"] != self.device.get("kind"):
raise FrameError("kind_mismatch", "client kind does not match the registered device", CLOSE_POLICY)
if not frame["modes"] or any(mode not in MODES for mode in frame["modes"]):
raise FrameError("bad_modes", "modes must be a non-empty list of known modes")
async def handshake(self) -> dict:
try:
raw = await asyncio.wait_for(
self.websocket.receive_text(), timeout=LIMITS.hello_timeout_seconds
)
except asyncio.TimeoutError:
raise FrameError("hello_timeout", "no hello frame arrived in time", CLOSE_POLICY)
frame = decode(raw, CLIENT_TO_SERVER)
if frame["type"] != "hello":
raise FrameError("expected_hello", "the first frame must be hello")
self._check_hello(frame)
self.hello = frame
await self.send(
"welcome",
device_uid=self.device_uid,
limits=LIMITS.describe(),
destinations=[item.describe() for item in SPEC.destinations],
settings=get_node_settings(self.user_uid),
)
return frame
def _handle(self, frame: dict) -> None:
kind = frame["type"]
if kind == "pong":
return
if kind == "usage":
self.reported_up += max(0, frame["bytes_up"])
self.reported_down += max(0, frame["bytes_down"])
return
raise FrameError(END_UNEXPECTED_FRAME, f"unexpected {kind} frame, no job is assigned")
async def run(self) -> str:
while not self.closed:
try:
raw = await asyncio.wait_for(
self.websocket.receive_text(), timeout=LIMITS.ping_seconds
)
except asyncio.TimeoutError:
if self.missed_pings >= LIMITS.missed_pings_before_evict:
await self.close(CLOSE_POLICY, END_HEARTBEAT)
return END_HEARTBEAT
self.missed_pings += 1
await self.send("ping")
continue
self.missed_pings = 0
self._handle(decode(raw, CLIENT_TO_SERVER))
return "closed"
+3
View File
@@ -94,6 +94,9 @@ class LimitSpec:
request_retention_seconds: int = 600
xp_daily_cap: int = 50
dispatcher_tick_ms: int = 200
hello_timeout_seconds: int = 10
reconcile_seconds: int = 2
prune_interval_seconds: int = 3600
def describe(self) -> dict[str, int]:
return dict(self.__dict__)
@@ -3,7 +3,7 @@
This page is generated from the protocol specification, `services/nodes/spec.py`, the same data served at [`/nodes/spec.json`](/nodes/spec.json). The server, the clients, the tests and this page all read that one source, so the numbers below cannot drift from the code.
**Status today:** the specification and `/nodes/spec.json` exist. The node socket, the device registration, the consent flow and the clients are not implemented yet. Nothing on this page is enabled for any account.
**Status today:** the specification, `/nodes/spec.json`, device registration, the socket ticket, the node socket and the consent gate are implemented on the server and are switched off by default. The clients and the search integration are not built yet, so no node can do anything useful and no job is ever sent. Nothing on this page is enabled for any account.
## Rules that never change
+11 -2
View File
@@ -2,7 +2,7 @@
retoor <retoor@molodetz.nl>
Every state-changing action in DevPlace records one append-only row through `devplacepy/services/audit/record.py` (`record` for a request-scoped actor, `record_system` for a background one). This file is the authoritative catalogue of the 360 event keys currently emitted, grouped by the category `services/audit/categories.py` `category_for` resolves them to.
Every state-changing action in DevPlace records one append-only row through `devplacepy/services/audit/record.py` (`record` for a request-scoped actor, `record_system` for a background one). This file is the authoritative catalogue of the 362 event keys currently emitted, grouped by the category `services/audit/categories.py` `category_for` resolves them to.
## Rules
@@ -36,6 +36,7 @@ Every state-changing action in DevPlace records one append-only row through `dev
| `message` | 2 |
| `moderation` | 12 |
| `news` | 9 |
| `nodes` | 3 |
| `notification` | 4 |
| `project` | 12 |
| `project_files` | 14 |
@@ -48,7 +49,7 @@ Every state-changing action in DevPlace records one append-only row through `dev
| `telegram` | 5 |
| `tools` | 12 |
**Total: 359 keys.**
**Total: 362 keys.**
## Account and profile (`account`)
@@ -543,6 +544,14 @@ Every state-changing action in DevPlace records one append-only row through `dev
| `telegram.send` | `services/devii/actions/dispatcher.py` |
| `telegram.unpair` | `routers/profile/telegram.py` |
## Community nodes (`nodes`)
| Event key | Recorded in |
|---|---|
| `node.device.create` | `routers/nodes/devices.py` |
| `node.device.delete` | `routers/nodes/devices.py` |
| `node.device.rotate` | `routers/nodes/devices.py` |
## Developer tools (`tools`)
| Event key | Recorded in |
+14
View File
@@ -100,6 +100,20 @@ server {
proxy_send_timeout 3600s;
}
# Community node network websocket (/nodes/ws).
location = /nodes/ws {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Pub/Sub bus websocket (/pubsub/ws).
location = /pubsub/ws {
proxy_pass http://app;
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "devplacepy"
version = "1.0.20"
version = "1.0.21"
description = "DevPlace - The Developer Social Network"
requires-python = ">=3.12"
dependencies = [
+50
View File
@@ -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()
+105
View File
@@ -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}
+67
View File
@@ -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
+155
View File
@@ -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)
+36
View File
@@ -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
+144
View File
@@ -0,0 +1,144 @@
# retoor <retoor@molodetz.nl>
import time
import pytest
from devplacepy.database import get_table, set_consent, set_setting
from devplacepy.database.nodes import save_node_settings
from devplacepy.services.nodes.consent import REASONS, ConsentGate
from devplacepy.utils import register_account
GATE = ConsentGate()
_counter = [0]
def fresh(age_band="adult"):
_counter[0] += 1
name = f"gate{int(time.time() * 1000)}{_counter[0]}"
uid, _, _ = register_account(
name, f"{name}@t.dev", "secret123", age_band=age_band, accepted_terms=True
)
return uid
@pytest.fixture
def switched_on(local_db):
set_setting("nodes_enabled", "1")
yield
set_setting("nodes_enabled", "0")
def ready():
uid = fresh()
set_consent("user", uid, "bandwidth_sharing", True)
save_node_settings(uid, {"enabled": True})
return uid
def test_a_fully_qualified_member_is_allowed(switched_on):
verdict = GATE.allows(ready())
assert verdict.allowed and verdict.reason == "" and verdict.message == ""
def test_the_kill_switch_beats_everything(local_db):
set_setting("nodes_enabled", "1")
uid = ready()
set_setting("nodes_enabled", "0")
assert GATE.allows(uid).reason == "kill_switch"
def test_default_is_off_without_the_setting(local_db):
get_table("site_settings").delete(key="nodes_enabled")
assert GATE.allows(ready()).reason == "kill_switch"
def test_unknown_account(switched_on):
assert GATE.allows("nobody").reason == "no_account"
assert GATE.allows("").reason == "no_account"
def test_each_missing_requirement_has_its_own_reason(switched_on):
no_consent = fresh()
save_node_settings(no_consent, {"enabled": True})
assert GATE.allows(no_consent).reason == "no_consent"
not_enabled = fresh()
set_consent("user", not_enabled, "bandwidth_sharing", True)
assert GATE.allows(not_enabled).reason == "not_enabled"
withdrawn = ready()
set_consent("user", withdrawn, "bandwidth_sharing", False)
assert GATE.allows(withdrawn).reason == "no_consent"
@pytest.mark.parametrize("band", ["", "under_min", "13_15", "16_17"])
def test_only_adults_pass(switched_on, band):
uid = fresh(age_band=band)
set_consent("user", uid, "bandwidth_sharing", True)
save_node_settings(uid, {"enabled": True})
assert GATE.allows(uid).reason == "not_adult"
def test_account_states_block(switched_on):
uid = ready()
users = get_table("users")
users.update({"uid": uid, "suspended_until": "2999-01-01T00:00:00+00:00"}, ["uid"])
assert GATE.allows(uid).reason == "suspended"
users.update({"uid": uid, "suspended_until": ""}, ["uid"])
users.update({"uid": uid, "is_active": False}, ["uid"])
assert GATE.allows(uid).reason == "account_inactive"
users.update({"uid": uid, "is_active": True}, ["uid"])
users.update({"uid": uid, "deletion_requested_at": "2026-01-01T00:00:00+00:00"}, ["uid"])
assert GATE.allows(uid).reason == "deletion_pending"
def test_every_reason_is_described():
for reason in (
"kill_switch",
"no_account",
"account_inactive",
"deletion_pending",
"suspended",
"not_adult",
"no_consent",
"not_enabled",
):
assert REASONS[reason]
def test_batch_decisions_equal_single_decisions(switched_on):
qualified = ready()
no_consent = fresh()
save_node_settings(no_consent, {"enabled": True})
minor = fresh(age_band="16_17")
set_consent("user", minor, "bandwidth_sharing", True)
save_node_settings(minor, {"enabled": True})
withdrawn = ready()
set_consent("user", withdrawn, "bandwidth_sharing", False)
uids = [qualified, no_consent, minor, withdrawn, "nobody"]
many = GATE.allows_many(uids)
assert list(many) == uids
for uid in uids:
assert many[uid] == GATE.allows(uid)
assert [many[uid].reason for uid in uids] == [
"", "no_consent", "not_adult", "no_consent", "no_account",
]
def test_batch_with_the_kill_switch_off_refuses_everyone(local_db):
set_setting("nodes_enabled", "0")
many = GATE.allows_many([ready(), ready()])
assert {verdict.reason for verdict in many.values()} == {"kill_switch"}
def test_batches_larger_than_the_chunk_size_are_all_decided(switched_on, monkeypatch):
monkeypatch.setattr("devplacepy.services.nodes.consent.BATCH_SIZE", 2)
uids = [ready() for _ in range(5)]
many = GATE.allows_many(uids)
assert len(many) == 5 and all(item.allowed for item in many.values())
def test_the_latest_consent_row_wins_in_a_batch(switched_on):
uid = ready()
set_consent("user", uid, "bandwidth_sharing", False)
set_consent("user", uid, "bandwidth_sharing", True)
assert GATE.allows_many([uid])[uid].allowed
+109
View File
@@ -0,0 +1,109 @@
# retoor <retoor@molodetz.nl>
import json
import pytest
from devplacepy.services.nodes import frames
from devplacepy.services.nodes.frames import FrameError, decode, encode
from devplacepy.services.nodes.spec import (
CLIENT_TO_SERVER,
CLOSE_FRAME_TOO_LARGE,
CLOSE_PROTOCOL,
SERVER_TO_CLIENT,
SPEC,
)
HELLO = {
"type": "hello",
"protocol": 1,
"client_version": "0.1.0",
"kind": "native",
"platform": "linux/amd64",
"modes": ["tunnel"],
}
def reason(raw, direction=CLIENT_TO_SERVER):
with pytest.raises(FrameError) as info:
decode(raw, direction)
return info.value
def test_a_valid_frame_round_trips():
assert decode(json.dumps(HELLO)) == HELLO
assert decode(json.dumps({"type": "pong"})) == {"type": "pong"}
@pytest.mark.parametrize("raw", ["", "nope", "[]", "1", "null", '"x"'])
def test_non_object_json_is_rejected(raw):
assert reason(raw).code == "bad_json"
def test_unknown_and_wrong_direction_types_are_rejected():
assert reason(json.dumps({"type": "teleport"})).code == "unknown_frame"
assert reason(json.dumps({"type": "welcome"})).code == "unknown_frame"
assert reason(json.dumps({"type": 5})).code == "unknown_frame"
assert reason(json.dumps({})).code == "unknown_frame"
def test_missing_unexpected_and_mistyped_fields_are_rejected():
assert reason(json.dumps({**HELLO, "protocol": None})).code == "bad_field_type"
missing = {key: value for key, value in HELLO.items() if key != "platform"}
assert reason(json.dumps(missing)).code == "missing_field"
assert reason(json.dumps({**HELLO, "extra": 1})).code == "unexpected_field"
assert reason(json.dumps({**HELLO, "protocol": "1"})).code == "bad_field_type"
assert reason(json.dumps({**HELLO, "modes": "tunnel"})).code == "bad_field_type"
def test_a_bool_is_not_an_int():
assert reason(json.dumps({**HELLO, "protocol": True})).code == "bad_field_type"
usage = {"type": "usage", "bytes_up": False, "bytes_down": 1}
assert reason(json.dumps(usage)).code == "bad_field_type"
def test_optional_fields_may_be_absent_but_not_wrong():
with_limits = {**HELLO, "limits": {"a": 1}}
assert decode(json.dumps(with_limits))["limits"] == {"a": 1}
assert reason(json.dumps({**HELLO, "limits": []})).code == "bad_field_type"
def test_the_size_cap_is_enforced_before_and_after_parsing():
huge = "x" * (frames.ceiling(CLIENT_TO_SERVER) + 1)
error = reason(huge)
assert error.code == "frame_too_large"
assert error.close_code == CLOSE_FRAME_TOO_LARGE
padded = {**HELLO, "platform": "x" * SPEC.frame("hello", CLIENT_TO_SERVER).max_bytes}
assert reason(json.dumps(padded)).code == "frame_too_large"
assert reason(b"{" + b" " * frames.ceiling(CLIENT_TO_SERVER)).close_code == (
CLOSE_FRAME_TOO_LARGE
)
def test_the_big_frame_may_exceed_the_small_cap():
body = "A" * (SPEC.frame("hello", CLIENT_TO_SERVER).max_bytes * 2)
result = {"type": "job_result", "job_id": "j", "status": 200, "body_b64": body}
assert decode(json.dumps(result))["body_b64"] == body
def test_protocol_errors_close_with_the_protocol_code():
assert reason("nope").close_code == CLOSE_PROTOCOL
def test_encode_validates_what_the_server_sends():
text = encode("kick", SERVER_TO_CLIENT, reason="bye")
assert json.loads(text) == {"type": "kick", "reason": "bye"}
with pytest.raises(FrameError):
encode("kick", SERVER_TO_CLIENT)
with pytest.raises(FrameError):
encode("kick", CLIENT_TO_SERVER, reason="x")
with pytest.raises(FrameError):
encode("kick", SERVER_TO_CLIENT, reason="x", extra=1)
def test_every_frame_in_the_spec_can_be_built_and_decoded():
samples = {"str": "x", "int": 1, "bool": True, "object": {}, "list": []}
for spec_frame in SPEC.frames:
fields = {item.name: samples[item.type] for item in spec_frame.fields}
text = encode(spec_frame.type, spec_frame.direction, **fields)
assert decode(text, spec_frame.direction)["type"] == spec_frame.type
+148
View File
@@ -0,0 +1,148 @@
# retoor <retoor@molodetz.nl>
import time
import pytest
from devplacepy.database import get_table, set_consent, set_setting
from devplacepy.database.nodes import (
create_device,
revoke_device,
save_node_settings,
suspend_device,
)
from devplacepy.services.nodes.service import NodesService
from devplacepy.utils import register_account
from tests.conftest import run_async
from tests.unit.services.nodes.session import FakeSocket
from devplacepy.services.nodes.session import NodeSession
_counter = [0]
def member():
_counter[0] += 1
name = f"svc{int(time.time() * 1000)}{_counter[0]}"
uid, _, _ = register_account(
name, f"{name}@t.dev", "secret123", age_band="adult", accepted_terms=True
)
set_consent("user", uid, "bandwidth_sharing", True)
save_node_settings(uid, {"enabled": True})
return uid
def connected(service, uid):
device, _ = create_device(uid, "x", "native")
socket = FakeSocket()
session = NodeSession(socket, device)
service.registry.admit(session)
return device, socket
@pytest.fixture
def service(local_db):
set_setting("nodes_enabled", "1")
yield NodesService()
set_setting("nodes_enabled", "0")
def reconcile(service):
return run_async(service.reconcile())
def test_healthy_nodes_stay_connected(service):
_, socket = connected(service, member())
assert reconcile(service) == 0
assert socket.closed is None
def test_the_kill_switch_disconnects_everyone_in_one_tick(service):
sockets = [connected(service, member())[1] for _ in range(4)]
set_setting("nodes_enabled", "0")
assert reconcile(service) == 4
assert service.registry.count() == 0
assert all(socket.sent[-1] == {"type": "kick", "reason": "kill_switch"} for socket in sockets)
def test_withdrawn_consent_disconnects_that_owner_only(service):
leaving, staying = member(), member()
_, gone = connected(service, leaving)
_, kept = connected(service, staying)
set_consent("user", leaving, "bandwidth_sharing", False)
assert reconcile(service) == 1
assert gone.sent[-1]["reason"] == "no_consent"
assert kept.closed is None and service.registry.count() == 1
def test_a_revoked_device_is_kicked(service):
uid = member()
device, socket = connected(service, uid)
revoke_device(device["uid"], uid)
assert reconcile(service) == 1
assert socket.sent[-1]["reason"] == "revoked"
def test_a_suspended_device_is_kicked(service):
uid = member()
device, socket = connected(service, uid)
suspend_device(device["uid"], "test", 30)
assert reconcile(service) == 1
assert socket.sent[-1]["reason"] == "suspended"
def test_donation_turned_off_by_the_member_disconnects(service):
uid = member()
_, socket = connected(service, uid)
save_node_settings(uid, {"enabled": False})
assert reconcile(service) == 1
assert socket.sent[-1]["reason"] == "not_enabled"
def test_a_deleted_account_is_disconnected(service):
uid = member()
_, socket = connected(service, uid)
get_table("users").update(
{"uid": uid, "deletion_requested_at": "2026-01-01T00:00:00+00:00"}, ["uid"]
)
assert reconcile(service) == 1
assert socket.sent[-1]["reason"] == "deletion_pending"
def test_the_gate_is_asked_once_for_all_owners_per_tick(service, monkeypatch):
uid, other = member(), member()
for owner in (uid, uid, uid, other):
connected(service, owner)
calls = []
original = service.gate.allows_many
def spy(uids):
uids = list(uids)
calls.append(sorted(set(uids)))
return original(uids)
monkeypatch.setattr(service.gate, "allows_many", spy)
reconcile(service)
assert calls == [sorted({uid, other})]
def test_an_empty_registry_costs_nothing(service, monkeypatch):
monkeypatch.setattr(service.gate, "allows_many", lambda uids: 1 / 0)
assert reconcile(service) == 0
def test_stopping_the_service_kicks_all(service):
_, socket = connected(service, member())
run_async(service.on_disable())
assert socket.sent[-1]["reason"] == "service_stopped"
assert service.registry.count() == 0
def test_metrics_report_connected_and_capacity(service):
connected(service, member())
labels = {item["label"]: item["value"] for item in service.collect_metrics()["stats"]}
assert labels["connected"] == 1 and labels["capacity"] > 0
def test_the_service_has_the_kill_switch_as_a_config_field(service):
keys = [field.key for field in service.all_fields()]
assert "nodes_enabled" in keys and "nodes_max_total" in keys
+199
View File
@@ -0,0 +1,199 @@
# retoor <retoor@molodetz.nl>
import asyncio
import json
import pytest
from devplacepy.services.nodes.frames import FrameError
from devplacepy.services.nodes.registry import NodeCounters, NodeRegistry
from devplacepy.services.nodes.session import END_HEARTBEAT, NodeSession
from devplacepy.services.nodes.spec import CLOSE_KICKED, CLOSE_POLICY, SPEC
from tests.conftest import run_async as run
LIMITS = SPEC.limits
HELLO = {
"type": "hello",
"protocol": SPEC.version,
"client_version": "0.1.0",
"kind": "native",
"platform": "linux/amd64",
"modes": ["tunnel", "fetch"],
}
class FakeSocket:
def __init__(self, incoming=()):
self.incoming: asyncio.Queue = asyncio.Queue()
for item in incoming:
self.incoming.put_nowait(item)
self.sent: list[dict] = []
self.closed: tuple[int, str] | None = None
async def receive_text(self):
return await self.incoming.get()
async def send_text(self, text):
self.sent.append(json.loads(text))
async def close(self, code=1000, reason=""):
self.closed = (code, reason)
def session_for(socket, user="u1", uid="d1", kind="native"):
return NodeSession(socket, {"uid": uid, "user_uid": user, "kind": kind})
def test_handshake_sends_welcome(local_db):
socket = FakeSocket([json.dumps(HELLO)])
session = session_for(socket)
frame = run(session.handshake())
assert frame["platform"] == "linux/amd64"
welcome = socket.sent[0]
assert welcome["type"] == "welcome" and welcome["device_uid"] == "d1"
assert welcome["limits"] == LIMITS.describe()
@pytest.mark.parametrize(
"mutation,code",
[
({"protocol": SPEC.version + 1}, "protocol_unsupported"),
({"protocol": SPEC.min_supported - 1}, "protocol_unsupported"),
({"kind": "extension"}, "kind_mismatch"),
({"modes": []}, "bad_modes"),
({"modes": ["tunnel", "teleport"]}, "bad_modes"),
],
)
def test_handshake_rejects_bad_hellos(local_db, mutation, code):
socket = FakeSocket([json.dumps({**HELLO, **mutation})])
with pytest.raises(FrameError) as info:
run(session_for(socket).handshake())
assert info.value.code == code
assert socket.sent == []
def test_the_first_frame_must_be_hello(local_db):
socket = FakeSocket([json.dumps({"type": "pong"})])
with pytest.raises(FrameError) as info:
run(session_for(socket).handshake())
assert info.value.code == "expected_hello"
def test_a_silent_client_times_out(local_db, monkeypatch):
monkeypatch.setattr("devplacepy.services.nodes.session.LIMITS", type("L", (), {
"hello_timeout_seconds": 0.05, "ping_seconds": 1, "missed_pings_before_evict": 2,
"describe": staticmethod(lambda: {}),
}))
with pytest.raises(FrameError) as info:
run(session_for(FakeSocket()).handshake())
assert info.value.code == "hello_timeout" and info.value.close_code == CLOSE_POLICY
def test_usage_is_advisory_and_summed():
session = session_for(FakeSocket())
session._handle({"type": "usage", "bytes_up": 5, "bytes_down": 7})
session._handle({"type": "usage", "bytes_up": -9, "bytes_down": 1})
assert (session.reported_up, session.reported_down) == (5, 8)
@pytest.mark.parametrize("kind", ["job_result", "job_error", "tunnel_data", "tunnel_close", "tunnel_opened"])
def test_frames_without_an_assigned_job_are_protocol_errors(kind):
with pytest.raises(FrameError):
session_for(FakeSocket())._handle({"type": kind})
def test_missed_pings_evict_after_the_floor(monkeypatch):
monkeypatch.setattr("devplacepy.services.nodes.session.LIMITS", type("L", (), {
"ping_seconds": 0.02, "missed_pings_before_evict": 2,
}))
socket = FakeSocket()
session = session_for(socket)
ended = run(session.run())
assert ended == END_HEARTBEAT
assert [item["type"] for item in socket.sent] == ["ping", "ping"]
assert socket.closed[0] == CLOSE_POLICY
def test_any_frame_resets_the_missed_counter(monkeypatch):
monkeypatch.setattr("devplacepy.services.nodes.session.LIMITS", type("L", (), {
"ping_seconds": 0.05, "missed_pings_before_evict": 2,
}))
socket = FakeSocket()
session = session_for(socket)
async def scenario():
task = asyncio.create_task(session.run())
await asyncio.sleep(0.07)
assert session.missed_pings == 1
await socket.incoming.put(json.dumps({"type": "pong"}))
await asyncio.sleep(0.02)
assert session.missed_pings == 0
task.cancel()
run(scenario())
def test_kick_sends_the_reason_then_closes_once():
socket = FakeSocket()
session = session_for(socket)
run(session.kick("revoked"))
run(session.kick("again"))
assert socket.sent == [{"type": "kick", "reason": "revoked"}]
assert socket.closed == (CLOSE_KICKED, "revoked")
def test_registry_replaces_the_same_device_and_enforces_capacity(local_db):
registry = NodeRegistry(NodeCounters())
first = session_for(FakeSocket(), uid="d1")
assert registry.admit(first) == (True, None)
second = session_for(FakeSocket(), uid="d1")
admitted, previous = registry.admit(second)
assert admitted and previous is first
registry.release(first)
assert registry.get("d1") is second
registry.release(second)
assert registry.count() == 0
counts = registry.counters.snapshot()
assert counts["connected_total"] == 2 and counts["replaced"] == 1
def test_registry_capacity_zero_admits_nobody(local_db):
from devplacepy.database import get_setting, set_setting
previous = get_setting("nodes_max_total", "")
set_setting("nodes_max_total", "0")
try:
registry = NodeRegistry(NodeCounters())
assert registry.capacity() == 0
assert registry.admit(session_for(FakeSocket())) == (False, None)
assert registry.counters.snapshot()["rejected_capacity"] == 1
finally:
set_setting("nodes_max_total", previous)
def test_registry_capacity_never_exceeds_the_spec_ceiling(local_db):
from devplacepy.database import get_setting, set_setting
previous = get_setting("nodes_max_total", "")
set_setting("nodes_max_total", str(LIMITS.max_connected_nodes * 10))
try:
assert NodeRegistry(NodeCounters()).capacity() == LIMITS.max_connected_nodes
finally:
set_setting("nodes_max_total", previous)
def test_counters_reject_unknown_names():
with pytest.raises(KeyError):
NodeCounters().inc("made_up")
def test_kick_all_empties_the_registry(local_db):
registry = NodeRegistry(NodeCounters())
sockets = [FakeSocket() for _ in range(3)]
for index, socket in enumerate(sockets):
registry.admit(session_for(socket, uid=f"d{index}"))
assert run(registry.kick_all("bye")) == 3
assert registry.count() == 0
assert all(socket.closed[0] == CLOSE_KICKED for socket in sockets)