Files

75 lines
7.7 KiB
Markdown
Raw Permalink Normal View History

2026-10-16 07:20:44 +02:00
# DevPlace Node, browser extension
A Manifest V3 extension for Chrome and Firefox from one source tree. It lets a DevPlace member donate a little of their connection to the community node network, in **fetch mode only** (an extension has no raw sockets). The protocol is `devplacepy/services/nodes/spec.py`, served at `GET /nodes/spec.json`; this extension never restates a number from it except in `src/spec.js`, which a test compares against `spec.py`.
## Install (sideload)
1. Chrome: `chrome://extensions`, enable Developer mode, Load unpacked, pick this directory. Firefox: `about:debugging#/runtime/this-firefox`, Load Temporary Add-on, pick `manifest.json`. A temporary Firefox add-on is removed on restart.
2. A disclosure page opens on first install. Read it and accept.
3. On your DevPlace profile give consent to bandwidth sharing, register a device and copy the `dpn_...` secret (shown once).
4. Open the popup, enter the server URL and the secret, grant the host permission for that server when asked, then switch sharing on.
Packaged build: `./build.sh <outdir>` writes `devplace-node-extension.zip` and `devplace-node-extension.zip.sha256`. The zip holds `manifest.json`, `src/`, `ui/`, `icons/` and this file; tests are not shipped.
2026-10-16 07:20:44 +02:00
## Chrome and Firefox from one tree
`manifest.json` declares both `background.service_worker` and `background.scripts`, both pointing at the same ES module.
| Key | Used by |
|-----|---------|
| `background.service_worker` | Chrome (service worker). Firefox ignores it with a console warning. |
| `background.scripts` | Firefox (event page). Chrome ignores it when a service worker is present. |
| `browser_specific_settings.gecko` | Firefox only (add-on id, minimum 128). Chrome warns about the unknown key and ignores it. |
Both use the `chrome.*` namespace with promises. Firefox store submission additionally needs a `data_collection_permissions` declaration, not added yet.
## How it works
- The background worker holds the WebSocket. It exchanges the device secret for a 30 second ticket (`POST /nodes/ticket`, `Authorization: Bearer dpn_...`) and opens `WS /nodes/ws?ticket=...`, then sends `hello` with `kind: "extension"` and `modes: ["fetch"]`.
- A `pong` is sent every `ping_seconds` as a keepalive and for every server `ping`. A `chrome.alarms` alarm every minute is the reconnect backstop when the worker has been suspended.
- Reconnects use full-jitter backoff between `reconnect_min_seconds` and `reconnect_max_seconds`. Close `4013` retries at the minimum delay. A kick (`4403`), a consent or device refusal (`1008` with a reason) or a rejected secret (HTTP 401/403) **halts** and shows the reason; nothing hammers the server. A timed suspension is the exception: a ticket `403` with reason `suspended`, or a `kick` with reason `suspended`, puts the client in the `SUSPENDED` state, shows the end time in the popup and retries at the `until` instant (clamped to 60 seconds to 24 hours, plus up to 30 seconds of jitter; 5 minutes when `until` is empty or unreadable).
2026-10-16 07:20:44 +02:00
- State (connection, last error) is in `chrome.storage.session`. Config (server URL, secret, limits, disclosure flag) and the usage counters are in `chrome.storage.local`.
- The secret is never logged and never shown after saving; the popup shows its first 8 characters only.
## Safety enforced in the extension, before every request
Independent of anything the server says:
- allowlist: the hosts baked into `src/spec.js` (`reddit.com`, `redd.it` and their subdomains, dot-safe suffix match). A refreshed list from `welcome` is accepted only entry by entry when it is equal or narrower (same or sub-suffix, methods and ports subsets, fetch mode present); a widening entry is dropped and reported in the popup
- https only, port 443 only (the spec's 80 is never used because plain http is refused), no credentials in the URL, method must be permitted (GET)
- `fetch` with `credentials: "omit"`, `redirect: "manual"`, `cache: "no-store"`, no referrer; only `accept` and `accept-language` are forwarded from the job, so no cookie or authorization header can be injected
- each redirect hop is re-checked against the allowlist, at most 5 hops; a redirect that leaves the allowlist is refused with `redirect_not_allowed`
- response body capped at `max_body_bytes` and by the remaining daily budget, with `truncated: true`; `set-cookie` style response headers are dropped
- timeout by `AbortController`, daily bandwidth cap, concurrency cap and active hours from the local settings (the active hours must be open in the donor's own browser clock AND in the server window evaluated in the server's timezone, an unknown zone falling back to UTC), each answered with its refusal code (`timeout`, `bandwidth_exhausted`, `concurrency_exceeded`, `outside_hours`). A server value can only lower these, never raise them
2026-10-16 07:20:44 +02:00
Known limit: browsers hide the target of a `redirect: "manual"` response (an opaque redirect), so the extension cannot read where it points. It fails closed with `redirect_not_allowed`. A followable redirect is only handled when the response exposes its `Location`.
## Permissions
| Permission | Why |
|------------|-----|
| `storage` | config, usage counters, connection state |
| `alarms` | reconnect backstop while the worker is suspended |
| `host_permissions`: `reddit.com`, `*.reddit.com`, `redd.it`, `*.redd.it` over https | fetch the allowlisted destinations without CORS. These are exactly the destinations in `spec.py`. |
| `optional_host_permissions`: any https origin, `localhost`, `127.0.0.1` over http | requested at connect time for the one server origin you enter, so the ticket request works. Not granted at install. |
No `tabs`, `cookies`, `history`, `webRequest` or `<all_urls>` permission.
## What is and is not shared
Shared: the URL of the requested public page, the page content that comes back, your public IP address as seen by the destination, and request and byte counts. Not used: your cookies, logins, browsing history, tabs, other sites, any host outside the list, any non-https or non-443 destination. See `ui/disclosure.html`, shown on first install.
## Tests
`node --test nodes/extension/test/*.mjs` (no dependencies). They cover Policy, Limits (including a server-timezone parity table generated with Python `zoneinfo`), the popup helpers, the packaged icons, WsClient suspension and device label, Fetcher with a stubbed `fetch`, WsClient with a fake WebSocket, and a drift check of `src/spec.js` against `spec.py`. Not covered by automated tests: `background.js` and the popup, which need a real browser.
2026-10-16 07:20:44 +02:00
## Gaps
- Battery-aware pause is not implemented in the extension (browsers expose no reliable power state to a background worker); the Go client honours `pause_on_battery`.
- Verified in real browsers on 2026-10-16: Chromium through `tests/e2e/nodes/extension.py` (disclosure, connect, online, a fetch-mode job answered 403 via `fetch`, suspend, disable) and Firefox 158 driven by geckodriver and selenium (same flow). The Firefox script is not in the repository. Method: start a scratch DevPlace server with a donor account, install the extension as a temporary add-on, open the popup from a chrome-context tab (`--remote-allow-system-access`), and watch the device go online in `/admin/nodes`.
- `manifest.json` sets `content_security_policy.extension_pages` to `script-src 'self'; object-src 'self'`. Firefox's default MV3 policy includes `upgrade-insecure-requests`, which rewrites `ws://127.0.0.1` to `wss://` and breaks a loopback server; a public `wss://` server is unaffected.
## Icons
`icons/icon-16|32|48|128.png` are generated by `python3 tools/make_icons.py` (Pillow), a node glyph on a rounded dark-blue square. The popup shows the server's device label from `welcome` and falls back to the label typed locally before the first connect.