Skip to content

Commit de63110

Browse files
committed
feat: secure system node catalog updates
1 parent c9dd72b commit de63110

14 files changed

Lines changed: 471 additions & 44 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: Refresh CDN cache
2+
3+
on:
4+
workflow_dispatch:
5+
push:
6+
branches:
7+
- main
8+
paths:
9+
- nodes.json
10+
11+
permissions:
12+
contents: read
13+
14+
jobs:
15+
refresh:
16+
runs-on: ubuntu-latest
17+
timeout-minutes: 15
18+
steps:
19+
- uses: actions/checkout@v4
20+
- uses: actions/setup-node@v4
21+
with:
22+
node-version: 22
23+
- name: Test cache refresh script
24+
run: node --test scripts/refresh-cdn-cache.test.mjs
25+
- name: Refresh CDN cache
26+
run: node scripts/refresh-cdn-cache.mjs

‎README.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,13 @@ npm run build:windows
5353

5454
Windows 需要先安装 Git for Windows。安装包默认按当前用户安装;WebView2 缺失时由安装器静默引导安装。产物位于 `src-tauri/target/x86_64-pc-windows-msvc/release/bundle/`。
5555

56-
系统节点目录源文件为仓库根目录的 `nodes.json`,主发布地址为 `https://cdn.jsdelivr.net/gh/DiscoverBox/gitboost@main/nodes.json`,主地址不可用时回退到 `https://cdn.jsdmirror.com/gh/DiscoverBox/gitboost@main/nodes.json`。文件只包含代理 URL 字符串,不包含 ID、名称、类型或转换规则。
56+
系统节点目录源文件为仓库根目录的 `nodes.json`,主发布地址为 `https://cdn.jsdelivr.net/gh/DiscoverBox/gitboost@main/nodes.json`,主地址不可用时回退到 `https://cdn.jsdmirror.cn/gh/DiscoverBox/gitboost@main/nodes.json`。文件使用 AES-256-GCM 加密,不直接包含代理域名;客户端解密并校验后才会更新本地缓存。由于解密密钥随开源客户端分发,这项措施用于避免静态目录直接暴露节点,并不用于抵抗客户端逆向。
57+
58+
更新系统节点时,先准备一个不提交到仓库的明文 URL 数组,再生成发布文件:
59+
60+
```bash
61+
node scripts/encrypt-nodes.mjs /path/to/plain-nodes.json nodes.json
62+
```
5763

5864
数据保存在系统的应用数据目录 `pro.gitboost.desktop` 下。`system-nodes.json` 保存最近一次有效的系统节点目录,`nodes.json` 只保存用户自定义节点。恢复操作只删除 GitBoost 自己注册的 `include.path` 并清空自己的重写规则,不修改任何仓库的 remote。
5965

‎nodes.json‎

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,5 @@
1-
[
2-
"https://fastgit.cc",
3-
"https://gh-proxy.com",
4-
"https://ghfast.top",
5-
"https://gh.api.99988866.xyz",
6-
"https://ghproxy.net",
7-
"https://gh.llkk.cc",
8-
"https://ghproxy.cc",
9-
"https://ghpr.cc",
10-
"https://gitdl.cn",
11-
"https://ghproxy.cn",
12-
"https://gh.6yit.com",
13-
"https://gh.tryxd.cn",
14-
"https://gh.pylas.xyz",
15-
"https://gh.nxnow.top",
16-
"https://gh.sixyin.com",
17-
"https://mirror.v2gh.com",
18-
"https://github-mirror.us.kg",
19-
"https://proxy.yaoyaoling.net"
20-
]
1+
{
2+
"version": 1,
3+
"nonce": "/5QWOcDIkXDma5gR",
4+
"ciphertext": "UbOtSlWJIqvqdZrfvaeP7o5q7o8KYp0XwXKQ/40RIFoU5bQoCA/oxQC2AhcF/ccRfh90VEJJeUANKRfcEnmhCsa+RBwXptM+d2Ede8DTKnIVuwiJSpHiOKbaxDR0Ypmd2LzCU9QDFh6KJAVcx0v4MujHJiuis4qR6//1e4XWIxeoOWah36zU6X/kEWx8E2skaLozU1cZEKRz2cseCiGr/uNDrVGiczXF1zjk6cH1u80nGHkzsjLK+XGls64gtm9KNfwdl6g0ojwUdd2n/eMyqMptSEv28gBo8M94esFp8f9juWE0GAnSzGI50XBRyzSRp9J+2PvVEF2C0xwRYVpRoVWCoR1TRajKTP5ellQPZJD9wilODUAyt+jeY+sPPCCK4kMW6rZn7XYijqW4rZ/C59zzs33q6onXrNh7BYr1Q9oUssBWRlQ8B+l/UI3jZqj5vRs1uIpo0tRAQ7hg6vRZ4/UIqyK8Q5U7nnP/Hmu7yE2njnlOXoVqGRBnVVTZSMarHAXrwGdtc/fSbaEQtsAZ79oSAVaFY1KUwK+zS9HzlXi4wlpVonU/jqpof4wLK4k+JQ82"
5+
}

