ghprobe 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md ADDED
@@ -0,0 +1,525 @@
1
+ # ghprobe
2
+
3
+ [![ci](https://github.com/zmw-zmw/ghprobe/actions/workflows/ci.yml/badge.svg)](https://github.com/zmw-zmw/ghprobe/actions/workflows/ci.yml)
4
+ [![License: GPL-3.0-only](https://img.shields.io/badge/license-GPL--3.0--only-blue.svg)](LICENSE)
5
+ [![Node.js](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](package.json)
6
+ [![Platform](https://img.shields.io/badge/platform-Windows-0078d6.svg)](README.en.md#requirements-and-faq)
7
+
8
+ English | [简体中文](README.md)
9
+
10
+ **A Windows command-line tool that speeds up direct connections to GitHub: it measures the IPs that actually work on *your* network right now, writes them into your hosts file, and swaps them out automatically when they die. No proxy, no system proxy changes, no traffic hijacking.**
11
+
12
+ The problem it solves is concrete: GitHub direct connections are sometimes fast, sometimes dead, `git clone` / `git push` hang or time out, and the hosts list you copied off the internet is a static snapshot that nobody maintains — when an IP dies, nobody notices. ghprobe moves "finding a usable IP" onto your own machine, automates it, and gives it self-healing.
13
+
14
+ - **Windows** only (writing the system hosts file, UAC elevation, and registering a scheduled task are all Windows-only capabilities)
15
+ - Touches **the hosts file only** — installs no proxy / driver / service
16
+ - Requires **Node.js ≥ 18**
17
+ - License **GPL-3.0-only**, author **zmw**, repository <https://github.com/zmw-zmw/ghprobe>
18
+
19
+ ## Quick start
20
+
21
+ ```bash
22
+ npm install -g ghprobe
23
+ ghprobe install # probe once + write hosts + register the per-minute health check (a single UAC prompt)
24
+ ghprobe uninstall # stop: remove the scheduled task and restore hosts
25
+ ```
26
+
27
+ Ready to use right away — you never touch the command line again. Full commands and configuration are in section 4, "Usage".
28
+
29
+ ---
30
+
31
+ ## 1. What it is
32
+
33
+ ### What it actually does
34
+
35
+ In one sentence: **ghprobe uses local probing to pick the fastest, most usable IP for each GitHub-related domain, and writes a self-maintaining hosts block.**
36
+
37
+ A full round is: confirm the machine is online (offline → skip) → resolve candidate IPs via local DoH + the built-in pool (auto-refreshed from the official meta once a day) → run a "three-stage probe" on every candidate → rank by latency → write the best IP plus one spare per domain into hosts. After that, a scheduled task health-checks periodically and re-ranks to swap in fresh IPs when the old ones die.
38
+
39
+ It provides **no** proxy, tunnel, traffic forwarding, or protocol evasion, and it does **not** try to "break through" anything; as long as the path is reachable in the first place, it does three things: **prefer good IPs, reject false-alive nodes, and replace IPs automatically when they fail.**
40
+
41
+ ### Core technical points
42
+
43
+ **① Local DoH resolution, bypassing system-DNS poisoning**
44
+
45
+ Candidates do not come from system DNS (which may be poisoned); they come over DoH (RFC 8484 wire format, with no UDP/53 path at all). The defaults are endpoints verified reachable from mainland China: Alibaba DNS (`223.5.5.5` / `223.6.6.6`) and DNSPod (`1.12.12.12`).
46
+
47
+ Key detail: **DoH endpoints are reached by IP directly, with SNI validation.** If reaching a DoH endpoint itself required resolving its hostname first, you would fall into the deadlock of "resolving the DoH hostname requires resolving first." So the config stores the endpoint's IP, and a `sni` field carries the service hostname for certificate validation (RFC 6066 forbids using an IP as SNI), for example:
48
+
49
+ ```yaml
50
+ doh:
51
+ - { name: alidns1, host: 223.5.5.5, sni: dns.alidns.com, path: /dns-query }
52
+ - { name: alidns2, host: 223.6.6.6, sni: dns.alidns.com, path: /dns-query }
53
+ - { name: dnspod, host: 1.12.12.12, sni: doh.pub, path: /dns-query }
54
+ ```
55
+
56
+ Multiple endpoints are queried concurrently; successful answers are unioned and de-duplicated, then a built-in IP pool adds more candidates.
57
+
58
+ **② Three-stage probe: only an IP that passes all three is "alive"**
59
+
60
+ For each `(domain, candidate IP)`:
61
+
62
+ 1. **TCP RTT** — open a connection on port 443 and measure baseline latency.
63
+ 2. **TLS handshake + certificate SAN validation** — `rejectUnauthorized: true`, with `servername` set to the target domain (SNI). The certificate must contain the target domain; DNS poisoning cannot forge a valid certificate.
64
+ 3. **HTTP application-layer final check** — send a real `GET` with `Host: <domain>` on a real business path, read back a small prefix of the body and validate: is the status code in the allow list, does the body contain error signatures, does it contain features the domain must have, is the 3xx redirect target within the whitelist.
65
+
66
+ **Why is stage 3 mandatory?** Because stage 2's certificate check cannot stop "false-alive IPs on shared CDNs." Example: a shared Fastly node such as `185.199.x.x` will return a **perfectly valid** certificate for `github.com` (the SAN really does match), yet the HTTP body is `Fastly error: unknown domain` — that IP does not serve `github.com` at all. A certificate-only check would treat such a "false-alive IP" as a good node and write it into hosts, so hosts looks successfully written while GitHub becomes *less* reachable. Stage 3 does the final judgment using the **real business path** and the **response content**, which is what filters it out (for the same reason, `codeload.github.com` is probed on the archive-download path, not the root, because the root may 301 back to the main site).
67
+
68
+ **③ The candidate pool follows GitHub's official ranges daily, and gives up gracefully when it can't**
69
+
70
+ The pool isn't just a static list hard-coded in config. Once a day (`poolUpdate.intervalHours`) ghprobe pulls the latest ranges from GitHub's official `api.github.com/meta`, expands them into candidate IPs and caches them locally for the `auto` pool. So even if GitHub's Azure ranges (`20.x` / `4.x`) shift, the pool keeps up by itself — of the 24 built-in Azure IPs, 4 are already no longer in the official web range.
71
+
72
+ Two details:
73
+
74
+ - **Pulling meta requires direct-IP + SNI**: `api.github.com`'s DNS is poisoned in China so a plain request gets no response — and this step happens precisely when there is no working IP yet, so it can only use the configured **seed pool** (`ipPools.seed`, GitHub's own `140.82.112.0/20`, unchanged for years) as the direct entry point. Seeds are tried **in parallel**; the first success returns immediately and the remaining in-flight requests are aborted.
75
+ - **No retry when it fails**: that API is unauthenticated and limited to **60 requests/hour**, so success and failure **share the same cooldown** (24h by default). If `api.github.com` is blocked at the SNI layer (no IP swap can fix that), retrying is pointless and just burns the rate limit — ghprobe simply carries on with the existing pool + DoH.
76
+
77
+ **④ No proxy**
78
+
79
+ It writes the hosts file, and nothing else. No proxy software, no system proxy settings changed, no drivers, no traffic hijacking. Uninstalling just strips ghprobe's own block out of hosts, leaving nothing behind.
80
+
81
+ **⑤ Fault layering (this is the project's value proposition)**
82
+
83
+ "GitHub is unreachable" has causes at different layers, and the fix differs per layer:
84
+
85
+ | Fault layer | Symptom | Fixable by "changing the IP"? |
86
+ | --- | --- | --- |
87
+ | **DNS poisoning** | resolves to a wrong / unreachable IP | ✅ Yes. Just switch to a correct IP |
88
+ | **SNI hostname blacklist** | the hostname itself is blocked — the SNI in the TLS handshake carries the blocked domain | ❌ No. No IP will help; only **changing the domain** works |
89
+ | **IP blackhole** | entire IP ranges are dropped | ✅ Yes. Switch to a working range |
90
+
91
+ Many tools/tutorials only handle the first layer and are helpless against the second (e.g. `raw.githubusercontent.com` being precisely RST). ghprobe handles the second layer with a dedicated **fallback chain** (see section 5), treating the "changing the IP can't fix it" case separately.
92
+
93
+ ---
94
+
95
+ ## 2. Pain points it solves
96
+
97
+ **Pain point 1: direct GitHub access is sometimes fast, sometimes dead**
98
+
99
+ `git clone` stalls at `Receiving objects`, `git push` times out, pages spin — same repo, same network, works in the morning, dead at night. Because the usable IPs for each GitHub domain drift with region, ISP, and time of day, and the address system DNS hands you is often slow or broken.
100
+
101
+ **Pain point 2: the hosts list you copied is a static snapshot**
102
+
103
+ The "GitHub hosts accelerator" list you find online is a snapshot someone measured at one point in time. When an IP dies, nobody knows, and it never updates itself. You apply it once, it works for a few days, then it stops working and you go hunting for a new list — which is really just manually maintaining a table that is doomed to go stale.
104
+
105
+ **Pain point 3: many tools want a proxy / system proxy change / driver install**
106
+
107
+ To speed up GitHub you are asked to install a pile of things: a proxy client, a system-proxy toggle, a TUN/TAP driver, a background service… heavy, dirty, and it may change how your browser, IDE, and other software reach the network. You just want `git clone` to be a bit faster; you don't want the whole system's network path rewritten.
108
+
109
+ **Pain point 4: picking IPs by hand with `ping` / `curl` is guesswork — and the "alive" one may be fake**
110
+
111
+ A manual `ping` only proves an IP responds; it does not prove that the IP actually serves GitHub. A shared CDN node will hand you a valid TLS certificate but tell you `unknown domain` at the HTTP layer — your ping looks "fine," yet after writing it into hosts GitHub gets *worse*. Picking IPs by feel walks straight into this trap.
112
+
113
+ **Pain point 5: GitHub suddenly breaks after boot and you have to go dig up IPs yourself**
114
+
115
+ IPs usually die while you are not looking. With no automatic check, you only find out when "it stops working one day," and then you go dig manually. ghprobe self-heals on a schedule: it sits silently, and the moment a written IP fails it re-ranks and swaps in a new one — without you watching.
116
+
117
+ ---
118
+
119
+ ## 3. How it works
120
+
121
+ ### Overall flow
122
+
123
+ ```
124
+ ┌─────────────────────────────────────┐
125
+ │ config: domains + DoH endpoints + │
126
+ │ IP pool │
127
+ └──────────────────┬──────────────────┘
128
+ │
129
+ ┌────────────────▼────────────────┐
130
+ │ Local network check ──no net──▶ │ ← all candidates would
131
+ │ skip this round │ be dead anyway
132
+ └────────────────┬────────────────┘
133
+ │online
134
+ ┌────────────────▼────────────────┐
135
+ │ (once a day) refresh pool from │ ← no retry on failure;
136
+ │ official meta │ wait for next cycle
137
+ └────────────────┬────────────────┘
138
+ │
139
+ ┌────────────────▼────────────────┐
140
+ │ Local DoH federation (concurrent,│ ← bypasses
141
+ │ IP direct-connect) + IP pool │ system-DNS poisoning
142
+ │ → candidate IP list │
143
+ └────────────────┬────────────────┘
144
+ │
145
+ ┌────────────────▼────────────────┐
146
+ │ Three-stage probe │
147
+ │ ① TCP RTT │
148
+ │ ② TLS handshake + cert SAN │ ← blocks DNS poisoning
149
+ │ ③ HTTP application-layer check │ ← blocks shared-CDN fakes
150
+ └────────────────┬────────────────┘
151
+ │
152
+ all three passed? ──no──▶ mark failed, drop
153
+ │yes
154
+ ┌────────────────▼────────────────┐
155
+ │ rank by score ascending, take │
156
+ │ the best per domain │
157
+ └────────────────┬────────────────┘
158
+ │
159
+ ┌────────────────▼────────────────┐
160
+ │ live IP for primary domain? ──no──▶ probe fallback domains │ ← change domain to beat SNI blocking
161
+ └────────────────┬────────────────┘
162
+ │
163
+ ┌────────────────▼────────────────┐
164
+ │ build hosts block (best + spare)│
165
+ └────────────────┬────────────────┘
166
+ │
167
+ ┌────────────────▼────────────────┐
168
+ │ elevated write to hosts + flush │
169
+ │ DNS cache; all-dead guard runs │
170
+ │ before writing │
171
+ └─────────────────────────────────┘
172
+ ```
173
+
174
+ ### How the three command paths relate
175
+
176
+ There is only one core pipeline (DoH → three-stage probe → ranking), used through three command paths:
177
+
178
+ - **`ghprobe probe`** — run one full probe, **without writing hosts** (add `--apply` to write). Good for first seeing what your network can measure.
179
+ - **`ghprobe health`** — health check: **only tests the IPs already written into hosts**. If everything is healthy it exits silently and touches nothing; only on failure does it trigger a full re-rank to swap IPs. This is what the scheduled task actually calls.
180
+ - **`ghprobe install`** — one-shot enable: runs a conservative health-check-style probe first, then in **a single UAC** both writes hosts and registers the scheduled task (which calls `health --apply` on a timer).
181
+
182
+ All three share the same rules when writing hosts (same block format, same all-dead guard), so a manual `probe --apply` and the scheduled `health --apply` never clobber each other.
183
+
184
+ ### Scheduling model: a sentinel, with a full re-rank only on failure
185
+
186
+ A fixed "full re-run every N hours" is a mismatch: wasteful when the network is fine, too slow when it isn't. ghprobe uses **tiers** instead:
187
+
188
+ - **Sentinel tier (default every 1 minute)**: one `health` run probes only the **already-written IPs** in the hosts block (with `probe.concurrency` concurrency), finishing in under 2 seconds. If all are healthy it exits silently without touching hosts.
189
+ - **Heavy tier (triggered only on failure)**: a full probe (DoH re-resolution + all candidates through the three stages) runs only once an existing IP is confirmed dead.
190
+
191
+ The sentinel interval comes from `schedule.healthMin`, default **1 minute** (also the lower bound for a Windows Task Scheduler repetition interval). It is the **worst-case wait** for "IP dies → swapped out."
192
+
193
+ ### Anti-jitter and safety design
194
+
195
+ - **All-fail predicate (only a total outage counts as network failure)**: the network is judged to be at fault (blackhole window) only when **all written IPs fail** and there are ≥ 2 entries; then nothing is written that round. As long as **any single entry is healthy**, the path is up, so the failing ones are just stale/dead IPs and a re-rank is triggered. (The earlier "majority failed → give up" predicate was proven to misfire: its denominator counted only written entries and was often as low as 3–4, so "3 stale + 1 healthy" was misjudged as an outage — not fixing when it should.)
196
+ - **All-dead guard**: before writing, if the new block has **no valid IP at all** while hosts still has old entries, the write is **refused and the old entries are kept**. This prevents a network blip of a few tens of seconds from wiping mappings that were working. `probe --apply` and `health --apply` share this guard.
197
+ - **Partial update + IP stickiness**: after a full re-rank, only domains that **actually differ** from the current hosts are updated; tiny score jitter on a healthy domain won't trigger an IP change (each change costs a DNS cache flush and invalidates connection pools).
198
+ - **Duplicate-entry fallback**: each domain gets its best IP **plus one spare** by default. Windows returns *all* same-name entries and clients try them one by one, so if the first IP happens to die mid-jitter, the browser / `git` automatically falls through to the spare — turning a "down for up to a minute" into "one extra connect timeout". Count is controlled by `hosts.maxEntriesPerDomain` (default 2; set 1 for the old single-entry behavior).
199
+ - **`# absent` cooldown**: domains present in config but absent from hosts (typically SNI-blocked domains that even a full run can't save) are recorded as a `# absent <domain> <timestamp>` comment inside the hosts block, and are not retried for `schedule.absentCooldownHours` (default 1 hour), so the heavy tier doesn't fire uselessly every minute.
200
+ - **Local offline → skip entirely**: each round starts with a local network check — ① is there an enabled, non-loopback interface; ② can we TCP-connect to a domestic baseline target (default `223.5.5.5:443`). If the machine is offline it **does not probe, does not refresh the pool, does not write hosts** — it only logs `action=skip reason=本地无网络连接`. Rationale: offline, every candidate is necessarily dead, so a round is pure waste, and the log gets flooded with "stuck / all-dead", mixing "this machine has no network" with "GitHub is blocked" and hiding the real fault. The baseline target is deliberately **not GitHub** — it only answers "can this machine get out", independent of whether GitHub is reachable.
201
+ - **Candidate pool refreshed daily**: pull the latest network ranges from GitHub's official `api.github.com/meta`, expand them into candidate IPs, cache them locally and feed the `auto` pool. Why: GitHub's Azure ranges change and the built-in `azure` pool goes stale (4 of 24 measured IPs were already gone). That API is unauthenticated and limited to **60 requests/hour**, so the interval can only be large (24h by default). If it gets blocked at the SNI layer (no IP swap can fix that), **a failure waits a full cycle too** — retrying is pointless there, so ghprobe just keeps working with the existing pool + DoH. Manual trigger: `ghprobe update-pool`, which ignores the interval.
202
+ - **Stateless**: the `# absent` cooldown table is encoded directly in the hosts block comments — it survives process restarts and cross-process runs with no extra state file.
203
+ - **Cross-process write lock**: `probe --apply` and the scheduled task may write hosts concurrently, so a lock file serializes them and avoids read-modify-write clobbering; if the lock can't be acquired the round is skipped and the next health check writes it.
204
+
205
+ ### The hosts block looks like this
206
+
207
+ ```text
208
+ # === ghprobe begin ===
209
+ # generated 2026-10-11T00:00:00.000Z by ghprobe
210
+ 140.82.112.3 github.com
211
+ 20.27.177.113 github.com
212
+ 20.27.177.113 codeload.github.com
213
+ 185.199.108.133 raw.githubusercontent.com
214
+ 185.199.111.133 raw.githubusercontent.com
215
+ # absent gist.githubusercontent.com 2026-10-11T00:00:00.000Z
216
+ # === ghprobe end ===
217
+ ```
218
+
219
+ Everything between `# === ghprobe begin/end ===` is owned by ghprobe; repeated writes only replace what's inside the block. **Lines outside the block (your own hosts entries and anything a third-party tool wrote) are never touched.**
220
+
221
+ A domain may appear on several lines (2 by default): **the first line is the current best IP, the rest are spares**. Windows returns all same-name entries and curl / browsers try them one by one — so if the first IP happens to be dead, the client falls through to a spare instead of waiting for the next health check. Controlled by `hosts.maxEntriesPerDomain`.
222
+
223
+ ---
224
+
225
+ ## 4. Usage
226
+
227
+ > Platform: **Windows**; prerequisite: **Node.js ≥ 18**. Run all commands in **PowerShell or cmd**.
228
+
229
+ ### Option 1: install from npm (recommended)
230
+
231
+ ```bash
232
+ npm install -g ghprobe
233
+ ```
234
+
235
+ Then two commands are all it takes:
236
+
237
+ ```bash
238
+ ghprobe install # start: probe + write hosts + register the scheduled task (a single UAC prompt)
239
+ ghprobe uninstall # stop: remove the scheduled task and restore hosts
240
+ ```
241
+
242
+ **What `ghprobe install` does:**
243
+
244
+ 1. **Generates the user config file** `%LOCALAPPDATA%\ghprobe\config.yaml` (it **does not overwrite** an existing one, so your edits survive) — candidate pools, timeouts, scheduling interval and the local network check all live there, ready to edit.
245
+ 2. Runs a **conservative health-check-style probe** first: it tests only the IPs already written into hosts, keeping the healthy ones and re-ranking only the confirmed-dead ones (so domains whose current IP is still healthy but weren't captured this round are not misrecorded as missing). If it detects **no local network**, this step is skipped automatically (everything would be dead anyway) but the scheduled task **is still registered**, and takes effect once you're back online.
246
+ 3. Prompts for **a single UAC**, then in the same elevated session does two things:
247
+ - **Writes hosts once**: backs up the current hosts to `hosts.ghprobe-bak`, writes the ghprobe block, verifies the block marker exists, rolls back automatically on failure, and flushes the DNS cache on success.
248
+ - **Registers the Windows scheduled task named `ghprobe`**: it runs **silently at highest privileges (RunLevel=Highest)** (so later writes never prompt UAC again) and runs `health --apply` on the `schedule.healthMin` interval (default 1 minute). The task action is launched through a hidden-window wrapper, so it is **windowless** and does not disturb you.
249
+
250
+ It's ready to use right away; you never touch the command line again.
251
+
252
+ **What `ghprobe uninstall` does:**
253
+
254
+ 1. **Restores hosts**: **strips** the whole ghprobe block and writes it back — it removes only ghprobe's own lines and **keeps third-party entries untouched** (it deliberately does *not* overwrite from `hosts.ghprobe-bak`: that backup is a "before write" snapshot, and overwriting from it would also roll back entries later added by other software), then flushes the DNS cache.
255
+ 2. **Removes the scheduled task**: `Unregister-ScheduledTask ghprobe`; idempotent when the task doesn't exist.
256
+
257
+ The two steps are independent — a failure in either does not affect the other, and the process exits non-zero on failure.
258
+
259
+ > If the package is not published to npm yet, run `npm install -g .` inside the repository directory for the same effect.
260
+
261
+ ### Option 2: clone and one-click deploy
262
+
263
+ ```bash
264
+ git clone https://github.com/zmw-zmw/ghprobe
265
+ cd ghprobe
266
+ ```
267
+
268
+ Then double-click `install.cmd`, or run in PowerShell:
269
+
270
+ ```powershell
271
+ .\install.ps1
272
+ ```
273
+
274
+ `install.ps1` will: self-elevate (**a single UAC prompt in total**) → check the Node version (≥ 18) → install dependencies → register the scheduled task → write hosts once immediately → **self-check and print the result back to the current window**.
275
+
276
+ Available switches:
277
+
278
+ ```powershell
279
+ .\install.ps1 # deploy: register the scheduled task + write hosts once now
280
+ .\install.ps1 -SkipProbe # register the scheduled task only; let the first health check write hosts
281
+ .\install.ps1 -Uninstall # uninstall: remove the scheduled task + restore hosts
282
+ ```
283
+
284
+ > `install.ps1` also supports `-LogFile <path>` to set the deploy log path (default `%TEMP%\ghprobe-deploy.log`); `-Inner` is used internally for self-elevation and should not be passed manually.
285
+ > `install.cmd` is just a cmd wrapper around `install.ps1` (equivalent, double-click to run).
286
+
287
+ ### Other commands
288
+
289
+ ```bash
290
+ # Run one probe round and print results (does not write hosts)
291
+ ghprobe probe
292
+ ghprobe probe -j # output JSON (for scripting)
293
+ ghprobe probe --apply # probe then write to system hosts (UAC prompt; automatic backup)
294
+
295
+ # Only build and print the hosts block; you decide whether to write it
296
+ ghprobe hosts
297
+
298
+ # Run a health check manually (does not write hosts by default; only previews on failure)
299
+ ghprobe health
300
+ ghprobe health --apply # write to system hosts when a problem is detected (UAC prompt)
301
+
302
+ # Refresh the candidate pool from GitHub's official api.github.com/meta (once a day is enough)
303
+ # That API is limited to 60 requests/hour unauthenticated, so don't call it often.
304
+ # health also does this on schedule, so you rarely need it manually.
305
+ ghprobe update-pool
306
+
307
+ # Print the paths of the user config file and the pool cache (the config file is editable)
308
+ ghprobe config-path
309
+
310
+ # Local HTTP API (default 127.0.0.1:8930)
311
+ ghprobe serve
312
+ # POST /api/probe trigger one probe round, return a summary
313
+ # GET /api/status latest result
314
+ # GET /api/hosts current generated hosts block
315
+ # GET /api/config effective config
316
+
317
+ # Every command supports -c / --config for a user config override
318
+ ghprobe probe -c my.yaml
319
+ ```
320
+
321
+ ### Where the config file lives
322
+
323
+ `ghprobe install` generates a **user config file** at:
324
+
325
+ ```
326
+ %LOCALAPPDATA%\ghprobe\config.yaml
327
+ ```
328
+
329
+ (`ghprobe config-path` prints this path at any time.)
330
+
331
+ It **takes priority over the built-in default config** and can be edited directly: candidate pools, timeouts, scheduling interval, target domains, local network check — all live there. The file ships with full comments (in Chinese) explaining every key. Save it and the next `health` round picks it up — no reinstall needed.
332
+
333
+ > Why not in the package directory: with a global npm install the package lives under `node_modules`, and npm upgrades/reinstalls **replace it entirely**, wiping your edits.
334
+ >
335
+ > If a user config already exists, `install` **will not overwrite it** (it only copies the built-in default when the file is missing).
336
+
337
+ ### Configuration override
338
+
339
+ Besides editing the user config file directly, you can also pass a YAML file containing **only the parts you want to change** via `-c my.yaml`: object properties (such as `timeouts` / `probe` / `serve` / `schedule`) are **merged recursively**, so you only write the keys you want to change.
340
+
341
+ Priority: the file given by `-c` > `%LOCALAPPDATA%\ghprobe\config.yaml` > the built-in `config.default.yaml`.
342
+
343
+ ```yaml
344
+ # Example 1: change only the TLS timeout to 3s; dohMs / httpMs keep their defaults
345
+ timeouts:
346
+ tlsMs: 3000
347
+
348
+ # Example 2: change the sentinel interval to 5 minutes
349
+ schedule:
350
+ healthMin: 5
351
+
352
+ # Example 3: use different DoH endpoints (note: host is an IP, sni is the service domain)
353
+ doh:
354
+ - { name: alidns1, host: 223.5.5.5, sni: dns.alidns.com, path: /dns-query }
355
+
356
+ # Example 4: adjust the target domain list
357
+ domains:
358
+ - github.com
359
+ - raw.githubusercontent.com
360
+
361
+ # Example 5: turn off the local network check (run even when offline; rarely needed)
362
+ networkCheck:
363
+ enabled: false
364
+ ```
365
+
366
+ The full default config lives in `config.default.yaml` in the repo (that file is the source of truth for key names, defaults and comments). The main parts:
367
+
368
+ ```yaml
369
+ ipPools:
370
+ seed: [...] # the most stable entry point (GitHub's own range); keep it — it's also the direct entry for pulling meta
371
+ fastly: [185.199.108.133, 185.199.109.133, 185.199.110.133, 185.199.111.133]
372
+ azure: [...] # goes stale; the auto pool maintained by poolUpdate below covers it
373
+
374
+ domainPools:
375
+ github.com: [auto, seed, azure]
376
+ codeload.github.com: [auto, seed, azure]
377
+ objects.githubusercontent.com: [fastly]
378
+ raw.githubusercontent.com: [fastly]
379
+ gist.githubusercontent.com: [fastly]
380
+ avatars.githubusercontent.com: [fastly]
381
+
382
+ poolUpdate:
383
+ enabled: true
384
+ intervalHours: 24 # success and failure share one cooldown (a failed pull waits a full cycle, no retry storm)
385
+ source: "https://api.github.com/meta"
386
+ host: api.github.com
387
+ sections: [web, api]
388
+ maxIps: 48
389
+ perBigNet: 4 # how many IPs to sample per big range (/20 holds 4096 addresses; never expand fully)
390
+ timeoutMs: 8000
391
+
392
+ networkCheck:
393
+ enabled: true
394
+ probe: { host: 223.5.5.5, port: 443, timeoutMs: 2000 }
395
+
396
+ fallbacks:
397
+ raw.githubusercontent.com: [cdn.jsdelivr.net]
398
+
399
+ timeouts: { dohMs: 5000, tcpMs: 2500, tlsMs: 6000, httpMs: 6000 }
400
+ probe: { maxCandidates: 8, maxPoolIps: 32, maxPerDomain: 4, concurrency: 10 }
401
+ hosts: { maxEntriesPerDomain: 2 }
402
+ serve: { host: 127.0.0.1, port: 8930 }
403
+ schedule:
404
+ healthMin: 1 # sentinel interval (minutes): test written IPs only, full re-rank on failure
405
+ absentCooldownHours: 1 # missing-domain cooldown (hours): SNI-blocked domains can't be saved even by a full run
406
+ ```
407
+
408
+ ### Uninstall / rollback and logs
409
+
410
+ ```bash
411
+ ghprobe uninstall # remove the scheduled task + restore hosts (removes only its own lines)
412
+ ```
413
+
414
+ If you deployed via `install.ps1`, you can also use `.\install.ps1 -Uninstall`.
415
+
416
+ - **Manual hosts rollback**: before each write the file is backed up to `C:\Windows\System32\drivers\etc\hosts.ghprobe-bak`; but `ghprobe uninstall` is preferred (it strips by block and won't harm third-party entries added later).
417
+ - **Health-check log**: `%LOCALAPPDATA%\ghprobe\ghprobe.log`. The scheduled task runs silently and both stdout/stderr are discarded, so each round's result is appended here (`action` / `reason` / `changed`) so you can investigate if something breaks.
418
+ - **User config**: `%LOCALAPPDATA%\ghprobe\config.yaml` (editable — see the previous section).
419
+ - **Pool cache**: `%LOCALAPPDATA%\ghprobe\pool.cache.json` (maintained by `update-pool`; don't edit by hand).
420
+ - **Deploy log**: with `install.ps1`, written to `%TEMP%\ghprobe-deploy.log` by default.
421
+
422
+ ---
423
+
424
+ ## 5. About "why is cdn.jsdelivr.net in there?"
425
+
426
+ **`cdn.jsdelivr.net` is not a domestic mirror.** It is the jsDelivr public CDN (running on Cloudflare). It appears in hosts for exactly one reason: the **fallback chain**.
427
+
428
+ One fault type is the **SNI hostname blacklist** — what is blocked is the hostname itself. The SNI in the TLS handshake carries that domain, so **no IP will help**; the only way out is to **change the domain**. jsDelivr maps GitHub files directly:
429
+
430
+ ```text
431
+ raw.githubusercontent.com/<user>/<repo>/<ref>/<path>
432
+ cdn.jsdelivr.net/gh/<user>/<repo>@<ref>/<path> <- same content
433
+ ```
434
+
435
+ So when `raw.githubusercontent.com` is SNI-blocked and all primary candidates are dead, ghprobe falls back to it.
436
+
437
+ Caveats:
438
+
439
+ - By default only `raw.githubusercontent.com` has a fallback; `github.com` / `codeload.github.com` never use one.
440
+ - It fires only when the primary candidates are **entirely dead** — any live IP and the fallback domain is never probed.
441
+ - What gets written is the `cdn.jsdelivr.net` IP — this rescues "change the domain," not "change the IP."
442
+
443
+ To remove it, empty `fallbacks` (`--config` merges recursively, so an empty object works):
444
+
445
+ ```yaml
446
+ fallbacks: {}
447
+ ```
448
+
449
+ ```bash
450
+ ghprobe install -c my.yaml
451
+ ```
452
+
453
+ The cost: once `raw.githubusercontent.com` is SNI-blocked, that class of files has no alternative entry point.
454
+
455
+ ---
456
+
457
+ ## 6. Requirements and FAQ
458
+
459
+ ### Requirements
460
+
461
+ - **OS: Windows.** Writing the system hosts file, UAC elevation, and registering a scheduled task are all Windows-only capabilities.
462
+ (The core logic — probing / ranking / DoH / HTTP API — is cross-platform and CI runs it on ubuntu; but on Linux/macOS, `probe` only shows results and **will not and cannot** write the system hosts file.)
463
+ - **Node.js ≥ 18.**
464
+ - First install needs administrator rights (one UAC prompt).
465
+
466
+ ### FAQ
467
+
468
+ **Q: Why does it need administrator rights?**
469
+ The system hosts file (`C:\Windows\System32\drivers\etc\hosts`) is writable only by administrators, and registering a highest-privilege scheduled task also requires it. `ghprobe install` combines "write hosts" and "register the task" into **a single UAC prompt**, so you only see one.
470
+
471
+ **Q: Why can't it always fix things?**
472
+ The tool can only find IPs usable **on your current network at the current moment**. If the problem is an SNI hostname blacklist (which changing the IP can't fix) or an IP blackhole, every candidate may be unusable. In that case it hits the fallback chain (if configured) or the all-dead guard — it **keeps the old IPs and never writes an empty block**, and never forces in a wrong IP.
473
+
474
+ **Q: Will it affect other software?**
475
+ No. It **only writes the hosts file** — no proxy, no system-proxy changes, no traffic hijacking. It operates only on the block between `# === ghprobe begin/end ===` that belongs to it, so **your existing hosts entries and third-party entries are never modified**; uninstall likewise strips only its own block.
476
+
477
+ **Q: How many IPs per domain?**
478
+ Two by default (`hosts.maxEntriesPerDomain`): the best IP plus one spare. Windows returns all same-name entries and clients try them in order, so the spare is a **real fallback** — if the first IP is dead, the client retries the second instead of failing outright. Set it to 1 for the old single-entry behavior.
479
+
480
+ **Q: What if an IP dies between two health checks?**
481
+ The client falls through to the spare entry, so the cost is one extra connect timeout rather than a hard failure. Without a spare, the worst-case interruption would be about `schedule.healthMin` (1 minute by default).
482
+
483
+ **Q: Does the scheduled task run while the screen is locked?**
484
+ The task registered by `install` uses `LogonType = Interactive`, so it won't fire while locked / logged off / not logged in; it resumes on the interval after logon.
485
+
486
+ ### Disclaimer
487
+
488
+ This tool performs DNS preference selection and connectivity probing only. It does **not** provide, include, or assist with any proxy, tunnel, traffic forwarding, or network-control evasion capability. You are solely responsible for any consequences of using this tool. Please comply with the laws and regulations of your jurisdiction.
489
+
490
+ ---
491
+
492
+ ## Changelog
493
+
494
+ See [CHANGELOG.md](CHANGELOG.md).
495
+
496
+ ## License
497
+
498
+ **ghprobe** is released under the **GNU General Public License v3.0**, licensed as **GPL-3.0-only** (SPDX identifier: `GPL-3.0-only`).
499
+
500
+ Note: this is **not** `GPL-3.0-or-later`. The project **deliberately** accepts only GPL v3 and *not* "or any later version" — an intentional version lock (for example, so that a future GPL v4's terms are never automatically applied to this work). Please keep the same version lock when redistributing.
501
+
502
+ ```
503
+ ghprobe — GitHub direct-connect optimizer: local DoH resolution + TLS-verified probing + hosts generation (no proxy)
504
+ Copyright (C) 2026 zmw
505
+
506
+ This program is free software: you can redistribute it and/or modify
507
+ it under the terms of the GNU General Public License as published by
508
+ the Free Software Foundation, version 3 of the License.
509
+
510
+ This program is distributed in the hope that it will be useful,
511
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
512
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
513
+ GNU General Public License for more details.
514
+
515
+ You should have received a copy of the GNU General Public License
516
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
517
+ ```
518
+
519
+ You are free to run, study, modify, and redistribute this software, subject to the GPL-3.0 conditions:
520
+
521
+ - **Copyleft**: when distributing this software or a derivative work you must provide the complete corresponding source and license the derivative under the **same license**.
522
+ - **Preserve notices**: you may not remove the copyright, license, or disclaimer notices.
523
+ - **No warranty**: the author provides no express or implied warranty (see GPL-3.0 sections 15 and 16).
524
+
525
+ The full license text is in [`LICENSE`](LICENSE) at the repository root. Repository: <https://github.com/zmw-zmw/ghprobe>.