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