‎scripts/encrypt-nodes.mjs‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { readFileSync, writeFileSync } from "node:fs";
2+
import { createCipheriv, randomBytes } from "node:crypto";
3+
4+
const key = Buffer.from(
5+
"2dbf43f277a10979cb9e06e72b0ddb710cb60d23b1b7c4654aa1f673467bd969",
6+
"hex",
7+
);
8+
const [inputPath, outputPath] = process.argv.slice(2);
9+
10+
if (!inputPath || !outputPath) {
11+
console.error("用法: node scripts/encrypt-nodes.mjs <明文节点.json> <输出.json>");
12+
process.exit(1);
13+
}
14+
15+
const nodes = JSON.parse(readFileSync(inputPath, "utf8"));
16+
if (!Array.isArray(nodes) || !nodes.every((node) => typeof node === "string")) {
17+
throw new Error("明文节点文件必须是 URL 字符串数组");
18+
}
19+
20+
const nonce = randomBytes(12);
21+
const cipher = createCipheriv("aes-256-gcm", key, nonce);
22+
const ciphertext = Buffer.concat([
23+
cipher.update(JSON.stringify(nodes), "utf8"),
24+
cipher.final(),
25+
cipher.getAuthTag(),
26+
]);
27+
const catalog = {
28+
version: 1,
29+
nonce: nonce.toString("base64"),
30+
ciphertext: ciphertext.toString("base64"),
31+
};
32+
33+
writeFileSync(outputPath, `${JSON.stringify(catalog, null, 2)}\n`);

