Files
archy/docker/dojobay/server/updates.mjs
T

237 lines
12 KiB
JavaScript

// How far behind is this instance? Compares the local data/version.json
// commit against the GitHub repository, over Tor (TLS through the same SOCKS
// tunnel the probes use), and reports commits behind plus releases published
// since this instance was built. Consumed by GET /api/admin/updates for the
// admin console's update line. Everything degrades gracefully: if GitHub is
// unreachable over Tor, the endpoint says so rather than failing the panel.
//
// "Releases behind" resolves release tags to commits and compares identity, so
// an instance running the exact commit of the newest release reports zero. The
// earlier version counted releases published after the local build timestamp,
// which always reported one behind: a tag is created after the commit it points
// at has been built and deployed. When the running commit is not itself a
// released tag, it falls back to that timestamp guess and flags it as such.
import tls from "node:tls";
import path from "node:path";
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { socks5Connect } from "../scripts/update.mjs";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const GITHUB_REPO = process.env.GITHUB_REPO || "Dojobay/dojobay";
const API_HOST = "api.github.com";
// The request line and headers, separated out so the Accept value is testable
// without a network or a TLS mock. It is not a detail: a download used to ask
// for `application/octet-stream`, and GitHub's archive route answers 415
// Unsupported Media Type to that, so self-update never got past its first
// request and no operator ever saw it work. Verified against the live endpoint:
// octet-stream returns 415, while both `application/vnd.github+json` and `*/*`
// return the 302 to codeload that this transport already follows.
//
// `*/*` rather than the JSON type, because a download genuinely will take
// whatever the route serves and saying so is true; asking for JSON to obtain a
// zip works only by convention and would be the next thing to break quietly.
// The metadata calls keep the JSON type, which is what those routes serve and
// what pins the API version.
export function githubRequestHead(apiPath, host, { binary = false } = {}) {
return `GET ${apiPath} HTTP/1.1\r\nHost: ${host}\r\nUser-Agent: dojobay-update-check\r\n` +
`Accept: ${binary ? "*/*" : "application/vnd.github+json"}\r\n` +
`Accept-Encoding: identity\r\nConnection: close\r\n\r\n`;
}
// One HTTPS GET over the Tor SOCKS proxy. Handles chunked replies, returns
// both a text `body` and a raw `bodyBuf`, and follows GitHub's redirect from
// api.github.com to codeload for zipball downloads (binary: true) up to a few
// hops. Host is derived per hop so codeload.github.com is reached correctly.
/**
* @param {string} apiPath
* @param {{ proxyHost?: string, proxyPort?: number, timeoutMs?: number,
* binary?: boolean, _host?: string, _hops?: number }} [opts]
*/
export async function githubGet(apiPath, { proxyHost, proxyPort, timeoutMs = 30000, binary = false, _host = API_HOST, _hops = 0 } = {}) {
const raw = await socks5Connect(proxyHost, proxyPort, _host, 443, timeoutMs);
const res = await new Promise((resolve, reject) => {
const timer = setTimeout(() => { socket.destroy(); reject(new Error("timeout")); }, timeoutMs);
const socket = tls.connect({ socket: raw, servername: _host }, () => {
socket.write(githubRequestHead(apiPath, _host, { binary }));
});
const chunks = [];
socket.on("data", (d) => chunks.push(d));
socket.on("error", (e) => { clearTimeout(timer); reject(e); });
socket.on("close", () => {
clearTimeout(timer);
try {
const all = Buffer.concat(chunks);
const headEnd = all.indexOf("\r\n\r\n");
if (headEnd < 0) return reject(new Error("malformed reply"));
const headText = all.subarray(0, headEnd).toString("latin1");
const m = headText.match(/^HTTP\/1\.[01] (\d{3})/);
if (!m) return reject(new Error("malformed reply"));
const status = +m[1];
const locM = headText.match(/\r\nlocation:\s*([^\r\n]+)/i);
let bodyBuf = all.subarray(headEnd + 4);
if (/transfer-encoding:\s*chunked/i.test(headText)) {
const parts = []; let p = 0;
for (;;) {
const nl = bodyBuf.indexOf("\r\n", p);
if (nl < 0) break;
const size = parseInt(bodyBuf.subarray(p, nl).toString("latin1"), 16);
if (!size) break;
parts.push(bodyBuf.subarray(nl + 2, nl + 2 + size));
p = nl + 2 + size + 2;
}
bodyBuf = Buffer.concat(parts);
}
resolve({ status, location: locM ? locM[1].trim() : null, bodyBuf });
} catch (e) { reject(e); }
});
});
if ([301, 302, 307, 308].includes(res.status) && res.location && _hops < 4) {
const u = new URL(res.location);
return githubGet(u.pathname + u.search, { proxyHost, proxyPort, timeoutMs, binary, _host: u.hostname, _hops: _hops + 1 });
}
return { status: res.status, body: res.bodyBuf.toString("utf8"), bodyBuf: res.bodyBuf };
}
/** What to tell an operator when GitHub refuses a request.
*
* A bare "HTTP 403" reads as something broken here, and it is not: GitHub
* allows sixty unauthenticated requests an hour PER IP ADDRESS, and a Tor exit
* is one address shared with everyone else using it, so an instance can arrive
* at an exit whose hour was already spent by strangers. Observed on a live
* instance: limit 60, remaining 0, used 60, for an exit nobody here had made a
* single request through.
*
* It clears by itself when the window rolls over, and often sooner on a new
* circuit, since the limit follows the exit rather than the client. Saying so
* is the difference between an operator waiting and an operator going looking
* for a fault that does not exist. 429 is included because GitHub uses it for
* secondary limits and means the same thing to a reader.
*/
export function githubRefusal(what, status) {
if (status === 403 || status === 429) {
// No call-site prefix. Which of the three requests hit the limit is of no
// use to an operator, and "compare: GitHub is rate-limiting..." reads as
// though "compare" were a thing that had gone wrong.
return `GitHub is rate-limiting this Tor exit (HTTP ${status}). `
+ "The limit is per exit address and shared with every other user of it, so it clears on its "
+ "own within the hour, usually sooner on a new circuit. Updating from a peer .onion does not "
+ "touch GitHub and works meanwhile.";
}
return `${what}: HTTP ${status}`;
}
export async function checkUpdates({ repo = GITHUB_REPO, transport = githubGet, cfg = {} } = {}) {
const verPath = path.join(process.env.PUBLIC_DATA_DIR || path.join(ROOT, "data"), "version.json");
const version = JSON.parse(await readFile(verPath, "utf8"));
if (!version.commit || version.commit === "dev") throw new Error("local version.json has no deployed commit");
const cmp = await transport(`/repos/${repo}/compare/${encodeURIComponent(version.commit)}...main`, cfg);
if (cmp.status !== 200) throw new Error(githubRefusal("compare", cmp.status));
const compare = JSON.parse(cmp.body);
const rel = await transport(`/repos/${repo}/releases?per_page=30`, cfg);
if (rel.status !== 200) throw new Error(githubRefusal("releases", rel.status));
const releases = JSON.parse(rel.body);
// Which release are we actually running?
//
// This used to count releases published after the local build timestamp,
// which is wrong in the ordinary case: a tag is created AFTER the commit it
// points at has been built and deployed, so an instance running the exact
// commit of the newest release always reported itself one release behind.
//
// Resolve each release's tag to a commit instead and compare identity. If our
// commit IS a released tag, the number of releases published after it is the
// honest answer (zero, when we are on the latest). Only when no tag matches do
// we fall back to the timestamp approximation, and say so.
let tagSha = new Map();
let tagsError = null;
try {
const tg = await transport(`/repos/${repo}/tags?per_page=100`, cfg);
if (tg.status === 200) {
for (const t of JSON.parse(tg.body)) {
if (t?.name && t?.commit?.sha) tagSha.set(t.name, String(t.commit.sha));
}
} else {
tagsError = githubRefusal("tag lookup", tg.status);
}
} catch (e) {
tagsError = "tag lookup: " + (e?.message || "failed");
}
// version.json carries a short commit, the API a full sha; match either way.
const sameCommit = (a, b) => {
if (!a || !b) return false;
const x = String(a).toLowerCase(), y = String(b).toLowerCase();
return x.startsWith(y) || y.startsWith(x);
};
const runningIndex = releases.findIndex((r) => sameCommit(tagSha.get(r.tag_name), version.commit));
const approximate = runningIndex < 0;
const builtAt = Date.parse(version.built || 0) || 0;
// Three states, and the third used to be reported as the second.
//
// matched our commit IS a released tag: the count is exact.
// no match we are on an untagged commit mid-cycle: the timestamp count
// is a fair approximation, because we are genuinely not on a
// release.
// no tag data we could not look tags up at all, usually because a shared
// Tor exit hit GitHub's rate limit. The timestamp count is
// then WORSE than saying nothing: a tag is always created
// after its commit was built, so an instance running the very
// newest release scores one behind. Report null instead.
const releasesBehind = !approximate ? runningIndex
: tagsError ? null
: releases.filter((r) => Date.parse(r.published_at || 0) > builtAt).length;
return {
commit: version.commit,
built: version.built || null,
commits_behind: compare.ahead_by ?? 0, // main is ahead of us by this many
status: compare.status || "unknown", // identical | behind | ahead | diverged
latest_release: releases[0] ? releases[0].tag_name : null,
/** The release we are running, when our commit is exactly a released tag. */
current_release: approximate ? null : releases[runningIndex].tag_name,
releases_behind: releasesBehind,
/** True when releases_behind is the timestamp guess rather than an identity
* match, which happens when the running commit is not itself a released
* tag (mid-cycle, or a local build). */
releases_behind_approx: approximate,
/** Why the release could not be identified, when it could not. */
releases_note: tagsError,
repo,
checked_at: new Date().toISOString(),
};
}
/** Whether an update check may be answered from the cache, and what to tell the
* operator if a forced check was refused.
*
* Three rules, and the third is the only interesting one. An ordinary request
* takes the cache while it is fresh, because six hours is right for an
* unattended check over Tor where GitHub rate limits shared exit nodes. A
* forced request goes out. A forced request inside the floor is answered from
* the cache with the wait attached rather than refused, because the operator
* asked what the state is and the honest answer is the last one known plus how
* stale it is.
*
* Pure, and separate from the route, so the floor can be tested without a
* reachable GitHub: the route only fills its cache on success, so an
* unreachable GitHub means the cached path is never taken and the floor never
* runs. A rule that cannot be exercised is a rule nobody has checked.
*/
export function updateCacheDecision({ cachedAt = null, now = Date.now(), forced = false,
forcedAt = 0, ttlMs = 6 * 3600 * 1000, floorMs = 60 * 1000 } = {}) {
const fresh = cachedAt !== null && now - cachedAt < ttlMs;
if (!fresh) return { serveCached: false, waitS: 0 };
if (!forced) return { serveCached: true, waitS: 0 };
const since = now - forcedAt;
if (forcedAt && since < floorMs) {
return { serveCached: true, waitS: Math.ceil((floorMs - since) / 1000) };
}
return { serveCached: false, waitS: 0 };
}