Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: tests

on:
push:
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
node-selftests:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Syntax checks
shell: pwsh
run: |
node --check accel.mjs
node --check gh.mjs
node --check clash.mjs
node --check discover.mjs
node --check hostsio.mjs
node --check ip.mjs
node --check manifest.mjs
node --check pool.mjs
node --check probe.mjs
- name: Offline self-tests
shell: pwsh
run: |
node probe.mjs --selftest
node ip.mjs --selftest
node hostsio.mjs --selftest
node accel.mjs --selftest
node gh.mjs --selftest
node relay/test/middleware.test.mjs
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

面向 Windows(Git Bash / PowerShell 皆可)。两层互补,各自解决实测确认不同的故障面:

## 稳定性策略(2026-10)

- **不改 Clash 配置**。本仓库只管理自己的 hosts 自动化和下载/克隆工具。
- **GitHub 主站与关键资源域不再固定静态 IP**:`github.com`、`gist.github.com`、`api.github.com`、`codeload.github.com`、`raw.githubusercontent.com`、`objects.githubusercontent.com`、`avatars.githubusercontent.com`、`github.githubassets.com` 一律使用当前 DNS。一次探测成功不能代表下一刻仍可用;写入全局 hosts 会把瞬时波动放大成整机访问故障。运行 `node accel.mjs` 会重建本工具标记块并移除旧的 GitHub 静态钉。
- **下载:健康镜像并发竞速 → 全部失败后官方 URL 无代理直连重试**。直连回退会清除本次子进程的代理环境变量,并使用 curl `--noproxy '*'`,不改变 Clash 或系统代理设置;只在完整文件成功后才替换输出文件。
- **克隆:镜像入口探测/重试 → 官方 GitHub URL 直连重试**。直连回退清除代理环境变量、显式清空 Git 的代理配置,而且不添加 `insteadOf` 镜像重写。若目标目录在运行前已存在,工具拒绝覆盖,不会因镜像失败删除已有文件。
- **边界说明**:镜像回退只适用于本工具的下载与 `git clone`。浏览器页面的透明故障切换必须由浏览器/系统路由层实现;在不改变 Clash、且浏览器仍配置为 GitHub 直连的前提下,任何仓库脚本都无法保证运营商链路、DNS 或 GitHub 上游永不故障。此次改动的目标是移除本工具主动造成的坏钉,并为本工具执行的下载/克隆补齐最终直连路线。


