435 lines
22 KiB
Python
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`."],
|
|
),
|
|
],
|
|
}
|