237 lines
12 KiB
JavaScript
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 };
|
|
}
|