feat: establish NekoNest Cloud control and relay
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
export const DAEMON_RELEASE_POLICY = Object.freeze({
|
||||
repository: "klarkxy/nekonest",
|
||||
minimumVersion: "0.2.6",
|
||||
assets: Object.freeze([
|
||||
Object.freeze({
|
||||
platform: "windows",
|
||||
architecture: "amd64",
|
||||
label: "Windows 10/11 · x64",
|
||||
filename: "nekonest-daemon-windows-amd64.zip",
|
||||
checksumEnv: "NEKONEST_CLOUD_DAEMON_WINDOWS_AMD64_SHA256",
|
||||
}),
|
||||
Object.freeze({
|
||||
platform: "linux",
|
||||
architecture: "amd64",
|
||||
label: "Linux · x86_64",
|
||||
filename: "nekonest-daemon-linux-amd64.tar.gz",
|
||||
checksumEnv: "NEKONEST_CLOUD_DAEMON_LINUX_AMD64_SHA256",
|
||||
}),
|
||||
Object.freeze({
|
||||
platform: "linux",
|
||||
architecture: "arm64",
|
||||
label: "Linux · arm64",
|
||||
filename: "nekonest-daemon-linux-arm64.tar.gz",
|
||||
checksumEnv: "NEKONEST_CLOUD_DAEMON_LINUX_ARM64_SHA256",
|
||||
}),
|
||||
]),
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { DAEMON_RELEASE_POLICY } from "./daemon-policy.mjs";
|
||||
|
||||
export const MINIMUM_CLOUD_DAEMON_VERSION = DAEMON_RELEASE_POLICY.minimumVersion;
|
||||
|
||||
export type StableVersion = [major: number, minor: number, patch: number];
|
||||
|
||||
export type ReportedDaemonVersion =
|
||||
| { state: "unreported"; version: null }
|
||||
| { state: "invalid"; version: null }
|
||||
| { state: "incompatible"; version: string }
|
||||
| { state: "compatible"; version: string };
|
||||
|
||||
export function parseStableVersion(value: string): StableVersion | null {
|
||||
const version = value.trim();
|
||||
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(version);
|
||||
if (!match) return null;
|
||||
const parts = match.slice(1).map(Number);
|
||||
if (parts.some((part) => !Number.isSafeInteger(part))) return null;
|
||||
return parts as StableVersion;
|
||||
}
|
||||
|
||||
export function compareStableVersions(left: StableVersion, right: StableVersion): number {
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
if (left[index] !== right[index]) return left[index] - right[index];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function classifyReportedDaemonVersion(value: string | undefined): ReportedDaemonVersion {
|
||||
const version = value?.trim() ?? "";
|
||||
if (!version) return { state: "unreported", version: null };
|
||||
|
||||
const parsed = parseStableVersion(version);
|
||||
const minimum = parseStableVersion(MINIMUM_CLOUD_DAEMON_VERSION);
|
||||
if (!parsed || !minimum) return { state: "invalid", version: null };
|
||||
if (compareStableVersions(parsed, minimum) < 0) {
|
||||
return { state: "incompatible", version };
|
||||
}
|
||||
return { state: "compatible", version };
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { DAEMON_RELEASE_POLICY } from "./daemon-policy.mjs";
|
||||
|
||||
const CHECKSUMS_FILE = "checksums.txt";
|
||||
const API_MAX_BYTES = 2 * 1024 * 1024;
|
||||
const CHECKSUMS_MAX_BYTES = 64 * 1024;
|
||||
const ASSET_MAX_BYTES = 128 * 1024 * 1024;
|
||||
|
||||
export class ReleaseVerificationError extends Error {
|
||||
constructor(code, message) {
|
||||
super(message);
|
||||
this.name = "ReleaseVerificationError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function fail(code, message) {
|
||||
throw new ReleaseVerificationError(code, message);
|
||||
}
|
||||
|
||||
function versionTuple(value) {
|
||||
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value);
|
||||
if (!match) return null;
|
||||
const parts = match.slice(1).map(Number);
|
||||
return parts.every(Number.isSafeInteger) ? parts : null;
|
||||
}
|
||||
|
||||
function compareVersions(left, right) {
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
if (left[index] !== right[index]) return left[index] - right[index];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function parseReleaseTag(rawTag) {
|
||||
const tag = String(rawTag ?? "").trim();
|
||||
const match = /^v(\d+\.\d+\.\d+)$/.exec(tag);
|
||||
if (!match) fail("invalid_release_tag", "tag 必须是稳定版本 vX.Y.Z");
|
||||
const version = match[1];
|
||||
const current = versionTuple(version);
|
||||
const minimum = versionTuple(DAEMON_RELEASE_POLICY.minimumVersion);
|
||||
if (!current || !minimum || compareVersions(current, minimum) < 0) {
|
||||
fail(
|
||||
"incompatible_release_version",
|
||||
`Cloud daemon 版本不得低于 v${DAEMON_RELEASE_POLICY.minimumVersion}`,
|
||||
);
|
||||
}
|
||||
return { tag, version };
|
||||
}
|
||||
|
||||
function sha256(bytes) {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
function githubHeaders(token, accept = "application/vnd.github+json") {
|
||||
return {
|
||||
accept,
|
||||
"user-agent": "nekonest-cloud-release-verifier",
|
||||
"x-github-api-version": "2022-11-28",
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchBytes(fetchImpl, url, options) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl(url, {
|
||||
headers: githubHeaders(options.authenticated ? options.token : undefined, options.accept),
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
} catch {
|
||||
fail("release_fetch_failed", `${options.label} 请求失败`);
|
||||
}
|
||||
if (!response?.ok) {
|
||||
fail("release_fetch_rejected", `${options.label} 返回 HTTP ${response?.status ?? "unknown"}`);
|
||||
}
|
||||
const declared = response.headers.get("content-length");
|
||||
if (declared && (!/^\d+$/.test(declared) || Number(declared) > options.maxBytes)) {
|
||||
fail("release_response_too_large", `${options.label} 超过大小上限`);
|
||||
}
|
||||
let bytes;
|
||||
try {
|
||||
bytes = new Uint8Array(await response.arrayBuffer());
|
||||
} catch {
|
||||
fail("release_response_read_failed", `${options.label} 响应读取失败`);
|
||||
}
|
||||
if (bytes.byteLength > options.maxBytes) {
|
||||
fail("release_response_too_large", `${options.label} 超过大小上限`);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function exactDownloadUrl(tag, filename) {
|
||||
return `https://github.com/${DAEMON_RELEASE_POLICY.repository}/releases/download/${tag}/${filename}`;
|
||||
}
|
||||
|
||||
function validateAsset(record, tag, filename) {
|
||||
if (!record || typeof record !== "object") fail("release_asset_missing", `缺少 ${filename}`);
|
||||
if (record.name !== filename || record.browser_download_url !== exactDownloadUrl(tag, filename)) {
|
||||
fail("release_asset_url_mismatch", `${filename} 不是精确 tag 的官方资产`);
|
||||
}
|
||||
if (
|
||||
record.state !== "uploaded" ||
|
||||
!Number.isSafeInteger(record.size) ||
|
||||
record.size < 1 ||
|
||||
record.size > ASSET_MAX_BYTES
|
||||
) {
|
||||
fail("release_asset_size_invalid", `${filename} 的资产大小无效`);
|
||||
}
|
||||
const digest = String(record.digest ?? "").toLowerCase();
|
||||
if (!/^sha256:[0-9a-f]{64}$/.test(digest)) {
|
||||
fail("release_asset_digest_missing", `${filename} 缺少 GitHub SHA-256 digest`);
|
||||
}
|
||||
return { ...record, digest: digest.slice("sha256:".length) };
|
||||
}
|
||||
|
||||
function indexRequiredAssets(release, tag) {
|
||||
if (!Array.isArray(release.assets)) fail("release_assets_invalid", "Release assets 不是数组");
|
||||
const requiredNames = [
|
||||
...DAEMON_RELEASE_POLICY.assets.map((asset) => asset.filename),
|
||||
CHECKSUMS_FILE,
|
||||
];
|
||||
const indexed = new Map();
|
||||
for (const name of requiredNames) {
|
||||
const matches = release.assets.filter((asset) => asset?.name === name);
|
||||
if (matches.length !== 1) {
|
||||
fail(
|
||||
matches.length === 0 ? "release_asset_missing" : "release_asset_duplicate",
|
||||
`${name} 必须恰好出现一次`,
|
||||
);
|
||||
}
|
||||
indexed.set(name, validateAsset(matches[0], tag, name));
|
||||
}
|
||||
return indexed;
|
||||
}
|
||||
|
||||
function parseChecksums(bytes) {
|
||||
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
const checksums = new Map();
|
||||
for (const rawLine of text.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
const match = /^([0-9a-fA-F]{64})\s+\*?([^\s]+)$/.exec(line);
|
||||
if (!match) fail("checksums_format_invalid", "checksums.txt 包含无效行");
|
||||
const filename = match[2];
|
||||
if (checksums.has(filename)) fail("checksums_duplicate", `${filename} 摘要重复`);
|
||||
checksums.set(filename, match[1].toLowerCase());
|
||||
}
|
||||
return checksums;
|
||||
}
|
||||
|
||||
export async function verifyDaemonRelease(input) {
|
||||
const { tag, version } = parseReleaseTag(input.tag);
|
||||
const fetchImpl = input.fetchImpl ?? globalThis.fetch;
|
||||
if (typeof fetchImpl !== "function") fail("fetch_unavailable", "当前 Node 不支持 fetch");
|
||||
const apiUrl = `https://api.github.com/repos/${DAEMON_RELEASE_POLICY.repository}/releases/tags/${tag}`;
|
||||
const apiBytes = await fetchBytes(fetchImpl, apiUrl, {
|
||||
token: input.token,
|
||||
authenticated: true,
|
||||
label: "GitHub Release API",
|
||||
maxBytes: API_MAX_BYTES,
|
||||
});
|
||||
let release;
|
||||
try {
|
||||
release = JSON.parse(new TextDecoder().decode(apiBytes));
|
||||
} catch {
|
||||
fail("release_json_invalid", "GitHub Release API 返回了无效 JSON");
|
||||
}
|
||||
const publishedAt = new Date(release?.published_at ?? "");
|
||||
if (
|
||||
release?.tag_name !== tag ||
|
||||
release?.draft !== false ||
|
||||
release?.prerelease !== false ||
|
||||
release?.html_url !== `https://github.com/${DAEMON_RELEASE_POLICY.repository}/releases/tag/${tag}` ||
|
||||
!Number.isFinite(publishedAt.getTime())
|
||||
) {
|
||||
fail("release_identity_invalid", "Release 必须是精确 tag 的公开稳定版本");
|
||||
}
|
||||
|
||||
const assetsByName = indexRequiredAssets(release, tag);
|
||||
const checksumAsset = assetsByName.get(CHECKSUMS_FILE);
|
||||
const checksumsBytes = await fetchBytes(fetchImpl, checksumAsset.browser_download_url, {
|
||||
token: input.token,
|
||||
label: CHECKSUMS_FILE,
|
||||
accept: "application/octet-stream",
|
||||
maxBytes: CHECKSUMS_MAX_BYTES,
|
||||
});
|
||||
if (checksumsBytes.byteLength !== checksumAsset.size || sha256(checksumsBytes) !== checksumAsset.digest) {
|
||||
fail("checksums_asset_mismatch", "checksums.txt 大小或 GitHub digest 不匹配");
|
||||
}
|
||||
const checksums = parseChecksums(checksumsBytes);
|
||||
|
||||
const verifiedAssets = [];
|
||||
for (const policyAsset of DAEMON_RELEASE_POLICY.assets) {
|
||||
const releaseAsset = assetsByName.get(policyAsset.filename);
|
||||
const expected = checksums.get(policyAsset.filename);
|
||||
if (!expected) fail("checksums_entry_missing", `${policyAsset.filename} 没有摘要`);
|
||||
if (releaseAsset.digest !== expected) {
|
||||
fail("release_digest_mismatch", `${policyAsset.filename} 的 API digest 与清单不一致`);
|
||||
}
|
||||
const bytes = await fetchBytes(fetchImpl, releaseAsset.browser_download_url, {
|
||||
token: input.token,
|
||||
label: policyAsset.filename,
|
||||
accept: "application/octet-stream",
|
||||
maxBytes: ASSET_MAX_BYTES,
|
||||
});
|
||||
if (bytes.byteLength !== releaseAsset.size) {
|
||||
fail("release_asset_size_mismatch", `${policyAsset.filename} 实际大小不匹配`);
|
||||
}
|
||||
if (sha256(bytes) !== expected) {
|
||||
fail("release_asset_hash_mismatch", `${policyAsset.filename} 实际 SHA-256 不匹配`);
|
||||
}
|
||||
verifiedAssets.push({
|
||||
...policyAsset,
|
||||
sha256: expected,
|
||||
size: bytes.byteLength,
|
||||
downloadUrl: releaseAsset.browser_download_url,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
repository: DAEMON_RELEASE_POLICY.repository,
|
||||
tag,
|
||||
version,
|
||||
releasePageUrl: release.html_url,
|
||||
releaseBaseUrl: `https://github.com/${DAEMON_RELEASE_POLICY.repository}/releases/download/${tag}`,
|
||||
checksumsUrl: checksumAsset.browser_download_url,
|
||||
checksumsSha256: checksumAsset.digest,
|
||||
publishedAt: publishedAt.toISOString(),
|
||||
assets: verifiedAssets,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatCloudReleaseEnvironment(verified) {
|
||||
const lines = [
|
||||
`NEKONEST_CLOUD_DAEMON_RELEASE_VERSION=${verified.version}`,
|
||||
`NEKONEST_CLOUD_DAEMON_RELEASE_BASE_URL=${verified.releaseBaseUrl}`,
|
||||
];
|
||||
for (const asset of verified.assets) lines.push(`${asset.checksumEnv}=${asset.sha256}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
Reference in New Issue
Block a user