> **关于权限(2026-10-13 实测更新)**:写 `C:\Windows\System32\drivers\etc\hosts` 的**内容是可行的**——
> 前提是 hosts 文件上有一条本用户的显式 ACE(本机现状:`jingjiang:(M)`,另有目录继承的 SYSTEM/Administrators 项)。
> `drivers\etc\` **目录本身不可写**(用户在该目录无法创建/删除文件),所以"临时文件 + 原子 rename"的记录方式在这台机器上
Expand All @@ -14,19 +23,19 @@
| 层 | 文件 | 解决什么 |
|---|---|---|
| A 自适应 hosts | `accel.mjs`(+ `ip` / `probe` / `hostsio` 三个模块) | 头像、raw、归档包、npm/jsDelivr、PyPI、SourceForge、cdnjs/unpkg 等**能靠 IP 救**的通道:候选 IP 逐条本机 TLS 实测,只有确实更快才写入 |
| B 竞速取件 | `gh.mjs` | `github.com` 首跳、release 下载、`git clone` 这类**换 IP 无效**的通道:每次请求同时压多个入口,先出数据的胜出,停滞带 Range 换源续传 |
| B 竞速取件 | `gh.mjs` | 下载/`git clone` 并发竞速镜像;镜像全失败时强制尝试官方地址直连。不是浏览器透明代理 |

设计取向是**宁可不写,也不写坏**:写错的 hosts 条目比不写更糟(浏览器全站受影响),所以每条固定都要过"多轮实测 + 写入后真实解析复核"。复核若发现真实解析落到了**没钉的那一族**,先按族补钉(用另一族实测可用的候选)再复核第二轮,补不上才整域撤销。该判定(`planAfterLeak`)可用 `node accel.mjs --selftest` 离线回归。

v2(2026-10-13)把这套守护做细了:落地复核 = **系统解析整链检查 + 每族 `--resolve` 直检**,任一项失败先重试 1 次再判决;某一族直检失败只撤那一族(旧版只能整域撤)。判定拆进三个可离线回归的模块:`ip.mjs`(`net.isIP` 严格校验 + v6 前缀归一)、`probe.mjs`(解析/判定纯函数)、`hostsio.mjs`(备份+直写+读回的安全提交)。

## 覆盖哪些域(2026-10-13 实测口径)

**进 A 层的域 = 「换 IP 救得回来」+「有可信候选源」两个条件都满足**,不是列得越多越好:
**进 A 层的域 = 「换 IP 救得回来」+「有可信候选源」两个条件都满足**,不是列得越多越好。GitHub 自有核心域由代码级安全规则排除静态 hosts 固定,以免短暂成功的地址在下一轮失效:

| 分类 | 域 | 候选来源 |
|---|---|---|
| GitHub | `github.com`、`api.github.com`、`codeload.github.com`、`raw`/`objects`/`avatars`/`githubassets` | GitHub `/meta`(带缓存兜底)+ DoH + 第三方 hosts hint |
| GitHub 核心域 | `github.com`、`api.github.com`、`codeload.github.com`、`raw`/`objects`/`avatars`/`githubassets` | **不静态钉 hosts**;动态 DNS 负责解析,`gh.mjs` 负责下载/克隆镜像竞速与官方直连回退 |
| Cloudflare 前置 | `cdn.jsdelivr.net`、`registry.npmjs.org`、`sourceforge.net`、`downloads.sourceforge.net`、`cloudflare.com`、`cdnjs.cloudflare.com`、`unpkg.com` | Cloudflare `ips-v4`/`ips-v6`(官方)+ DoH |
| Fastly 前置 | `pypi.org`、`files.pythonhosted.org` | Fastly `public-ip-list`(官方)+ DoH |

Expand Down
86 changes: 83 additions & 3 deletions accel.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,65 @@ const REVERT = ARGV.includes('--revert')
const VERBOSE = ARGV.includes('-v')
const SELFTEST = ARGV.includes('--selftest')
const DOMRE = /^[a-z0-9._-]+$/i
// Critical GitHub web/resource endpoints must use current DNS. This code-level invariant prevents
// a briefly healthy IP pin turning into a machine-wide outage between scheduled maintenance runs.
const NEVER_PIN_DOMAINS = new Set([
'github.com', 'gist.github.com', 'api.github.com', 'codeload.github.com',
'raw.githubusercontent.com', 'objects.githubusercontent.com',
'avatars.githubusercontent.com', 'github.githubassets.com'
])

// Remove only GitHub core pins from our managed hosts block. This is safe even when a proxy
// fake-IP environment prevents fresh probes; retain unrelated CDN/package pins and hand-written lines.
function stripProtectedPinsFromBlock(block) {
const lines = String(block || '').split(/\r?\n/)
const start = lines.findIndex(function (line) { return line.trim() === MARK_S })
const end = lines.findIndex(function (line, i) { return i > start && line.trim() === MARK_E })
if (start < 0 || end < 0) return { block: block, changed: false }
let changed = false
const out = lines.slice(0, start + 1)
for (let i = start + 1; i < end; i++) {
const line = lines[i]
const trimmed = line.trim()
if (!trimmed || trimmed.charAt(0) === '#') { out.push(line); continue }
const fields = trimmed.split(/\s+/)
if (fields.length < 2) { out.push(line); continue }
const keep = fields.slice(1).filter(function (domain) {
return !NEVER_PIN_DOMAINS.has(domain.toLowerCase())
})
if (keep.length !== fields.length - 1) changed = true
if (keep.length) out.push(fields[0] + ' ' + keep.join(' '))
else if (keep.length === fields.length - 1) out.push(line)
}
out.push.apply(out, lines.slice(end))
return { block: out.join('\r\n'), changed: changed }
}
async function removeProtectedPinsFromHosts() {
if (DRY) return false
const cur = readHosts(CFG.hosts_path)
if (cur === null) return false
const start = cur.indexOf(MARK_S)
const end = cur.indexOf(MARK_E, start + MARK_S.length)
if (start < 0 || end < 0) return false
const managed = cur.slice(start, end + MARK_E.length)
const filtered = stripProtectedPinsFromBlock(managed)
if (!filtered.changed) return false
const result = await commitHosts({
hostsPath: CFG.hosts_path,
bdir: path.join(HERE, 'backups'),
block: filtered.block,
markS: MARK_S,
markE: MARK_E,
keep: 5
})
if (!result.ok) {
log('保护性清理 GitHub hosts 固定未成立:' + result.reason + (result.error ? ' ' + result.error : ''))
return false
}
await run('ipconfig.exe', ['/flushdns'], 8000)
log('保护性清理完成:仅移除本工具块内的 GitHub 核心域固定,保留其他域的固定')
return true
}

function now() { return new Date().toISOString().slice(11, 19) }
function log() {
Expand Down Expand Up @@ -374,7 +433,8 @@ async function main() {
const env = await fakeEnv()
if (env.active) {
log('环境:系统解析 ' + env.domain + ' -> ' + env.ip + ' = fake-ip(代理内核接管 DNS)')
log('==== 本轮跳过:hosts 层被架空,保留现有固定,不写也不撤(report.json 留上一轮) ====')
await removeProtectedPinsFromHosts()
log('==== 本轮跳过 IP 测速与选路;已尽力清理本工具块内的 GitHub 核心域旧固定,其他域固定保留 ====')
return
}
// 候选过期就顺手刷新一次(discover 自己也会判有效期;这里保证它不会永远不跑)。
Expand All @@ -401,7 +461,14 @@ async function main() {
: { up: false }, pool: { enabled: POOL.on }, domains: {} }
const chosen = []
for (const dom of Object.keys(CFG.domains)) {
if (CFG.deny.indexOf(dom) >= 0 || !DOMRE.test(dom)) continue
if (!DOMRE.test(dom)) continue
if (NEVER_PIN_DOMAINS.has(dom)) {
report.domains[dom] = { baseline: null, baseline6: null, pinned: null, pinned6: null,
reason: 'safety-no-pin:GitHub 核心域保留动态 DNS,避免间歇性 IP 固定影响主站/资源' }
log('保护性跳过 ', dom.padEnd(28), '不写入 hosts 固定(使用动态 DNS)')
continue
}
if (CFG.deny.indexOf(dom) >= 0) continue
const conf = CFG.domains[dom] || {}
const r = await evalDomain(dom, conf, remote)
if (r.pin4 || r.pin6) chosen.push({ dom: dom, ip: r.pin4, ip6: r.pin6, alt4: r.alt4, alt6: r.alt6, canary: conf.canary || '/' })
Expand Down Expand Up @@ -571,6 +638,19 @@ if (SELFTEST) {
if (!ok) bad++
console.log(' ' + (ok ? 'PASS' : 'FAIL') + ' 落地码 ' + dom + ' code=' + code + ' -> ' + got + (ok ? '' : ',期望 ' + want))
}
// Stability invariant: critical GitHub endpoints never enter the hosts pin set.
const protectedDomains = ['github.com', 'gist.github.com', 'api.github.com', 'codeload.github.com', 'raw.githubusercontent.com', 'objects.githubusercontent.com', 'avatars.githubusercontent.com', 'github.githubassets.com']
const protectedOk = protectedDomains.every(function (d) { return NEVER_PIN_DOMAINS.has(d) })
if (!protectedOk) bad++
console.log(' ' + (protectedOk ? 'PASS' : 'FAIL') + ' GitHub 核心域禁止静态 hosts 固定' + (protectedOk ? '' : '(缺失保护域名)'))
const sampleBlock = [MARK_S, '# generated', '1.2.3.4 github.com other.example', '2.2.2.2 raw.githubusercontent.com', '3.3.3.3 cdn.jsdelivr.net', MARK_E].join('\r\n')
const cleanedBlock = stripProtectedPinsFromBlock(sampleBlock)
const cleanupOk = cleanedBlock.changed && cleanedBlock.block.indexOf('github.com') < 0 &&
cleanedBlock.block.indexOf('raw.githubusercontent.com') < 0 &&
cleanedBlock.block.indexOf('1.2.3.4 other.example') >= 0 &&
cleanedBlock.block.indexOf('3.3.3.3 cdn.jsdelivr.net') >= 0
if (!cleanupOk) bad++
console.log(' ' + (cleanupOk ? 'PASS' : 'FAIL') + ' fake-IP 早退清理只移除核心域,保留其他域固定' + (cleanupOk ? '' : '(结果不符)'))
// 模块接线自检:ip.mjs 的严格校验确实被本进程用上(垃圾形式一律为 0)
const wireCases = [
['2606:50c0:8000::153', 6], ['104.17.208.5', 4], ['2606:50c0:8000:::1', 0], ['', 0]
Expand Down Expand Up @@ -602,7 +682,7 @@ if (SELFTEST) {
console.log(' ' + (ok ? 'PASS' : 'FAIL') + ' ' + name + ' -> take4=' + !!got.take4 + ' take6=' + !!got.take6 +
(ok ? '' : ',期望 take4=' + want4 + ' take6=' + want6))
}
const total = cases.length + codeCases.length + wireCases.length + pinCases.length
const total = cases.length + codeCases.length + wireCases.length + pinCases.length + 2
console.log(bad ? ' 自测失败 ' + bad + ' 项' : ' 自测 ' + total + '/' + total + ' 通过')
process.exit(bad ? 1 : 0)
}
Expand Down
8 changes: 8 additions & 0 deletions config.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,14 @@
"low_speed_limit": 1024,
"low_speed_time": 20
},
"direct_fallback": {
"enabled": true,
"retries": 2,
"clone_retries": 2,
"connect_timeout_s": 6,
"speed_limit_bps": 1024,
"speed_time_s": 30
},
"domains": {
"api.github.com": { "canary": "/zen" },
"raw.githubusercontent.com": { "canary": "/521xueweihan/GitHub520/main/hosts" },
Expand Down
Loading
Loading