‎scripts/refresh-cdn-cache.mjs‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { readFile } from "node:fs/promises";
2+
import { pathToFileURL } from "node:url";
3+
4+
const sleep = (milliseconds) =>
5+
new Promise((resolve) => setTimeout(resolve, milliseconds));
6+
7+
export async function refreshCdnCache({
8+
repository,
9+
ref,
10+
filePath = "nodes.json",
11+
readFileImpl = readFile,
12+
fetchImpl = fetch,
13+
wait = sleep,
14+
maxAttempts = 21,
15+
pollIntervalMs = 30_000,
16+
log = console.log,
17+
}) {
18+
const cdnPath = `/gh/${repository}@${ref}/${filePath}`;
19+
const purgeUrl = `https://purge.jsdelivr.net${cdnPath}`;
20+
const jsDelivrUrl = `https://cdn.jsdelivr.net${cdnPath}`;
21+
const mirrorUrl = `https://cdn.jsdmirror.cn${cdnPath}`;
22+
const expectedContent = await readFileImpl(filePath);
23+
24+
const purgeResponse = await fetchImpl(purgeUrl);
25+
if (!purgeResponse.ok) {
26+
throw new Error(`jsDelivr cache purge failed: HTTP ${purgeResponse.status}`);
27+
}
28+
29+
const purgeResult = await purgeResponse.json();
30+
const providers = Object.values(
31+
purgeResult.paths?.[cdnPath]?.providers ?? {},
32+
);
33+
if (
34+
purgeResult.status !== "finished" ||
35+
providers.length === 0 ||
36+
providers.some((succeeded) => !succeeded)
37+
) {
38+
throw new Error(`jsDelivr cache purge failed: ${JSON.stringify(purgeResult)}`);
39+
}
40+
41+
log(`jsDelivr cache purged: ${jsDelivrUrl}`);
42+
43+
const contentMatches = async (url) => {
44+
try {
45+
const response = await fetchImpl(url, { cache: "no-store" });
46+
if (!response.ok) {
47+
return false;
48+
}
49+
const content = Buffer.from(await response.arrayBuffer());
50+
return content.equals(expectedContent);
51+
} catch (error) {
52+
log(`Cache check failed for ${url}: ${error.message}`);
53+
return false;
54+
}
55+
};
56+
57+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
58+
const [jsDelivrReady, mirrorReady] = await Promise.all([
59+
contentMatches(jsDelivrUrl),
60+
contentMatches(mirrorUrl),
61+
]);
62+
63+
if (jsDelivrReady && mirrorReady) {
64+
log(`CDN cache refreshed: ${mirrorUrl}`);
65+
return;
66+
}
67+
68+
if (attempt === maxAttempts) {
69+
throw new Error(
70+
`CDN cache did not refresh after ${maxAttempts} checks ` +
71+
`(jsDelivr: ${jsDelivrReady}, JSDMirror: ${mirrorReady})`,
72+
);
73+
}
74+
75+
log(
76+
`Waiting for CDN refresh (${attempt}/${maxAttempts}): ` +
77+
`jsDelivr=${jsDelivrReady}, JSDMirror=${mirrorReady}`,
78+
);
79+
await wait(pollIntervalMs);
80+
}
81+
}
82+
83+
async function main() {
84+
await refreshCdnCache({
85+
repository: process.env.GITHUB_REPOSITORY ?? "DiscoverBox/gitboost",
86+
ref: process.env.GITHUB_REF_NAME ?? "main",
87+
});
88+
}
89+
90+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
91+
main().catch((error) => {
92+
console.error(error.message);
93+
process.exitCode = 1;
94+
});
95+
}

‎scripts/refresh-cdn-cache.test.mjs‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
4+
import { refreshCdnCache } from "./refresh-cdn-cache.mjs";
5+
6+
const repository = "DiscoverBox/gitboost";
7+
const ref = "main";
8+
const cdnPath = `/gh/${repository}@${ref}/nodes.json`;
9+
10+
test("purges jsDelivr and waits for JSDMirror to match nodes.json", async () => {
11+
const expectedContent = '{"version":1}\n';
12+
13+
let mirrorChecks = 0;
14+
const requestedUrls = [];
15+
const fetchImpl = async (url) => {
16+
requestedUrls.push(url);
17+
if (url.startsWith("https://purge.jsdelivr.net/")) {
18+
return Response.json({
19+
status: "finished",
20+
paths: { [cdnPath]: { providers: { CF: true, FY: true } } },
21+
});
22+
}
23+
if (url.startsWith("https://cdn.jsdmirror.cn/")) {
24+
mirrorChecks += 1;
25+
return new Response(mirrorChecks === 1 ? "stale" : expectedContent);
26+
}
27+
return new Response(expectedContent);
28+
};
29+
30+
await refreshCdnCache({
31+
repository,
32+
ref,
33+
readFileImpl: async () => Buffer.from(expectedContent),
34+
fetchImpl,
35+
wait: async () => {},
36+
maxAttempts: 2,
37+
pollIntervalMs: 0,
38+
log: () => {},
39+
});
40+
41+
assert.equal(mirrorChecks, 2);
42+
assert.equal(requestedUrls[0], `https://purge.jsdelivr.net${cdnPath}`);
43+
});
44+
45+
test("fails when a jsDelivr provider does not purge the cache", async () => {
46+
await assert.rejects(
47+
refreshCdnCache({
48+
repository,
49+
ref,
50+
readFileImpl: async () => Buffer.from("[]\n"),
51+
fetchImpl: async () =>
52+
Response.json({
53+
status: "finished",
54+
paths: { [cdnPath]: { providers: { CF: true, FY: false } } },
55+
}),
56+
log: () => {},
57+
}),
58+
/jsDelivr cache purge failed/,
59+
);
60+
});

0 commit comments

Comments
 (0)