Zo doen wijdat in oost

This commit is contained in:
2026-10-16 07:20:44 +02:00
parent 25d9cb1c7d
commit 6f80a820da
229 changed files with 23894 additions and 74 deletions
+126
View File
@@ -0,0 +1,126 @@
import { LIMITS } from "./spec.js";
const KEY = "config";
const SECRET_PREFIX = "dpn_";
const SECRET_MIN_CHARS = 16;
const SECRET_MAX_CHARS = 128;
const LABEL_MAX_CHARS = 64;
const SHOWN_SECRET_CHARS = 8;
const LOOPBACK = ["localhost", "127.0.0.1", "[::1]"];
const DEFAULTS = Object.freeze({
serverUrl: "",
secret: "",
label: "",
disclosed: false,
enabled: false,
bandwidthMb: LIMITS.default_donor_mb_per_day,
maxConcurrent: LIMITS.max_concurrent_per_node,
activeFrom: "",
activeTo: "",
});
export class ConfigError extends Error {}
export class Config {
constructor(area) {
this.area = area;
this.data = { ...DEFAULTS };
}
static validateServerUrl(raw) {
let url;
try {
url = new URL(String(raw).trim());
} catch (error) {
throw new ConfigError("The server URL is not a valid URL");
}
const loopback = LOOPBACK.includes(url.hostname);
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
throw new ConfigError("The server URL must use https");
}
if (url.username || url.password) {
throw new ConfigError("The server URL must not contain credentials");
}
return url.origin;
}
static validateSecret(raw) {
const secret = String(raw).trim();
if (
!secret.startsWith(SECRET_PREFIX) ||
secret.length < SECRET_MIN_CHARS ||
secret.length > SECRET_MAX_CHARS ||
/\s/.test(secret)
) {
throw new ConfigError("That is not a DevPlace device secret (it starts with dpn_)");
}
return secret;
}
async load() {
const stored = (await this.area.get(KEY))[KEY];
this.data = { ...DEFAULTS, ...(stored && typeof stored === "object" ? stored : {}) };
}
async save() {
await this.area.set({ [KEY]: { ...this.data } });
}
get hasSecret() {
return Boolean(this.data.secret);
}
get secretPrefix() {
return this.data.secret ? this.data.secret.slice(0, SHOWN_SECRET_CHARS) + "..." : "";
}
credentials() {
return { serverUrl: this.data.serverUrl, secret: this.data.secret };
}
view() {
const { secret, ...rest } = this.data;
return { ...rest, hasSecret: this.hasSecret, secretPrefix: this.secretPrefix };
}
async connect({ serverUrl, secret, label }) {
this.data.serverUrl = Config.validateServerUrl(serverUrl);
this.data.secret = Config.validateSecret(secret);
this.data.label = String(label || "").trim().slice(0, LABEL_MAX_CHARS);
await this.save();
}
async acceptDisclosure() {
this.data.disclosed = true;
await this.save();
}
async setEnabled(enabled) {
if (enabled && !this.data.disclosed) {
throw new ConfigError("Read and accept the disclosure first");
}
if (enabled && !this.hasSecret) {
throw new ConfigError("Connect a device secret first");
}
this.data.enabled = Boolean(enabled);
await this.save();
}
async saveSettings(values) {
for (const key of ["bandwidthMb", "maxConcurrent", "activeFrom", "activeTo"]) {
if (key in values) {
this.data[key] = values[key];
}
}
await this.save();
}
async forget() {
this.data.secret = "";
this.data.serverUrl = "";
this.data.label = "";
this.data.enabled = false;
await this.save();
}
}
+193
View File
@@ -0,0 +1,193 @@
import {
DROPPED_RESPONSE_HEADERS,
FETCH_FAILED,
FORWARDED_REQUEST_HEADERS,
MAX_BODY_CHUNKS,
MAX_HEADER_VALUE_CHARS,
MAX_REDIRECTS,
MAX_RESPONSE_HEADERS,
REFUSALS,
} from "./spec.js";
const REDIRECT_STATUSES = [301, 302, 303, 307, 308];
const REQUEST_OVERHEAD_BYTES = 256;
const BASE64_CHUNK = 0x8000;
export function toBase64(bytes) {
let text = "";
for (let index = 0; index < bytes.length; index += BASE64_CHUNK) {
text += String.fromCharCode.apply(null, bytes.subarray(index, index + BASE64_CHUNK));
}
return btoa(text);
}
function concat(parts, total) {
const joined = new Uint8Array(total);
let offset = 0;
for (const part of parts) {
joined.set(part, offset);
offset += part.length;
}
return joined;
}
export class Fetcher {
constructor({ policy, limits, usage, fetchImpl, clock }) {
this.policy = policy;
this.limits = limits;
this.usage = usage;
this.fetchImpl = fetchImpl || ((...args) => globalThis.fetch(...args));
this.clock = clock || (() => Date.now());
}
setPolicy(policy) {
this.policy = policy;
}
failure(jobId, code) {
return { type: "job_error", job_id: jobId, code };
}
requestHeaders(raw) {
const headers = {};
if (!raw || typeof raw !== "object") {
return headers;
}
for (const [name, value] of Object.entries(raw)) {
const key = name.toLowerCase();
if (FORWARDED_REQUEST_HEADERS.includes(key) && typeof value === "string") {
headers[key] = value;
}
}
return headers;
}
responseHeaders(response) {
const headers = {};
let count = 0;
response.headers.forEach((value, name) => {
const key = name.toLowerCase();
if (DROPPED_RESPONSE_HEADERS.includes(key) || count >= MAX_RESPONSE_HEADERS) {
return;
}
headers[key] = String(value).slice(0, MAX_HEADER_VALUE_CHARS);
count += 1;
});
return headers;
}
isRedirect(response) {
return response.type === "opaqueredirect" || REDIRECT_STATUSES.includes(response.status);
}
async readBody(response, cap) {
if (!response.body || typeof response.body.getReader !== "function") {
const whole = new Uint8Array(await response.arrayBuffer());
return whole.length > cap
? { bytes: whole.slice(0, cap), truncated: true }
: { bytes: whole, truncated: false };
}
const reader = response.body.getReader();
const parts = [];
let total = 0;
let truncated = false;
for (let chunks = 0; chunks < MAX_BODY_CHUNKS; chunks += 1) {
const { done, value } = await reader.read();
if (done) {
return { bytes: concat(parts, total), truncated };
}
const room = cap - total;
if (value.length > room) {
parts.push(value.subarray(0, room));
total += room;
truncated = true;
break;
}
parts.push(value);
total += value.length;
}
truncated = true;
await reader.cancel();
return { bytes: concat(parts, total), truncated };
}
async follow(job, verdict, signal) {
let current = verdict.url;
const headers = this.requestHeaders(job.headers);
for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
const response = await this.fetchImpl(current.href, {
method: verdict.method,
headers,
credentials: "omit",
redirect: "manual",
cache: "no-store",
referrerPolicy: "no-referrer",
signal,
});
if (!this.isRedirect(response)) {
return { response, final: current };
}
const location = response.headers.get("location");
if (response.type === "opaqueredirect" || !location || hop === MAX_REDIRECTS) {
if (response.body && typeof response.body.cancel === "function") {
await response.body.cancel();
}
return { refused: REFUSALS.REDIRECT_NOT_ALLOWED };
}
const next = this.policy.checkRedirect(location, current, verdict.method);
if (response.body && typeof response.body.cancel === "function") {
await response.body.cancel();
}
if (!next.ok) {
return { refused: REFUSALS.REDIRECT_NOT_ALLOWED };
}
current = next.url;
}
return { refused: REFUSALS.REDIRECT_NOT_ALLOWED };
}
async execute(job) {
const verdict = this.policy.check(job.method, job.url);
if (!verdict.ok) {
return this.failure(job.job_id, verdict.code);
}
const refusal = this.limits.admit(this.usage.usedBytes);
if (refusal) {
return this.failure(job.job_id, refusal);
}
const started = this.clock();
const up = job.url.length + REQUEST_OVERHEAD_BYTES;
const controller = new AbortController();
const timer = setTimeout(
() => controller.abort(),
this.limits.timeoutSeconds(job.timeout) * 1000,
);
try {
const outcome = await this.follow(job, verdict, controller.signal);
if (outcome.refused) {
await this.usage.record({ ok: false, up, down: 0 });
return this.failure(job.job_id, outcome.refused);
}
const budget = this.limits.remainingBytes(this.usage.usedBytes + up);
const cap = Math.min(this.limits.maxBodyBytes, budget);
const body = await this.readBody(outcome.response, cap);
const down = body.bytes.length;
await this.usage.record({ ok: true, up, down });
return {
type: "job_result",
job_id: job.job_id,
status: outcome.response.status,
headers: this.responseHeaders(outcome.response),
body_b64: toBase64(body.bytes),
truncated: body.truncated,
elapsed_ms: Math.max(0, Math.round(this.clock() - started)),
};
} catch (error) {
await this.usage.record({ ok: false, up, down: 0 });
return this.failure(job.job_id, controller.signal.aborted ? REFUSALS.TIMEOUT : FETCH_FAILED);
} finally {
clearTimeout(timer);
this.limits.release();
}
}
}
+167
View File
@@ -0,0 +1,167 @@
import { LIMITS, REFUSALS } from "./spec.js";
const MB = 1024 * 1024;
const HOURS = /^([01]\d|2[0-3]):([0-5]\d)$/;
const CAP_KEYS = [
"max_frame_bytes",
"max_body_bytes",
"max_concurrent_per_node",
"job_timeout_seconds",
"ping_seconds",
"url_max_chars",
];
function isCount(value) {
return typeof value === "number" && Number.isInteger(value) && value >= 1;
}
function clampInt(value, low, high, fallback) {
if (!isCount(value)) {
return fallback;
}
return Math.min(high, Math.max(low, value));
}
function minutes(text) {
const match = HOURS.exec(text);
return match ? Number(match[1]) * 60 + Number(match[2]) : null;
}
export class Limits {
constructor(options = {}) {
this.baked = options.baked || LIMITS;
this.clock = options.clock || (() => new Date());
this.caps = {};
for (const key of CAP_KEYS) {
this.caps[key] = this.baked[key];
}
this.reconnectMin = this.baked.reconnect_min_seconds;
this.reconnectMax = this.baked.reconnect_max_seconds;
this.serverBandwidthMb = this.baked.max_donor_mb_per_day;
this.serverConcurrent = this.baked.max_concurrent_per_node;
this.local = Limits.normaliseLocal({}, this.baked);
this.inflight = 0;
if (options.local) {
this.setLocal(options.local);
}
}
static normaliseLocal(values, baked = LIMITS) {
const from = minutes(values.activeFrom || "");
const to = minutes(values.activeTo || "");
const both = from !== null && to !== null;
return {
bandwidthMb: clampInt(
values.bandwidthMb,
1,
baked.max_donor_mb_per_day,
baked.default_donor_mb_per_day,
),
maxConcurrent: clampInt(
values.maxConcurrent,
1,
baked.max_concurrent_per_node,
baked.max_concurrent_per_node,
),
activeFrom: both ? values.activeFrom : "",
activeTo: both ? values.activeTo : "",
};
}
setLocal(values) {
this.local = Limits.normaliseLocal({ ...this.local, ...values }, this.baked);
}
applyWelcome(welcomeLimits, serverSettings) {
const incoming = welcomeLimits && typeof welcomeLimits === "object" ? welcomeLimits : {};
for (const key of CAP_KEYS) {
this.caps[key] = Math.min(this.baked[key], isCount(incoming[key]) ? incoming[key] : this.baked[key]);
}
const ceiling = this.baked.reconnect_max_seconds;
this.reconnectMin = clampInt(incoming.reconnect_min_seconds, 1, ceiling, this.baked.reconnect_min_seconds);
this.reconnectMax = clampInt(incoming.reconnect_max_seconds, this.reconnectMin, ceiling, ceiling);
this.applyServerSettings(serverSettings);
}
applyServerSettings(settings) {
const values = settings && typeof settings === "object" ? settings : {};
this.serverBandwidthMb = clampInt(
values.bandwidth_mb_day,
1,
this.baked.max_donor_mb_per_day,
this.baked.max_donor_mb_per_day,
);
this.serverConcurrent = clampInt(
values.max_concurrent,
1,
this.baked.max_concurrent_per_node,
this.baked.max_concurrent_per_node,
);
}
get maxBodyBytes() {
return this.caps.max_body_bytes;
}
get maxFrameBytes() {
return this.caps.max_frame_bytes;
}
get urlMaxChars() {
return this.caps.url_max_chars;
}
get pingSeconds() {
return this.caps.ping_seconds;
}
get concurrency() {
return Math.min(this.caps.max_concurrent_per_node, this.local.maxConcurrent, this.serverConcurrent);
}
get bandwidthBytes() {
return Math.min(this.local.bandwidthMb, this.serverBandwidthMb) * MB;
}
timeoutSeconds(requested) {
const asked = isCount(requested) ? requested : this.caps.job_timeout_seconds;
return Math.min(asked, this.caps.job_timeout_seconds);
}
helloLimits() {
return { max_concurrent: this.concurrency };
}
remainingBytes(usedBytes) {
return Math.max(0, this.bandwidthBytes - usedBytes);
}
inActiveHours() {
const from = minutes(this.local.activeFrom);
const to = minutes(this.local.activeTo);
if (from === null || to === null || from === to) {
return true;
}
const now = this.clock();
const current = now.getHours() * 60 + now.getMinutes();
return from < to ? current >= from && current < to : current >= from || current < to;
}
admit(usedBytes) {
if (!this.inActiveHours()) {
return REFUSALS.OUTSIDE_HOURS;
}
if (this.remainingBytes(usedBytes) <= 0) {
return REFUSALS.BANDWIDTH_EXHAUSTED;
}
if (this.inflight >= this.concurrency) {
return REFUSALS.CONCURRENCY_EXCEEDED;
}
this.inflight += 1;
return null;
}
release() {
this.inflight = Math.max(0, this.inflight - 1);
}
}
+128
View File
@@ -0,0 +1,128 @@
import {
ALLOWED_PORTS,
DESTINATIONS,
HTTPS_PORT,
LIMITS,
MODE_FETCH,
REFUSALS,
} from "./spec.js";
const HTTPS = "https:";
function isSubset(candidate, allowed) {
return Array.isArray(candidate) && candidate.every((item) => allowed.includes(item));
}
function normaliseHost(host) {
return String(host).trim().toLowerCase().replace(/\.+$/, "");
}
export class Policy {
constructor(destinations = DESTINATIONS, limits = LIMITS) {
this.destinations = destinations;
this.urlMaxChars = limits.url_max_chars;
}
static narrow(baked, refreshed) {
const accepted = [];
const rejected = [];
if (!Array.isArray(refreshed)) {
return { destinations: [], rejected: ["destinations is not a list"] };
}
for (const item of refreshed) {
const suffix = item && typeof item.suffix === "string" ? normaliseHost(item.suffix) : "";
const parent = baked.find(
(entry) => suffix === entry.suffix || suffix.endsWith("." + entry.suffix),
);
if (!parent) {
rejected.push(`${suffix || "?"}: not in the baked-in allowlist`);
continue;
}
const methods = Array.isArray(item.methods) ? item.methods.map((m) => String(m).toUpperCase()) : [];
const ports = Array.isArray(item.ports) ? item.ports : [];
const modes = Array.isArray(item.modes) ? item.modes : [];
if (!methods.length || !isSubset(methods, parent.methods)) {
rejected.push(`${suffix}: methods widen the baked-in list`);
continue;
}
if (!ports.length || !isSubset(ports, parent.ports) || !isSubset(ports, ALLOWED_PORTS)) {
rejected.push(`${suffix}: ports widen the baked-in list`);
continue;
}
if (!modes.includes(MODE_FETCH)) {
rejected.push(`${suffix}: not available in fetch mode`);
continue;
}
accepted.push({
suffix,
adapter: String(item.adapter || parent.adapter),
methods,
ports,
modes,
max_rps: typeof item.max_rps === "number" ? item.max_rps : parent.max_rps,
});
}
return { destinations: accepted, rejected };
}
static fromRefreshed(baked, refreshed, limits = LIMITS) {
const narrowed = Policy.narrow(baked, refreshed);
return { policy: new Policy(narrowed.destinations, limits), rejected: narrowed.rejected };
}
destinationFor(host) {
const name = normaliseHost(host);
return (
this.destinations.find((item) => name === item.suffix || name.endsWith("." + item.suffix)) || null
);
}
refuse(code, reason) {
return { ok: false, code, reason };
}
check(method, rawUrl) {
if (typeof rawUrl !== "string" || !rawUrl || rawUrl.length > this.urlMaxChars) {
return this.refuse(REFUSALS.HOST_NOT_ALLOWED, "url is missing or too long");
}
let url;
try {
url = new URL(rawUrl);
} catch (error) {
return this.refuse(REFUSALS.HOST_NOT_ALLOWED, "url does not parse");
}
if (url.protocol !== HTTPS) {
return this.refuse(REFUSALS.HOST_NOT_ALLOWED, "only https is allowed");
}
if (url.username || url.password) {
return this.refuse(REFUSALS.HOST_NOT_ALLOWED, "credentials in the url are refused");
}
const destination = this.destinationFor(url.hostname);
if (!destination) {
return this.refuse(REFUSALS.HOST_NOT_ALLOWED, `${url.hostname} is not on the allowlist`);
}
const port = url.port === "" ? HTTPS_PORT : Number(url.port);
if (port !== HTTPS_PORT || !destination.ports.includes(port) || !ALLOWED_PORTS.includes(port)) {
return this.refuse(REFUSALS.PORT_NOT_ALLOWED, `port ${port} is not allowed over https`);
}
const verb = String(method || "").toUpperCase();
if (!destination.methods.includes(verb)) {
return this.refuse(REFUSALS.METHOD_NOT_ALLOWED, `${verb || "?"} is not permitted`);
}
return { ok: true, url, destination, method: verb };
}
checkRedirect(location, base, method) {
let target;
try {
target = new URL(location, base);
} catch (error) {
return this.refuse(REFUSALS.REDIRECT_NOT_ALLOWED, "redirect target does not parse");
}
const verdict = this.check(method, target.href);
if (!verdict.ok) {
return this.refuse(REFUSALS.REDIRECT_NOT_ALLOWED, verdict.reason);
}
return verdict;
}
}
+37
View File
@@ -0,0 +1,37 @@
const KEY = "state";
export const STATES = Object.freeze({
OFF: "off",
CONNECTING: "connecting",
ONLINE: "online",
BACKOFF: "backoff",
HALTED: "halted",
});
export class State {
constructor(area, clock = () => Date.now()) {
this.area = area;
this.clock = clock;
this.data = { state: STATES.OFF, detail: "", since: clock(), deviceUid: "", lastError: "" };
this.chain = Promise.resolve();
}
async load() {
const stored = (await this.area.get(KEY))[KEY];
if (stored && typeof stored === "object") {
this.data = { ...this.data, ...stored };
}
}
snapshot() {
return { ...this.data };
}
set(patch) {
const changed = patch.state && patch.state !== this.data.state;
this.data = { ...this.data, ...patch, since: changed ? this.clock() : this.data.since };
const payload = { ...this.data };
this.chain = this.chain.then(() => this.area.set({ [KEY]: payload }));
return this.chain;
}
}
+81
View File
@@ -0,0 +1,81 @@
const KEY = "usage";
function dayKey(date) {
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${date.getFullYear()}-${month}-${day}`;
}
function empty(day) {
return {
day,
requests: 0,
failed: 0,
bytesUp: 0,
bytesDown: 0,
lifetimeRequests: 0,
lifetimeBytes: 0,
};
}
export class Usage {
constructor(area, clock = () => new Date()) {
this.area = area;
this.clock = clock;
this.data = empty(dayKey(clock()));
this.chain = Promise.resolve();
}
async load() {
const stored = (await this.area.get(KEY))[KEY];
if (stored && typeof stored === "object") {
this.data = { ...empty(dayKey(this.clock())), ...stored };
}
this.roll();
}
roll() {
const today = dayKey(this.clock());
if (this.data.day !== today) {
this.data = {
...empty(today),
lifetimeRequests: this.data.lifetimeRequests,
lifetimeBytes: this.data.lifetimeBytes,
};
}
}
snapshot() {
this.roll();
return { ...this.data };
}
get usedBytes() {
this.roll();
return this.data.bytesUp + this.data.bytesDown;
}
record({ ok, up, down }) {
this.roll();
const bytesUp = Math.max(0, Math.floor(up) || 0);
const bytesDown = Math.max(0, Math.floor(down) || 0);
this.data.requests += 1;
this.data.failed += ok ? 0 : 1;
this.data.bytesUp += bytesUp;
this.data.bytesDown += bytesDown;
this.data.lifetimeRequests += 1;
this.data.lifetimeBytes += bytesUp + bytesDown;
return this.persist();
}
persist() {
const payload = { ...this.data };
this.chain = this.chain.then(() => this.area.set({ [KEY]: payload }));
return this.chain;
}
async reset() {
this.data = empty(dayKey(this.clock()));
await this.persist();
}
}
+375
View File
@@ -0,0 +1,375 @@
import { Policy } from "./Policy.js";
import { STATES } from "./State.js";
import {
CLIENT_KIND,
CLOSE_CAPACITY,
CLOSE_KICKED,
CLOSE_NORMAL,
CLOSE_PROTOCOL,
CLOSE_RETRY_OWNER,
DESTINATIONS,
FRAME_CEILING_BYTES,
HALTING_CLOSE_REASONS_EXEMPT,
MAX_BACKOFF_EXPONENT,
MODE_FETCH,
PROTOCOL_VERSION,
REFUSALS,
SERVER_FRAMES,
} from "./spec.js";
const OPEN = 1;
const POLICY_CLOSE = 1008;
const TICKET_PATH = "/nodes/ticket";
const WS_PATH = "/nodes/ws";
const HALT_STATUSES = [401, 403];
class FrameFault extends Error {}
export class WsClient {
constructor(options) {
this.config = options.config;
this.limits = options.limits;
this.fetcher = options.fetcher;
this.state = options.state;
this.fetchImpl = options.fetchImpl || ((...args) => globalThis.fetch(...args));
this.WebSocketImpl = options.WebSocketImpl || globalThis.WebSocket;
this.timers = options.timers || globalThis;
this.random = options.random || Math.random;
this.clientVersion = options.clientVersion || "0.0.0";
this.platform = options.platform || "chrome";
this.baked = options.baked || DESTINATIONS;
this.socket = null;
this.wanted = false;
this.connecting = false;
this.welcomed = false;
this.attempt = 0;
this.retryTimer = null;
this.pingTimer = null;
this.pending = new Set();
this.generation = 0;
}
async start() {
this.wanted = true;
await this.ensure();
}
async stop(detail = "") {
this.wanted = false;
this.clearTimers();
this.generation += 1;
this.closeSocket(CLOSE_NORMAL, "stop");
await this.state.set({ state: STATES.OFF, detail });
}
async ensure() {
if (!this.wanted || this.socket || this.connecting || this.retryTimer) {
return;
}
await this.connectOnce();
}
clearTimers() {
if (this.retryTimer) {
this.timers.clearTimeout(this.retryTimer);
this.retryTimer = null;
}
if (this.pingTimer) {
this.timers.clearInterval(this.pingTimer);
this.pingTimer = null;
}
}
closeSocket(code, reason) {
const socket = this.socket;
this.socket = null;
if (socket && socket.readyState <= OPEN) {
socket.close(code, reason);
}
}
backoffSeconds(fast) {
const low = this.limits.reconnectMin;
if (fast) {
return low;
}
const exponent = Math.min(this.attempt, MAX_BACKOFF_EXPONENT);
const ceiling = Math.min(this.limits.reconnectMax, low * 2 ** exponent);
return Math.max(low, this.random() * ceiling);
}
async scheduleRetry(detail, fast = false) {
if (!this.wanted) {
return;
}
const seconds = this.backoffSeconds(fast);
this.attempt += 1;
this.clearTimers();
this.retryTimer = this.timers.setTimeout(() => {
this.retryTimer = null;
this.ensure();
}, seconds * 1000);
await this.state.set({ state: STATES.BACKOFF, detail, retryInSeconds: Math.round(seconds) });
}
async halt(detail) {
this.wanted = false;
this.clearTimers();
this.generation += 1;
this.closeSocket(CLOSE_NORMAL, "halt");
await this.state.set({ state: STATES.HALTED, detail, lastError: detail });
}
async fetchTicket() {
const { serverUrl, secret } = this.config.credentials();
const response = await this.fetchImpl(serverUrl + TICKET_PATH, {
method: "POST",
headers: { Authorization: `Bearer ${secret}` },
credentials: "omit",
cache: "no-store",
});
if (!response.ok) {
let reason = "";
try {
reason = (await response.json()).reason || "";
} catch (error) {
reason = "";
}
const error = new Error(`ticket refused with ${response.status} ${reason}`.trim());
error.status = response.status;
throw error;
}
const body = await response.json();
if (!body || typeof body.ticket !== "string" || !body.ticket) {
throw new Error("ticket response has no ticket");
}
const target = new URL(typeof body.ws_path === "string" ? body.ws_path : WS_PATH, serverUrl);
if (target.origin !== new URL(serverUrl).origin) {
throw new Error("ticket response points at another origin");
}
target.protocol = target.protocol === "https:" ? "wss:" : "ws:";
target.searchParams.set("ticket", body.ticket);
return target.href;
}
async connectOnce() {
this.connecting = true;
const generation = this.generation;
await this.state.set({ state: STATES.CONNECTING, detail: "" });
let address;
try {
address = await this.fetchTicket();
} catch (error) {
this.connecting = false;
if (generation !== this.generation) {
return;
}
if (HALT_STATUSES.includes(error.status)) {
await this.halt(error.message);
return;
}
await this.state.set({ lastError: error.message });
await this.scheduleRetry(error.message);
return;
}
this.connecting = false;
if (generation !== this.generation) {
return;
}
this.open(address, generation);
}
open(address, generation) {
this.welcomed = false;
const socket = new this.WebSocketImpl(address);
this.socket = socket;
socket.onopen = () => {
if (generation !== this.generation || this.socket !== socket) {
return;
}
this.sendFrame("hello", {
protocol: PROTOCOL_VERSION,
client_version: this.clientVersion,
kind: CLIENT_KIND,
platform: this.platform,
modes: [MODE_FETCH],
limits: this.limits.helloLimits(),
});
};
socket.onmessage = (event) => {
if (this.socket !== socket) {
return;
}
return this.handleMessage(event.data).catch((error) => this.protocolFault(error));
};
socket.onclose = (event) => {
if (this.socket !== socket) {
return;
}
this.socket = null;
this.onClosed(event.code, event.reason || "");
};
socket.onerror = () => {
this.state.set({ lastError: "websocket error" });
};
}
sendFrame(type, fields = {}) {
if (!this.socket || this.socket.readyState !== OPEN) {
throw new Error(`cannot send ${type}, socket is not open`);
}
const text = JSON.stringify({ type, ...fields });
const ceiling = FRAME_CEILING_BYTES[type] || FRAME_CEILING_BYTES.default;
if (new TextEncoder().encode(text).length > ceiling) {
throw new Error(`${type} frame exceeds its size cap`);
}
this.socket.send(text);
}
async protocolFault(error) {
await this.state.set({ lastError: error.message });
this.closeSocket(CLOSE_PROTOCOL, "protocol");
this.clearTimers();
await this.scheduleRetry(error.message);
}
decode(raw) {
if (typeof raw !== "string") {
throw new FrameFault("binary frames are not part of the protocol");
}
if (raw.length > this.limits.maxFrameBytes) {
throw new FrameFault("server frame exceeds the size cap");
}
let frame;
try {
frame = JSON.parse(raw);
} catch (error) {
throw new FrameFault("server frame is not valid JSON");
}
if (!frame || typeof frame !== "object" || Array.isArray(frame)) {
throw new FrameFault("server frame must be a JSON object");
}
const shape = SERVER_FRAMES[frame.type];
if (!shape) {
throw new FrameFault(`unknown server frame ${String(frame.type)}`);
}
for (const [name, kind] of Object.entries(shape)) {
const value = frame[name];
const good = kind === "array" ? Array.isArray(value) : typeof value === kind && value !== null;
if (!good) {
throw new FrameFault(`frame ${frame.type} field ${name} must be ${kind}`);
}
}
return frame;
}
async handleMessage(raw) {
const frame = this.decode(raw);
if (frame.type !== "welcome" && !this.welcomed && frame.type !== "kick") {
throw new FrameFault(`${frame.type} arrived before welcome`);
}
if (frame.type === "welcome") {
await this.onWelcome(frame);
} else if (frame.type === "ping") {
this.sendFrame("pong");
} else if (frame.type === "job") {
this.runJob(frame);
} else if (frame.type === "settings_update") {
this.limits.applyServerSettings(frame.settings);
} else if (frame.type === "kick") {
await this.halt(`kicked by the server: ${frame.reason}`);
} else {
this.sendFrame("tunnel_opened", {
conn_id: frame.conn_id,
ok: false,
code: REFUSALS.HOST_NOT_ALLOWED,
});
throw new FrameFault(`${frame.type} is a tunnel frame and this client is fetch-only`);
}
}
async onWelcome(frame) {
if (this.welcomed) {
throw new FrameFault("a second welcome arrived");
}
this.limits.applyWelcome(frame.limits, frame.settings);
const refreshed = Policy.fromRefreshed(this.baked, frame.destinations, this.limits.baked);
this.fetcher.setPolicy(refreshed.policy);
this.welcomed = true;
this.attempt = 0;
this.startKeepalive();
const notes = refreshed.rejected.length ? `ignored: ${refreshed.rejected.join("; ")}` : "";
await this.state.set({
state: STATES.ONLINE,
detail: notes,
deviceUid: frame.device_uid,
lastError: notes,
});
}
startKeepalive() {
if (this.pingTimer) {
this.timers.clearInterval(this.pingTimer);
}
this.pingTimer = this.timers.setInterval(() => {
try {
this.sendFrame("pong");
} catch (error) {
this.state.set({ lastError: error.message });
}
}, this.limits.pingSeconds * 1000);
}
runJob(frame) {
const task = this.fetcher
.execute(frame)
.then((result) => {
this.sendFrame(result.type, result);
return this.reportUsage(result);
})
.catch((error) => this.state.set({ lastError: error.message }))
.finally(() => this.pending.delete(task));
this.pending.add(task);
}
reportUsage(result) {
const down = result.type === "job_result" ? Math.ceil((result.body_b64.length * 3) / 4) : 0;
this.sendFrame("usage", { bytes_up: 0, bytes_down: down });
}
async idle() {
while (this.pending.size) {
await Promise.all([...this.pending]);
}
}
classifyClose(code, reason) {
if (code === CLOSE_KICKED) {
return { action: "halt", detail: `closed by the server: ${reason || "kicked"}` };
}
if (code === CLOSE_RETRY_OWNER) {
return { action: "retry", fast: true, detail: "server asked for a retry" };
}
if (code === POLICY_CLOSE && !HALTING_CLOSE_REASONS_EXEMPT.includes(reason)) {
return { action: "halt", detail: `refused by the server: ${reason || "policy"}` };
}
if (code === CLOSE_CAPACITY) {
return { action: "retry", fast: false, detail: "server at capacity" };
}
return { action: "retry", fast: false, detail: `closed ${code} ${reason}`.trim() };
}
async onClosed(code, reason) {
this.clearTimers();
this.welcomed = false;
if (!this.wanted) {
return;
}
const verdict = this.classifyClose(code, reason);
if (verdict.action === "halt") {
await this.halt(verdict.detail);
return;
}
await this.scheduleRetry(verdict.detail, verdict.fast);
}
}
+135
View File
@@ -0,0 +1,135 @@
import { Config, ConfigError } from "./Config.js";
import { Fetcher } from "./Fetcher.js";
import { Limits } from "./Limits.js";
import { Policy } from "./Policy.js";
import { State, STATES } from "./State.js";
import { Usage } from "./Usage.js";
import { WsClient } from "./WsClient.js";
const ALARM = "devplace-node-backstop";
const ALARM_PERIOD_MINUTES = 1;
const DISCLOSURE_PAGE = "ui/disclosure.html";
const config = new Config(chrome.storage.local);
const usage = new Usage(chrome.storage.local);
const state = new State(chrome.storage.session);
const limits = new Limits();
const fetcher = new Fetcher({ policy: new Policy(), limits, usage });
const client = new WsClient({
config,
limits,
fetcher,
state,
clientVersion: chrome.runtime.getManifest().version,
platform: navigator.userAgent.includes("Firefox") ? "firefox" : "chrome",
});
let ready = null;
function boot() {
if (!ready) {
ready = (async () => {
await Promise.all([config.load(), usage.load(), state.load()]);
limits.setLocal(config.data);
})();
}
return ready;
}
async function reconcile() {
await boot();
limits.setLocal(config.data);
if (config.data.enabled && config.data.disclosed && config.hasSecret) {
await client.start();
} else if (client.wanted || state.data.state !== STATES.OFF) {
await client.stop();
}
}
async function status() {
await boot();
return {
config: config.view(),
state: state.snapshot(),
usage: usage.snapshot(),
live: { concurrency: limits.concurrency, bandwidthBytes: limits.bandwidthBytes },
};
}
const COMMANDS = {
status: async () => status(),
connect: async (message) => {
await config.connect(message);
await client.stop("credentials changed");
await reconcile();
return status();
},
accept: async () => {
await config.acceptDisclosure();
return status();
},
enable: async (message) => {
await config.setEnabled(Boolean(message.enabled));
await reconcile();
return status();
},
settings: async (message) => {
await config.saveSettings(message.values || {});
limits.setLocal(config.data);
return status();
},
forget: async () => {
await config.forget();
await client.stop("device forgotten");
return status();
},
resetUsage: async () => {
await usage.reset();
return status();
},
};
async function dispatch(message) {
await boot();
const command = COMMANDS[message && message.cmd];
if (!command) {
return { error: `unknown command ${String(message && message.cmd)}` };
}
try {
return await command(message);
} catch (error) {
if (error instanceof ConfigError) {
return { error: error.message };
}
await state.set({ lastError: error.message });
return { error: error.message };
}
}
chrome.runtime.onMessage.addListener((message, sender, respond) => {
if (sender.id !== chrome.runtime.id) {
return false;
}
dispatch(message).then(respond);
return true;
});
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === ALARM) {
reconcile().catch((error) => state.set({ lastError: error.message }));
}
});
chrome.runtime.onInstalled.addListener((details) => {
chrome.alarms.create(ALARM, { periodInMinutes: ALARM_PERIOD_MINUTES });
if (details.reason === "install") {
chrome.tabs.create({ url: chrome.runtime.getURL(DISCLOSURE_PAGE) });
}
});
chrome.runtime.onStartup.addListener(() => {
chrome.alarms.create(ALARM, { periodInMinutes: ALARM_PERIOD_MINUTES });
reconcile().catch((error) => state.set({ lastError: error.message }));
});
reconcile().catch((error) => state.set({ lastError: error.message }));
+104
View File
@@ -0,0 +1,104 @@
export const PROTOCOL_VERSION = 1;
export const MIN_SUPPORTED_PROTOCOL = 1;
export const CLIENT_KIND = "extension";
export const MODE_FETCH = "fetch";
export const ALLOWED_PORTS = Object.freeze([80, 443]);
export const HTTPS_PORT = 443;
export const CLOSE_NORMAL = 1000;
export const CLOSE_PROTOCOL = 4400;
export const CLOSE_RETRY_OWNER = 4013;
export const CLOSE_CAPACITY = 4029;
export const CLOSE_KICKED = 4403;
export const CLOSE_POLICY = 1008;
export const MAX_REDIRECTS = 5;
export const MAX_RESPONSE_HEADERS = 64;
export const MAX_HEADER_VALUE_CHARS = 512;
export const MAX_BODY_CHUNKS = 65536;
export const MAX_BACKOFF_EXPONENT = 16;
export const LIMITS = Object.freeze({
max_frame_bytes: 256 * 1024,
max_body_bytes: 2 * 1024 * 1024,
max_concurrent_per_node: 2,
job_timeout_seconds: 25,
default_donor_mb_per_day: 500,
max_donor_mb_per_day: 20 * 1024,
ping_seconds: 20,
reconnect_min_seconds: 1,
reconnect_max_seconds: 60,
url_max_chars: 2048,
});
export const FRAME_CEILING_BYTES = Object.freeze({
job_result: 3 * 1024 * 1024,
default: 256 * 1024,
});
export const DESTINATIONS = Object.freeze([
Object.freeze({
suffix: "reddit.com",
adapter: "reddit",
methods: Object.freeze(["GET"]),
ports: Object.freeze([80, 443]),
modes: Object.freeze(["tunnel", "fetch"]),
max_rps: 0.5,
}),
Object.freeze({
suffix: "redd.it",
adapter: "reddit",
methods: Object.freeze(["GET"]),
ports: Object.freeze([80, 443]),
modes: Object.freeze(["tunnel", "fetch"]),
max_rps: 0.5,
}),
]);
export const REFUSALS = Object.freeze({
HOST_NOT_ALLOWED: "host_not_allowed",
PORT_NOT_ALLOWED: "port_not_allowed",
ADDRESS_NOT_PUBLIC: "address_not_public",
REDIRECT_NOT_ALLOWED: "redirect_not_allowed",
METHOD_NOT_ALLOWED: "method_not_allowed",
BANDWIDTH_EXHAUSTED: "bandwidth_exhausted",
CONCURRENCY_EXCEEDED: "concurrency_exceeded",
OUTSIDE_HOURS: "outside_hours",
BODY_TOO_LARGE: "body_too_large",
TIMEOUT: "timeout",
});
export const FETCH_FAILED = "fetch_failed";
export const SERVER_FRAMES = Object.freeze({
welcome: Object.freeze({
device_uid: "string",
limits: "object",
destinations: "array",
settings: "object",
}),
ping: Object.freeze({}),
job: Object.freeze({
job_id: "string",
method: "string",
url: "string",
timeout: "number",
}),
tunnel_open: Object.freeze({ conn_id: "string", host: "string", port: "number" }),
tunnel_data: Object.freeze({ conn_id: "string", b64: "string" }),
tunnel_close: Object.freeze({ conn_id: "string" }),
settings_update: Object.freeze({ settings: "object" }),
kick: Object.freeze({ reason: "string" }),
});
export const DROPPED_RESPONSE_HEADERS = Object.freeze([
"set-cookie",
"set-cookie2",
"authorization",
"proxy-authenticate",
]);
export const FORWARDED_REQUEST_HEADERS = Object.freeze(["accept", "accept-language"]);
export const HALTING_CLOSE_REASONS_EXEMPT = Object.freeze(["ticket", "heartbeat", "error", "capacity"]);