ghlink 0.4.15__py3-none-any.whl
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.
- ghlink/__init__.py +4 -0
- ghlink/assets/ghlink-icon-128.png +0 -0
- ghlink/assets/ghlink-icon.ico +0 -0
- ghlink/assets/ghlink-icon.png +0 -0
- ghlink/builtin_github520.py +56 -0
- ghlink/config.py +73 -0
- ghlink/github520.py +257 -0
- ghlink/hosts_manager.py +259 -0
- ghlink/lock.py +142 -0
- ghlink/main.py +491 -0
- ghlink/notifier.py +48 -0
- ghlink/platform_adapter.py +209 -0
- ghlink/probe.py +119 -0
- ghlink/resolver.py +205 -0
- ghlink/service.py +1014 -0
- ghlink/state.py +68 -0
- ghlink/tray.py +688 -0
- ghlink-0.4.15.dist-info/METADATA +436 -0
- ghlink-0.4.15.dist-info/RECORD +23 -0
- ghlink-0.4.15.dist-info/WHEEL +5 -0
- ghlink-0.4.15.dist-info/entry_points.txt +2 -0
- ghlink-0.4.15.dist-info/licenses/LICENSE +21 -0
- ghlink-0.4.15.dist-info/top_level.txt +1 -0
ghlink/__init__.py
ADDED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""内置 GitHub520 兜底数据(v0.3.0 新增,李工 2026-08-22 定)。
|
|
2
|
+
|
|
3
|
+
背景:新安装/首次拉取失败时若无缓存,ghlink 拿不到任何社区 IP →
|
|
4
|
+
GitHub520 段为空、非核心域名无 hosts 条目。内置一份最新快照兜底,
|
|
5
|
+
保证首装断网/拉取失败也能直接可用。
|
|
6
|
+
|
|
7
|
+
格式:hosts 文本快照(与 raw.hellogithub.com/hosts 同构),由
|
|
8
|
+
github520.parse_hosts() 解析——数据即字符串,避免大字典触发
|
|
9
|
+
SonarCloud 重复代码块误报(D 级质量门禁)。
|
|
10
|
+
|
|
11
|
+
更新规则:每次发版前抓取 https://raw.hellogithub.com/hosts 更新本文件。
|
|
12
|
+
核心域名(github.com/api.github.com)仍由动态自愈兜底,解析时剔除。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
# 快照时间:2026-08-22T07:51:33+08:00(来源 raw.hellogithub.com/hosts)
|
|
16
|
+
BUILTIN_GITHUB520_HOSTS = """140.82.113.25 alive.github.com
|
|
17
|
+
20.205.243.168 api.github.com
|
|
18
|
+
140.82.114.21 api.individual.githubcopilot.com
|
|
19
|
+
185.199.111.133 avatars.githubusercontent.com
|
|
20
|
+
185.199.111.133 avatars0.githubusercontent.com
|
|
21
|
+
185.199.111.133 avatars1.githubusercontent.com
|
|
22
|
+
185.199.111.133 avatars2.githubusercontent.com
|
|
23
|
+
185.199.111.133 avatars3.githubusercontent.com
|
|
24
|
+
185.199.111.133 avatars4.githubusercontent.com
|
|
25
|
+
185.199.111.133 avatars5.githubusercontent.com
|
|
26
|
+
185.199.111.133 camo.githubusercontent.com
|
|
27
|
+
140.82.114.22 central.github.com
|
|
28
|
+
185.199.111.133 cloud.githubusercontent.com
|
|
29
|
+
20.205.243.165 codeload.github.com
|
|
30
|
+
140.82.114.22 collector.github.com
|
|
31
|
+
185.199.111.133 desktop.githubusercontent.com
|
|
32
|
+
185.199.111.133 favicons.githubusercontent.com
|
|
33
|
+
159.106.121.75 gist.github.com
|
|
34
|
+
16.15.237.95 github-cloud.s3.amazonaws.com
|
|
35
|
+
52.216.62.41 github-com.s3.amazonaws.com
|
|
36
|
+
16.15.223.74 github-production-release-asset-2e65be.s3.amazonaws.com
|
|
37
|
+
54.231.229.17 github-production-repository-file-5c1aeb.s3.amazonaws.com
|
|
38
|
+
52.217.64.212 github-production-user-asset-6210df.s3.amazonaws.com
|
|
39
|
+
192.0.66.2 github.blog
|
|
40
|
+
20.205.243.166 github.com
|
|
41
|
+
140.82.113.18 github.community
|
|
42
|
+
185.199.110.215 github.githubassets.com
|
|
43
|
+
203.111.254.117 github.global.ssl.fastly.net
|
|
44
|
+
185.199.111.153 github.io
|
|
45
|
+
185.199.111.133 github.map.fastly.net
|
|
46
|
+
185.199.111.153 githubstatus.com
|
|
47
|
+
140.82.112.26 live.github.com
|
|
48
|
+
185.199.111.133 media.githubusercontent.com
|
|
49
|
+
185.199.111.133 objects.githubusercontent.com
|
|
50
|
+
13.107.42.16 pipelines.actions.githubusercontent.com
|
|
51
|
+
185.199.111.133 raw.githubusercontent.com
|
|
52
|
+
185.199.111.133 user-images.githubusercontent.com
|
|
53
|
+
150.171.110.104 vscode.dev
|
|
54
|
+
140.82.114.21 education.github.com
|
|
55
|
+
185.199.111.133 private-user-images.githubusercontent.com
|
|
56
|
+
"""
|
ghlink/config.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""配置加载:JSON 配置,零依赖。
|
|
2
|
+
|
|
3
|
+
config.example.json 为模板。字段:
|
|
4
|
+
- probe: targets(探测域名列表), timeout_sec, round_interval_min
|
|
5
|
+
- trigger: consecutive_failures(默认3), cooldown_min(默认15), verify_success_rounds(默认2)
|
|
6
|
+
- resolver: doh_sources, cache_ttl_sec, max_candidates
|
|
7
|
+
- notify: feishu_webhook(空=关闭), enabled(默认true)
|
|
8
|
+
- state_file, lock_file, hosts_backup_dir
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from typing import Any, Dict
|
|
14
|
+
|
|
15
|
+
DEFAULT_CONFIG: Dict[str, Any] = {
|
|
16
|
+
"probe": {
|
|
17
|
+
"targets": [
|
|
18
|
+
"github.com",
|
|
19
|
+
"api.github.com",
|
|
20
|
+
"codeload.github.com",
|
|
21
|
+
"github.global.ssl.fastly.net",
|
|
22
|
+
# v0.2.18 扩域(赛博 22:20 定案,李工 23:21 批准并入 v0.2.19 规划):
|
|
23
|
+
# GitHub 生态核心域名,覆盖 clone/raw/release 下载链盲区
|
|
24
|
+
"raw.githubusercontent.com",
|
|
25
|
+
"objects.githubusercontent.com",
|
|
26
|
+
"gist.github.com",
|
|
27
|
+
"github.githubassets.com",
|
|
28
|
+
],
|
|
29
|
+
"timeout_sec": 15, # v0.2.8:5→15(慢链路不误杀,李工 23:15 实测定论)
|
|
30
|
+
# 目标域名健康度管理(v0.2):长期不可达域名自动降级,核心域名优先保证切换成功
|
|
31
|
+
"core_targets": ["github.com", "api.github.com"], # 核心域名永不降级
|
|
32
|
+
"degrade_after_rounds": 3, # v0.2.18:非核心域名连续失败 N 轮 → 降级(1h 粒度 ≈ 3h)
|
|
33
|
+
"recover_rounds": 2, # 降级域名连续成功 N 轮 → 恢复纳入
|
|
34
|
+
},
|
|
35
|
+
"trigger": {
|
|
36
|
+
# v0.2.18(李工 22:27 定:探测 1 小时不频繁):阈值按 1h 粒度核算
|
|
37
|
+
"consecutive_failures": 3, # 连续 3 小时失败才触发切换
|
|
38
|
+
"cooldown_min": 180, # 切换冷却 3 小时(原 15min 在 1h 粒度下无意义)
|
|
39
|
+
"verify_success_rounds": 2, # 切换后连续 2 小时成功才回 normal
|
|
40
|
+
},
|
|
41
|
+
"resolver": {
|
|
42
|
+
"doh_sources": [
|
|
43
|
+
"https://dns.alidns.com/resolve",
|
|
44
|
+
"https://doh.pub/dns-query",
|
|
45
|
+
"https://cloudflare-dns.com/dns-query",
|
|
46
|
+
"https://dns.google/resolve",
|
|
47
|
+
],
|
|
48
|
+
"cache_ttl_sec": 3600,
|
|
49
|
+
"max_candidates": 5,
|
|
50
|
+
},
|
|
51
|
+
"notify": {"enabled": True, "feishu_webhook": ""},
|
|
52
|
+
"state_file": "ghlink_status.json",
|
|
53
|
+
"lock_file": "ghlink.lock",
|
|
54
|
+
"hosts_backup_dir": "backup",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_config(path: str) -> Dict[str, Any]:
|
|
59
|
+
"""加载配置,缺失字段回退默认值。"""
|
|
60
|
+
cfg = json.loads(json.dumps(DEFAULT_CONFIG)) # deep copy
|
|
61
|
+
if path and os.path.exists(path):
|
|
62
|
+
with open(path, encoding="utf-8") as f:
|
|
63
|
+
user_cfg = json.load(f)
|
|
64
|
+
_deep_merge(cfg, user_cfg)
|
|
65
|
+
return cfg
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> None:
|
|
69
|
+
for k, v in override.items():
|
|
70
|
+
if isinstance(v, dict) and isinstance(base.get(k), dict):
|
|
71
|
+
_deep_merge(base[k], v)
|
|
72
|
+
else:
|
|
73
|
+
base[k] = v
|
ghlink/github520.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""GitHub520 hosts 段集成(v0.2.18,赛博 22:20 两步走第二步,李工 23:21 并入)。
|
|
2
|
+
|
|
3
|
+
设计(赛博定案):
|
|
4
|
+
- 周期拉取 https://raw.hellogithub.com/hosts(默认 1 小时,SwitchHosts 同款)
|
|
5
|
+
- 合入 ghlink 独立段落(# ghlink Start/End),核心域名 ghlink 自愈优先
|
|
6
|
+
- 写入前基础可达性抽检(坏 IP 不入场)
|
|
7
|
+
- 核心域名(github.com/api.github.com)仍走 ghlink 动态验证兜底,
|
|
8
|
+
非核心域名才用 GitHub520 社区 IP——互补而不互相拖累
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import time
|
|
14
|
+
import urllib.request
|
|
15
|
+
from typing import Any, Dict, List
|
|
16
|
+
|
|
17
|
+
from .builtin_github520 import BUILTIN_GITHUB520_HOSTS # v0.4.2:首装断网/拉取失败兜底
|
|
18
|
+
|
|
19
|
+
# 拉取状态缓存文件(放 state 同目录)
|
|
20
|
+
_CACHE_NAME = "ghlink520_cache.json"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _cache_path(state_dir: str = "") -> str:
|
|
24
|
+
"""缓存文件路径:优先 state 目录,兜底用户目录。"""
|
|
25
|
+
if state_dir:
|
|
26
|
+
return os.path.join(state_dir, _CACHE_NAME)
|
|
27
|
+
return os.path.join(os.path.expanduser("~"), ".ghlink", _CACHE_NAME)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def fetch_hosts(url: str, timeout_sec: float = 30) -> str:
|
|
31
|
+
"""拉取 GitHub520 hosts 文本(失败抛异常,由调用方降级)。"""
|
|
32
|
+
req = urllib.request.Request(url, headers={"User-Agent": "ghlink/0.4.2"})
|
|
33
|
+
with urllib.request.urlopen(req, timeout=timeout_sec) as resp:
|
|
34
|
+
return resp.read().decode("utf-8", errors="replace")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_hosts(text: str) -> Dict[str, List[str]]:
|
|
38
|
+
"""解析 hosts 文本 → {domain: [ips]}。跳过注释/空行/坏行。"""
|
|
39
|
+
entries: Dict[str, List[str]] = {}
|
|
40
|
+
for line in text.splitlines():
|
|
41
|
+
line = line.strip()
|
|
42
|
+
if not line or line.startswith("#"):
|
|
43
|
+
continue
|
|
44
|
+
parts = line.split()
|
|
45
|
+
if len(parts) < 2:
|
|
46
|
+
continue
|
|
47
|
+
ip, domain = parts[0], parts[1].rstrip(".")
|
|
48
|
+
# 只收 GitHub 生态域名(防社区列表混入无关项)
|
|
49
|
+
if not domain.endswith(
|
|
50
|
+
("github.com", "githubusercontent.com", "githubassets.com", "fastly.net")
|
|
51
|
+
):
|
|
52
|
+
continue
|
|
53
|
+
entries.setdefault(domain, [])
|
|
54
|
+
if ip not in entries[domain]:
|
|
55
|
+
entries[domain].append(ip)
|
|
56
|
+
return entries
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _safe_cache_path(state_dir: str = "") -> str:
|
|
60
|
+
"""校验并规范化缓存路径(SonarCloud S8707:防符号链接/路径逃逸)。
|
|
61
|
+
|
|
62
|
+
要求:绝对路径 + realpath 解析符号链接 + 位于允许目录
|
|
63
|
+
(用户主目录或系统临时目录)内。
|
|
64
|
+
"""
|
|
65
|
+
import tempfile
|
|
66
|
+
|
|
67
|
+
path = _cache_path(state_dir)
|
|
68
|
+
resolved = os.path.realpath(path)
|
|
69
|
+
if not os.path.isabs(resolved):
|
|
70
|
+
raise ValueError(f"cache path must be absolute: {path}")
|
|
71
|
+
allowed_roots = (os.path.expanduser("~"), tempfile.gettempdir())
|
|
72
|
+
for root in allowed_roots:
|
|
73
|
+
root = os.path.realpath(root)
|
|
74
|
+
if resolved == root or resolved.startswith(root + os.sep):
|
|
75
|
+
return resolved
|
|
76
|
+
raise ValueError(f"cache path outside allowed dirs: {resolved}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _ip_reachable(ip: str, timeout_sec: float = 2.0) -> bool:
|
|
80
|
+
"""基础可达性抽检:TCP 443 连通即认为可用(防坏 IP 入场)。
|
|
81
|
+
|
|
82
|
+
v0.4.2(拂晓实测建议):超时 5s→2s 收敛——TCP 443 建连 <2s 即可判通断,
|
|
83
|
+
40 行 × 5s 串行最坏 200s,2s + 并行后显著提速。
|
|
84
|
+
"""
|
|
85
|
+
import socket
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
sock = socket.create_connection((ip, 443), timeout=timeout_sec)
|
|
89
|
+
sock.close()
|
|
90
|
+
return True
|
|
91
|
+
except OSError:
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _precheck_ips(
|
|
96
|
+
ips: List[str],
|
|
97
|
+
timeout_sec: float = 2.0,
|
|
98
|
+
max_check: int = 5,
|
|
99
|
+
cache: Dict[str, bool] | None = None,
|
|
100
|
+
) -> List[str]:
|
|
101
|
+
"""v0.4.2(拂晓实测建议落地):并行预检 + 短路 + 去重缓存。
|
|
102
|
+
|
|
103
|
+
- 并行:ThreadPoolExecutor 并发 TCP 443 预检(40 行最坏从串行 200s → 并行 ~2s)
|
|
104
|
+
- 短路:每域名最多预检前 max_check 条(first-match-wins 只吃首条)
|
|
105
|
+
- 去重:同 IP 本轮只预检一次(cache dict 跨域名共享结果)
|
|
106
|
+
- 语义:可达排前(ok_ips + rest),未预检的排后——保持现有排序语义
|
|
107
|
+
"""
|
|
108
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
109
|
+
|
|
110
|
+
cache = cache if cache is not None else {}
|
|
111
|
+
to_check = [ip for ip in ips[:max_check] if ip not in cache]
|
|
112
|
+
with ThreadPoolExecutor(max_workers=max(len(to_check), 1)) as pool:
|
|
113
|
+
futs = {pool.submit(_ip_reachable, ip, timeout_sec): ip for ip in to_check}
|
|
114
|
+
for f in futs:
|
|
115
|
+
cache[futs[f]] = f.result()
|
|
116
|
+
ok = [ip for ip in ips if cache.get(ip)]
|
|
117
|
+
rest = [ip for ip in ips if ip not in ok]
|
|
118
|
+
return ok + rest
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def filter_reachable(entries: Dict[str, List[str]], max_ips: int = 2) -> Dict[str, List[str]]:
|
|
122
|
+
"""抽检:每域名保留可达 IP(最多 max_ips 个),全不可达则剔除该域名。
|
|
123
|
+
|
|
124
|
+
v0.4.2(拂晓实测建议):改用 _precheck_ips 并行预检(超时 2s、短路前 5 条、去重)。
|
|
125
|
+
"""
|
|
126
|
+
out: Dict[str, List[str]] = {}
|
|
127
|
+
cache: Dict[str, bool] = {}
|
|
128
|
+
for domain, ips in entries.items():
|
|
129
|
+
ranked = _precheck_ips(ips, timeout_sec=2.0, max_check=5, cache=cache)
|
|
130
|
+
ok_ips = [ip for ip in ranked if cache.get(ip)][:max_ips]
|
|
131
|
+
if ok_ips:
|
|
132
|
+
out[domain] = ok_ips
|
|
133
|
+
return out
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def load_cached(state_dir: str = "") -> Dict[str, List[str]]:
|
|
137
|
+
"""读本地缓存(拉取失败时兜底,防坏 IP 列表已抽检过)。"""
|
|
138
|
+
try:
|
|
139
|
+
path = _safe_cache_path(state_dir)
|
|
140
|
+
if os.path.exists(path):
|
|
141
|
+
with open(path, encoding="utf-8") as f:
|
|
142
|
+
data = json.load(f)
|
|
143
|
+
return dict(data.get("entries", {}))
|
|
144
|
+
except (OSError, ValueError):
|
|
145
|
+
pass
|
|
146
|
+
return {}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def cache_age(state_dir: str = "") -> float:
|
|
150
|
+
"""v0.4.2:返回本地缓存年龄(秒);无缓存/损坏返回超大值(视为过期需重拉)。"""
|
|
151
|
+
try:
|
|
152
|
+
path = _safe_cache_path(state_dir)
|
|
153
|
+
if os.path.exists(path):
|
|
154
|
+
with open(path, encoding="utf-8") as f:
|
|
155
|
+
data = json.load(f)
|
|
156
|
+
ts = float(data.get("ts", 0))
|
|
157
|
+
if ts:
|
|
158
|
+
return time.time() - ts
|
|
159
|
+
except (OSError, ValueError):
|
|
160
|
+
pass
|
|
161
|
+
return float("inf")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def save_cache(entries: Dict[str, List[str]], state_dir: str = "") -> None:
|
|
165
|
+
"""保存抽检后的缓存(供下次拉取失败兜底)。"""
|
|
166
|
+
try:
|
|
167
|
+
path = _safe_cache_path(state_dir)
|
|
168
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
169
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
170
|
+
json.dump({"ts": time.time(), "entries": entries}, f)
|
|
171
|
+
except (OSError, ValueError):
|
|
172
|
+
pass
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def sync_github520(cfg: Dict[str, Any], state_dir: str = "") -> Dict[str, List[str]]:
|
|
176
|
+
"""日常轮次:拉取 + 解析 + 抽检 + 缓存;失败回退缓存/内置快照。
|
|
177
|
+
|
|
178
|
+
返回非核心域名的社区 IP(核心域名由动态自愈优先,不写死静态 IP)。
|
|
179
|
+
"""
|
|
180
|
+
return _sync(cfg, state_dir, include_core=False)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def initial_entries(cfg: Dict[str, Any], state_dir: str = "") -> Dict[str, List[str]]:
|
|
184
|
+
"""首装全量兜底(v0.4.2 新增,李工 12:35 点 1):含全部域名(含核心),
|
|
185
|
+
预检过的 IP 排前、未预检的排后——首装/动态失败时 hosts 必有可用条目。
|
|
186
|
+
"""
|
|
187
|
+
return _sync(cfg, state_dir, include_core=True, full_write=True)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _sync(
|
|
191
|
+
cfg: Dict[str, Any],
|
|
192
|
+
state_dir: str = "",
|
|
193
|
+
include_core: bool = False,
|
|
194
|
+
full_write: bool = False,
|
|
195
|
+
) -> Dict[str, List[str]]:
|
|
196
|
+
"""核心同步逻辑。include_core=保留核心域名;full_write=全量写(预检过排前)。"""
|
|
197
|
+
g = cfg.get("github520", {})
|
|
198
|
+
if not g.get("enabled", True):
|
|
199
|
+
return {}
|
|
200
|
+
url = g.get("url", "https://raw.hellogithub.com/hosts")
|
|
201
|
+
timeout_sec = float(g.get("timeout_sec", 30))
|
|
202
|
+
core = set(cfg.get("probe", {}).get("core_targets", ["github.com", "api.github.com"]))
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
text = fetch_hosts(url, timeout_sec)
|
|
206
|
+
entries = parse_hosts(text)
|
|
207
|
+
if not include_core:
|
|
208
|
+
entries = {d: ips for d, ips in entries.items() if d not in core}
|
|
209
|
+
if full_write:
|
|
210
|
+
entries = _sort_prechecked_first(entries, min(timeout_sec, 5.0))
|
|
211
|
+
else:
|
|
212
|
+
entries = filter_reachable(entries)
|
|
213
|
+
if entries:
|
|
214
|
+
save_cache(entries, state_dir)
|
|
215
|
+
return entries
|
|
216
|
+
except Exception:
|
|
217
|
+
pass
|
|
218
|
+
# 拉取失败 → 缓存兜底(缓存已抽检过)
|
|
219
|
+
cached = load_cached(state_dir)
|
|
220
|
+
if cached:
|
|
221
|
+
if include_core:
|
|
222
|
+
# v0.4.3(李工 8 bug 点④ + 顾笙无缓存场景验证):首装全量语义下
|
|
223
|
+
# 缓存是 sync_github520(include_core=False) 存的,本就不含核心域名——
|
|
224
|
+
# 若直接剔除核心,动态从未成功 + 拉取失败时 hosts 段无 github.com
|
|
225
|
+
# 主条目。从内置快照补齐核心域名静态 IP(20.205.243.166 github.com 等)。
|
|
226
|
+
builtin = parse_hosts(BUILTIN_GITHUB520_HOSTS)
|
|
227
|
+
for _d in core:
|
|
228
|
+
if _d in builtin and _d not in cached:
|
|
229
|
+
cached[_d] = builtin[_d]
|
|
230
|
+
return cached
|
|
231
|
+
return {d: ips for d, ips in cached.items() if d not in core}
|
|
232
|
+
# 内置快照兜底(防首装断网尴尬)
|
|
233
|
+
builtin = parse_hosts(BUILTIN_GITHUB520_HOSTS)
|
|
234
|
+
if not include_core:
|
|
235
|
+
builtin = {d: ips for d, ips in builtin.items() if d not in core}
|
|
236
|
+
if full_write:
|
|
237
|
+
builtin = _sort_prechecked_first(builtin, min(timeout_sec, 5.0))
|
|
238
|
+
else:
|
|
239
|
+
builtin = filter_reachable(builtin)
|
|
240
|
+
if builtin:
|
|
241
|
+
save_cache(builtin, state_dir)
|
|
242
|
+
return builtin
|
|
243
|
+
return {}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _sort_prechecked_first(
|
|
247
|
+
entries: Dict[str, List[str]], timeout_sec: float = 2.0
|
|
248
|
+
) -> Dict[str, List[str]]:
|
|
249
|
+
"""v0.4.2:全量写入时预检过的 IP 排前、未预检的排后(hosts 取首个命中)。
|
|
250
|
+
|
|
251
|
+
v0.4.2(拂晓实测建议):走 _precheck_ips 并行预检(超时 2s、短路前 5 条、去重缓存)。
|
|
252
|
+
"""
|
|
253
|
+
out: Dict[str, List[str]] = {}
|
|
254
|
+
cache: Dict[str, bool] = {}
|
|
255
|
+
for domain, ips in entries.items():
|
|
256
|
+
out[domain] = _precheck_ips(ips, timeout_sec=timeout_sec, max_check=5, cache=cache)
|
|
257
|
+
return out
|
ghlink/hosts_manager.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""hosts 段落式管理:写入/备份/回滚/自检。
|
|
2
|
+
|
|
3
|
+
约定(借鉴 GitHub520 思路,全新实现):
|
|
4
|
+
- hosts 段落标记:# ghlink Start / # ghlink End,段落可重复安全更新
|
|
5
|
+
- GitHub520 静态兜底子段:# ghlink520 Start / # ghlink520 End(v0.2.19 起)
|
|
6
|
+
—— 初始化时合入一次,后续动态更新自动保留(不重复合入、不丢失)
|
|
7
|
+
- 写入前 backup_hosts(),写入后立即自检(probe 替换域名),失败 restore_hosts()
|
|
8
|
+
- 自检失败 → 回滚 + degraded 状态 + 告警,坏配置绝不留场
|
|
9
|
+
- v0.2.19(李工 8 条):正常态也保持 hosts 段存在(全局访问生效),
|
|
10
|
+
写入前与现有段落比较,内容无变化不落盘(避免频繁写盘/flushdns)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from typing import Dict, List
|
|
14
|
+
|
|
15
|
+
from . import platform_adapter
|
|
16
|
+
|
|
17
|
+
START_MARK = "# ghlink Start"
|
|
18
|
+
END_MARK = "# ghlink End"
|
|
19
|
+
G520_START = "# ghlink520 Start"
|
|
20
|
+
G520_END = "# ghlink520 End"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_block(entries: Dict[str, List[str]]) -> str:
|
|
24
|
+
"""由 {domain: [ips]} 生成 hosts 段落文本(含 Start/End 标记)。"""
|
|
25
|
+
lines = [START_MARK]
|
|
26
|
+
for domain, ips in entries.items():
|
|
27
|
+
for ip in ips:
|
|
28
|
+
lines.append(f"{ip} {domain}")
|
|
29
|
+
lines.append(END_MARK)
|
|
30
|
+
return "\n".join(lines) + "\n"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_combined_block(dynamic: Dict[str, List[str]], g520: Dict[str, List[str]]) -> str:
|
|
34
|
+
"""生成「动态段 + GitHub520 静态子段」复合段落。
|
|
35
|
+
|
|
36
|
+
结构:
|
|
37
|
+
# ghlink Start
|
|
38
|
+
<动态 IP:8 域名>
|
|
39
|
+
# ghlink520 Start
|
|
40
|
+
<GitHub520 社区 IP:非核心域名>
|
|
41
|
+
# ghlink520 End
|
|
42
|
+
# ghlink End
|
|
43
|
+
|
|
44
|
+
g520 为空时不输出子段标记,段落保持原样(向后兼容旧 hosts)。
|
|
45
|
+
"""
|
|
46
|
+
lines = [START_MARK]
|
|
47
|
+
for domain, ips in dynamic.items():
|
|
48
|
+
for ip in ips:
|
|
49
|
+
lines.append(f"{ip} {domain}")
|
|
50
|
+
if g520:
|
|
51
|
+
lines.append(G520_START)
|
|
52
|
+
for domain, ips in g520.items():
|
|
53
|
+
for ip in ips:
|
|
54
|
+
lines.append(f"{ip} {domain}")
|
|
55
|
+
lines.append(G520_END)
|
|
56
|
+
lines.append(END_MARK)
|
|
57
|
+
return "\n".join(lines) + "\n"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _read_hosts(path: str) -> str:
|
|
61
|
+
try:
|
|
62
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
63
|
+
return f.read()
|
|
64
|
+
except OSError:
|
|
65
|
+
return ""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _write_hosts(path: str, content: str) -> bool:
|
|
69
|
+
try:
|
|
70
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
71
|
+
f.write(content)
|
|
72
|
+
return True
|
|
73
|
+
except OSError:
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _extract_section(content: str, start_mark: str, end_mark: str) -> str:
|
|
78
|
+
"""提取段落内部文本(不含标记);段落缺失/标记不完整返回 ''。"""
|
|
79
|
+
start = content.find(start_mark)
|
|
80
|
+
end = content.find(end_mark)
|
|
81
|
+
if start == -1 or end == -1 or end <= start:
|
|
82
|
+
return ""
|
|
83
|
+
return content[start + len(start_mark) : end]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def current_ghlink_block(path: str = "") -> str:
|
|
87
|
+
"""读取当前 hosts 中的 # ghlink Start/End 段全文(含标记);不存在返回 ''。"""
|
|
88
|
+
path = path or platform_adapter.get_hosts_path()
|
|
89
|
+
content = _read_hosts(path)
|
|
90
|
+
start = content.find(START_MARK)
|
|
91
|
+
end = content.find(END_MARK)
|
|
92
|
+
if start == -1 or end == -1 or end <= start:
|
|
93
|
+
return ""
|
|
94
|
+
return content[start : end + len(END_MARK)]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def current_g520_entries(path: str = "") -> Dict[str, List[str]]:
|
|
98
|
+
"""从当前 hosts 提取 GitHub520 子段条目 {domain: [ips]};无子段返回 {}。
|
|
99
|
+
|
|
100
|
+
v0.2.19:初始化合入后,动态更新时从现有 hosts 保留该子段(不重复拉取/合入)。
|
|
101
|
+
"""
|
|
102
|
+
path = path or platform_adapter.get_hosts_path()
|
|
103
|
+
content = _read_hosts(path)
|
|
104
|
+
section = _extract_section(content, G520_START, G520_END)
|
|
105
|
+
if not section:
|
|
106
|
+
return {}
|
|
107
|
+
entries: Dict[str, List[str]] = {}
|
|
108
|
+
for line in section.splitlines():
|
|
109
|
+
line = line.strip()
|
|
110
|
+
if not line or line.startswith("#"):
|
|
111
|
+
continue
|
|
112
|
+
parts = line.split()
|
|
113
|
+
if len(parts) < 2:
|
|
114
|
+
continue
|
|
115
|
+
ip, domain = parts[0], parts[1].rstrip(".")
|
|
116
|
+
entries.setdefault(domain, [])
|
|
117
|
+
if ip not in entries[domain]:
|
|
118
|
+
entries[domain].append(ip)
|
|
119
|
+
return entries
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def apply_block(
|
|
123
|
+
block: str,
|
|
124
|
+
backup_dir: str = "backup",
|
|
125
|
+
preserve_g520: bool = True,
|
|
126
|
+
) -> tuple:
|
|
127
|
+
"""写入 hosts(替换旧段落);提权/写入失败返回 (False, "")。
|
|
128
|
+
|
|
129
|
+
参数:
|
|
130
|
+
- block: 新段落全文(含 # ghlink Start/End 标记)
|
|
131
|
+
- preserve_g520: 若现有 hosts 含 GitHub520 子段而新 block 不含,
|
|
132
|
+
则自动保留子段(v0.2.19 初始化合一次、动态更新不丢兜底段)
|
|
133
|
+
|
|
134
|
+
返回 (ok, backup_path):ok=False 表示写入失败;ok=True 时 backup_path
|
|
135
|
+
为本次写入前的备份文件路径(供自检失败回滚使用)。
|
|
136
|
+
"""
|
|
137
|
+
if not platform_adapter.ensure_privilege():
|
|
138
|
+
return False, ""
|
|
139
|
+
path = platform_adapter.get_hosts_path()
|
|
140
|
+
content = _read_hosts(path)
|
|
141
|
+
|
|
142
|
+
# v0.2.19:动态段更新时保留现有 GitHub520 子段(初始化已合入,不重复拉取)
|
|
143
|
+
if preserve_g520 and G520_START not in block:
|
|
144
|
+
g520 = _extract_section(content, G520_START, G520_END)
|
|
145
|
+
if g520:
|
|
146
|
+
# 把子段插入 # ghlink End 之前
|
|
147
|
+
end_pos = block.rfind(END_MARK)
|
|
148
|
+
if end_pos != -1:
|
|
149
|
+
sub = f"\n{G520_START}{g520}{G520_END}"
|
|
150
|
+
block = block[:end_pos] + sub + block[end_pos:]
|
|
151
|
+
|
|
152
|
+
# v0.4.0(李工 12:35 点 3):段落插到文件最前优先命中(first-match-wins),
|
|
153
|
+
# 避免用户预存条目在段落前遮蔽 ghlink 写入;段落外内容零改动
|
|
154
|
+
start = content.find(START_MARK)
|
|
155
|
+
end = content.find(END_MARK)
|
|
156
|
+
if start != -1 and end != -1 and end > start:
|
|
157
|
+
before = content[:start]
|
|
158
|
+
after = content[end + len(END_MARK) :]
|
|
159
|
+
content = before + block + after
|
|
160
|
+
# 若段落不在文件最前(前面还有非空内容),把段落提前到最前
|
|
161
|
+
if before.strip():
|
|
162
|
+
content = block + "\n" + before + after
|
|
163
|
+
elif start == -1 and end == -1:
|
|
164
|
+
content = block + "\n" + content.rstrip("\n") + "\n"
|
|
165
|
+
else:
|
|
166
|
+
# 段落标记不完整,视为异常:整体重建安全内容
|
|
167
|
+
return False, ""
|
|
168
|
+
|
|
169
|
+
# v0.2.19:内容无变化不落盘(避免每轮写盘 + flushdns)
|
|
170
|
+
if content == _read_hosts(path):
|
|
171
|
+
return True, ""
|
|
172
|
+
|
|
173
|
+
backup = platform_adapter.backup_hosts(backup_dir)
|
|
174
|
+
if not backup:
|
|
175
|
+
return False, ""
|
|
176
|
+
if not _write_hosts(path, content):
|
|
177
|
+
platform_adapter.restore_hosts(backup)
|
|
178
|
+
return False, ""
|
|
179
|
+
platform_adapter.flush_dns()
|
|
180
|
+
return True, backup
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def remove_block(path: str = "") -> bool:
|
|
184
|
+
"""v0.4.1(拂晓 Linux 严格测试发现):移除 hosts 中的 ghlink 段落(含 ghlink520 子段),
|
|
185
|
+
还原基线(disable/卸载时调用,李工"卸载也直接删"要求)。段落不存在返回 True(幂等)。"""
|
|
186
|
+
if not platform_adapter.ensure_privilege():
|
|
187
|
+
return False
|
|
188
|
+
path = path or platform_adapter.get_hosts_path()
|
|
189
|
+
content = _read_hosts(path)
|
|
190
|
+
start = content.find(START_MARK)
|
|
191
|
+
end = content.find(END_MARK)
|
|
192
|
+
if start == -1 or end == -1 or end <= start:
|
|
193
|
+
return True # 无段落,幂等成功
|
|
194
|
+
# 移除段落(含段落前后的多余空行清理)
|
|
195
|
+
before = content[:start]
|
|
196
|
+
after = content[end + len(END_MARK) :]
|
|
197
|
+
new_content = before + after
|
|
198
|
+
# 清理段落移除后残留的双空行
|
|
199
|
+
while "\n\n\n" in new_content:
|
|
200
|
+
new_content = new_content.replace("\n\n\n", "\n\n")
|
|
201
|
+
if new_content == content:
|
|
202
|
+
return True
|
|
203
|
+
if not _write_hosts(path, new_content):
|
|
204
|
+
return False
|
|
205
|
+
platform_adapter.flush_dns()
|
|
206
|
+
return True
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def detect_external_dupes(path: str = "") -> Dict[str, str]:
|
|
210
|
+
"""v0.4.0(李工 12:35 点 3):检测段落外预存的 GitHub 生态域名条目。
|
|
211
|
+
|
|
212
|
+
返回 {domain: "ip"}——用户在 ghlink 块之外已配置的条目,
|
|
213
|
+
first-match-wins 下可能与 ghlink 写入冲突。enable 时调用,命中则告警+备份。
|
|
214
|
+
"""
|
|
215
|
+
path = path or platform_adapter.get_hosts_path()
|
|
216
|
+
content = _read_hosts(path)
|
|
217
|
+
# 剔除 ghlink 段落(含 ghlink520 子段)
|
|
218
|
+
start = content.find(START_MARK)
|
|
219
|
+
end = content.find(END_MARK)
|
|
220
|
+
if start != -1 and end != -1 and end > start:
|
|
221
|
+
content = content[:start] + content[end + len(END_MARK) :]
|
|
222
|
+
out: Dict[str, str] = {}
|
|
223
|
+
for line in content.splitlines():
|
|
224
|
+
line = line.strip()
|
|
225
|
+
if not line or line.startswith("#"):
|
|
226
|
+
continue
|
|
227
|
+
parts = line.split()
|
|
228
|
+
if len(parts) < 2:
|
|
229
|
+
continue
|
|
230
|
+
ip, domain = parts[0], parts[1].rstrip(".")
|
|
231
|
+
if domain.endswith(
|
|
232
|
+
(".github.com", "github.com", "githubusercontent.com", "githubassets.com", "fastly.net")
|
|
233
|
+
):
|
|
234
|
+
out.setdefault(domain, ip)
|
|
235
|
+
return out
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def verify_after_apply(targets: List[str], timeout_sec: float) -> bool:
|
|
239
|
+
"""写入后立即自检:新 IP 下全部目标连通才算成功。
|
|
240
|
+
|
|
241
|
+
v0.4.4(李工 03:27 终裁 B 方案,顾笙无缓存场景专项发现):分级宽容降级——
|
|
242
|
+
先三层全检(TCP+TLS+HTTP HEAD),失败目标用 TCP-only 复检:
|
|
243
|
+
TCP 通判通过(与预检同口径,防 TLS 干扰误杀——TLS 握手被干扰但 IP 实际可达
|
|
244
|
+
时不再回滚清空兜底写入);TCP 也不通才判失败(真坏 IP 仍回滚,坏配置绝不留场)。
|
|
245
|
+
"""
|
|
246
|
+
from . import probe
|
|
247
|
+
|
|
248
|
+
results = probe.probe_all(targets, timeout_sec)
|
|
249
|
+
failed = [h for h, r in results.items() if not r.get("ok")]
|
|
250
|
+
if not failed:
|
|
251
|
+
return True
|
|
252
|
+
# 分级宽容:失败目标 TCP-only 复检(TCP 通即通过)
|
|
253
|
+
tcp_results = probe.probe_tcp_only_many(failed, timeout_sec)
|
|
254
|
+
return all(r.get("ok") for r in tcp_results.values())
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def rollback(backup_path: str) -> bool:
|
|
258
|
+
"""回滚 hosts 到备份版本。"""
|
|
259
|
+
return platform_adapter.restore_hosts(backup_path)
|