Files
devplacepy/devplacepy/docs_api/groups/nodes.py
T
retoor 407673bf57
DevPlace CI / test (push) Failing after 45m44s
Nodes
2026-10-20 16:58:31 +02:00

435 lines
22 KiB
Python

# retoor <retoor@molodetz.nl>
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",
"title": "Community nodes",
"intro": """
# Community nodes
Members can donate a little of their internet connection to the DevPlace community. A donated
**node** is a small program on the member's own machine that dials out to DevPlace and, when
asked, fetches a public page from a short list of allowed websites on DevPlace's behalf. This is
how search reaches sites that block data-centre addresses.
- **Opt-in only.** Nobody is enrolled by default. Donating needs an explicit consent that only
the account holder can grant or withdraw.
- **Not an open proxy.** A node refuses everything that is not on the published allowlist, every
port except 80 and 443, and every private or local address. The refusal is enforced on the
donor's machine as well as on the server.
- **Counted and visible.** Requests and bytes relayed are counted by the server and shown to the
donor.
The wire protocol, every limit and the allowlist are published as one machine-readable document
at `/nodes/spec.json` and rendered on the [Node network protocol](/docs/nodes-protocol.html)
page. Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html).
""",
"endpoints": [
endpoint(
id="nodes-spec",
method="GET",
path="/nodes/spec.json",
title="Node protocol specification",
summary="The single source of truth for the node protocol: version, frames, limits, allowlist and refusal codes.",
auth="public",
interactive=True,
sample_response={
"version": 1,
"min_supported": 1,
"open_proxy": False,
"allowed_ports": [80, 443],
"limits": {"max_devices_per_user": 5, "max_connected_nodes": 2000},
"frames": [
{
"type": "hello",
"direction": "c2s",
"max_bytes": 262144,
"fields": [{"name": "protocol", "type": "int", "required": True}],
}
],
"destinations": [
{
"suffix": "reddit.com",
"adapter": "reddit",
"methods": ["GET"],
"ports": [80, 443],
"modes": ["tunnel", "fetch"],
"max_rps": 0.5,
}
],
"refusals": [{"code": "host_not_allowed", "meaning": "destination is not on the allowlist"}],
},
notes=[
"`open_proxy` is always `false`. A change to that would be a protocol change, not a setting.",
"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`, `deletion_pending`, `suspended`, `account_inactive`) unless donation is switched on, 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.",
],
),
endpoint(
id="nodes-me",
method="GET",
path="/nodes/me",
title="Your donation status",
summary="Whether you may donate right now (and the reason when not), your settings, your devices and what you have donated.",
auth="user",
interactive=True,
sample_response={
"consent": True,
"can_donate": True,
"reason": "",
"message": "",
"settings": {"enabled": True, "bandwidth_mb_day": 500, "max_concurrent": 2},
"platforms": ["reddit"],
"devices": [DEVICE_SAMPLE],
"max_devices": 5,
"donated": {"lifetime_requests": 12, "lifetime_bytes": 482113, "today_requests": 3},
"limits": {"max_donor_mb_per_day": 20480, "max_concurrent_per_node": 2, "default_donor_mb_per_day": 500},
},
),
endpoint(
id="nodes-settings",
method="POST",
path="/nodes/settings",
title="Change your donation settings",
summary="Update bandwidth, concurrency, active hours, platforms and display. Unsent fields keep their value.",
auth="user",
ajax=True,
encoding="form",
params=[
field("enabled", "form", "boolean", False, "true", "Switch donation on or off. Switching on needs the `bandwidth_sharing` consent and is refused when sent by the Devii assistant."),
field("bandwidth_mb_day", "form", "integer", False, "500", "Daily bandwidth cap in megabytes."),
field("max_concurrent", "form", "integer", False, "2", "Simultaneous requests."),
field("active_from", "form", "string", False, "08:00", "Start of the active window, HH:MM."),
field("active_to", "form", "string", False, "22:00", "End of the active window, HH:MM."),
field("timezone", "form", "string", False, "Europe/Amsterdam", "IANA timezone of the window."),
field("allowed_platforms", "form", "string", False, "reddit", "Comma separated platforms you are willing to serve."),
field("pause_on_battery", "form", "boolean", False, "true", "Stop donating on battery power."),
field("public_display", "form", "boolean", False, "false", "Show your name on the donor leaderboard."),
],
sample_response={"consent": True, "can_donate": True, "settings": {"enabled": True}},
notes=["`403` with `reason: no_consent` when switching on without the consent."],
),
endpoint(
id="nodes-stats",
method="GET",
path="/nodes/stats",
title="Community donation totals",
summary="Aggregate numbers only: requests relayed, bytes relayed and number of donors. No per-member data.",
auth="public",
interactive=True,
sample_response={"requests": 1200, "bytes": 90311234, "donors": 7},
),
endpoint(
id="nodes-leaderboard",
method="GET",
path="/nodes/leaderboard",
title="Donor leaderboard",
summary="The top donors who chose to be shown. Members are not listed unless they switched on public display.",
auth="public",
interactive=True,
sample_response={"donors": [{"username": "alice", "requests": 400, "bytes": 31200000}]},
),
endpoint(
id="nodes-social-search",
method="GET",
path="/nodes/social/search",
title="Social search",
summary="Search public developer discussion on Hacker News, Stack Overflow, GitHub, dev.to, Mastodon and, when donors are online, Reddit.",
auth="user",
interactive=True,
params=[
field("q", "query", "string", True, "sqlite wal", "What to search for, up to 200 characters."),
field("platforms", "query", "string", False, "hackernews,github", "Comma separated subset. Empty means all."),
field("limit", "query", "integer", False, "8", "Hits per platform, 1 to 20."),
],
sample_response={
"query": "sqlite wal",
"platforms": ["hackernews", "reddit"],
"hits": [
{
"platform": "hackernews",
"title": "SQLite WAL mode explained",
"url": "https://news.ycombinator.com/item?id=1",
"snippet": "",
"author": "someone",
"score": 120,
"comments": 33,
"created_at": "2026-01-01T00:00:00Z",
"via": "direct",
}
],
"unavailable": {"reddit": "no_nodes"},
},
notes=[
"Platforms that could not answer are listed under `unavailable` with a reason code; results are never invented for them.",
"Counted against a daily per-member quota (`429` when reached). Bluesky and Lobsters are not covered because they block unauthenticated server access.",
],
),
endpoint(
id="nodes-internal-fetch",
method="POST",
path="/nodes/internal/fetch",
title="Fetch a page through the donor network",
summary="Fetch one page from an allowlisted site through a donor node. Meant for agents and services that hold an API key.",
auth="user",
encoding="form",
params=[
field("url", "form", "string", True, "https://www.reddit.com/r/programming/.json", "Absolute URL on an allowlisted host."),
field("cache", "form", "boolean", False, "true", "Allow a cached copy."),
field("timeout", "form", "integer", False, "15", "Seconds to wait, 1 to 60."),
],
sample_response={
"url": "https://www.reddit.com/r/programming/.json",
"status": 200,
"via": "fetch",
"truncated": False,
"elapsed_ms": 812,
"content_type": "application/json",
"text": "{...}",
},
notes=[
"`403` with `reason: host_not_allowed` for any host that is not on the allowlist, `503` with `reason: no_nodes` when no donor is online, `429` when the daily node quota is reached.",
"`via` says how the body arrived: `fetch` (relayed by a donor browser, treat as unverified) or `cache`.",
],
),
endpoint(
id="nodes-client-version",
method="GET",
path="/nodes/client-version",
title="Client versions and checksums",
summary="The protocol versions this server speaks and every downloadable client with its SHA-256.",
auth="public",
interactive=True,
sample_response={
"version": "0.1.0",
"protocol": 1,
"min_protocol": 1,
"artifacts": [{"name": "devplace-node-linux-amd64", "size": 6291456, "sha256": "ab12..."}],
},
),
endpoint(
id="nodes-download",
method="GET",
path="/nodes/download/{artifact}",
title="Download a client",
summary="Download one client artifact listed by the client version endpoint. The SHA-256 is also sent as `X-Content-SHA256`.",
auth="public",
params=[
field("artifact", "path", "string", True, "devplace-node-linux-amd64", "Artifact name."),
],
sample_response="(binary file)",
),
endpoint(
id="admin-nodes",
method="GET",
path="/admin/nodes",
title="Node network overview",
summary="Pool, queue, device breakdowns, today's totals, open flags, recent devices, settings and the kill switch.",
auth="admin",
interactive=True,
sample_response={
"enabled": True,
"pool": {"online": True, "connected": 12, "capacity": 2000, "accepted": 40, "kicked": 3, "jobs_done": 310, "jobs_failed": 4, "open_circuits": 0, "heartbeat": "2026-01-01T00:00:00+00:00"},
"queue": {"depth": 2, "max_depth": 500, "queued": 1, "assigned": 1, "done": 30, "failed": 1, "expired": 0},
"devices_by_status": {"active": 14, "suspended": 1},
"devices_by_kind": {"native": 10, "extension": 5},
"devices_by_asn_class": {"unknown": 15},
"devices_by_country": {"unknown": 15},
"usage_today": {"requests_ok": 120, "requests_failed": 3, "bytes_up": 40000, "bytes_down": 9000000, "cache_hits_served": 0, "integrity_failures": 0},
"flags": [{"uid": "FLAG_UID", "kind": "refusal_mismatch", "detail": "", "status": "open", "device_uid": "DEVICE_UID", "device_label": "Home laptop", "user_uid": "USER_UID", "owner_username": "alice", "created_at": "2026-01-01T00:00:00+00:00"}],
"devices": [dict(DEVICE_SAMPLE, owner_uid="USER_UID", owner_username="alice", suspend_reason="")],
"settings": [{"key": "nodes_max_total", "label": "Maximum connected nodes", "kind": "int", "value": "2000", "default": "2000", "ceiling": 2000}],
"limits": {"max_devices_per_user": 5, "max_connected_nodes": 2000},
},
notes=[
"Per-destination circuit state lives in the memory of the worker that holds the sockets and is not readable from other workers; only the number of open circuits is reported.",
"`usage_today` is the current UTC day. Lists are capped at 100 rows.",
],
),
endpoint(
id="admin-nodes-killswitch",
method="POST",
path="/admin/nodes/killswitch",
title="Switch community donation on or off",
summary="The global kill switch. Switching it off disconnects every node within one reconcile tick.",
auth="admin",
encoding="form",
destructive=True,
params=[
field("enabled", "form", "boolean", False, "false", "True to switch on, false to switch off. Omit to flip the current state."),
],
sample_response={"ok": True, "redirect": "/admin/nodes", "data": {"enabled": False}},
notes=["Audited as `node.killswitch`."],
),
endpoint(
id="admin-nodes-settings",
method="POST",
path="/admin/nodes/settings",
title="Update node network settings",
summary="Change the caps and quotas. Only the fields you send are changed; values above the ceiling are clamped to it.",
auth="admin",
encoding="form",
params=[
field("nodes_max_total", "form", "integer", False, "2000", "Maximum connected nodes, 0 to 2000."),
field("nodes_daily_quota_user", "form", "integer", False, "200", "Daily node requests per member, 0 to 10000."),
field("nodes_daily_quota_guest", "form", "integer", False, "0", "Daily node requests per guest, 0 to 1000."),
field("nodes_cache_ttl_seconds", "form", "integer", False, "600", "Result cache lifetime in seconds, 0 to 600."),
field("nodes_daily_social_user", "form", "integer", False, "300", "Daily social searches per member, 0 to 10000."),
field("nodes_social_via_nodes", "form", "string", False, "0", "`1` routes social search through nodes, `0` keeps it direct."),
],
sample_response={"ok": True, "redirect": "/admin/nodes", "data": {"changed": ["nodes_max_total"], "clamped": []}},
notes=["Audited as `node.admin.settings` with the old and new values."],
),
endpoint(
id="admin-nodes-suspend",
method="POST",
path="/admin/nodes/devices/{device_uid}/suspend",
title="Suspend a donor device",
summary="Suspend one device for 1 to 10080 minutes. The device is disconnected on the next tick and cannot reconnect until the period ends.",
auth="admin",
encoding="form",
destructive=True,
params=[
field("device_uid", "path", "string", True, "DEVICE_UID", "Device uid."),
field("minutes", "form", "integer", True, "60", "Suspension length in minutes, 1 to 10080."),
field("reason", "form", "string", False, "Repeated refusals", "Reason, up to 500 characters."),
],
sample_response={"ok": True, "redirect": "/admin/nodes", "data": {"uid": "DEVICE_UID", "minutes": 60}},
notes=["Refused (audited as `denied`) when the device owner is a more senior administrator. Audited as `node.suspend`."],
),
endpoint(
id="admin-nodes-class",
method="POST",
path="/admin/nodes/devices/{device_uid}/class",
title="Override a device's address class",
summary="Let one donor device serve jobs although its address is not residential or mobile.",
auth="admin",
encoding="form",
destructive=True,
params=[
field("device_uid", "path", "string", True, "DEVICE_UID", "Device uid."),
field("value", "form", "string", True, "allow", "`allow` to override, `clear` to remove the override."),
],
sample_response={"ok": True, "redirect": "/admin/nodes", "data": {"uid": "DEVICE_UID", "class_override": "allow"}},
notes=["Refused (audited as `denied`) when the device owner is a more senior administrator. Audited as `node.device.override`."],
),
endpoint(
id="admin-nodes-unsuspend",
method="POST",
path="/admin/nodes/devices/{device_uid}/unsuspend",
title="Lift a device suspension",
summary="Return a suspended device to active.",
auth="admin",
encoding="form",
params=[
field("device_uid", "path", "string", True, "DEVICE_UID", "Device uid."),
],
sample_response={"ok": True, "redirect": "/admin/nodes", "data": {"uid": "DEVICE_UID"}},
notes=["`409` when the device is not suspended. Audited as `node.unsuspend`."],
),
endpoint(
id="admin-nodes-flag-resolve",
method="POST",
path="/admin/nodes/flags/{flag_uid}/resolve",
title="Resolve a node flag",
summary="Mark an open integrity or refusal flag as handled.",
auth="admin",
encoding="form",
params=[
field("flag_uid", "path", "string", True, "FLAG_UID", "Flag uid."),
],
sample_response={"ok": True, "redirect": "/admin/nodes", "data": {"uid": "FLAG_UID"}},
notes=["`409` when the flag is already resolved. Audited as `node.flag.resolve`."],
),
],
}