Repository navigation
Expand file tree
/
Copy pathgithub_direct.py
More file actions
9647 lines (8793 loc) · 496 KB
/
Copy pathgithub_direct.py
File metadata and controls
9647 lines (8793 loc) · 496 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# -*- coding: utf-8 -*-
"""
GitHub 直连助手 —— 绿色单文件版
作者: 海风(kele551) https://gitcode.com/kele551/FoxPath
协作: 小腾(只在源码里留档,不出现在界面上)
干什么
------
电信宽带到 GitHub 的路径一直在漂: 某个 IP 今天 0.8 秒, 明天就彻底不通,
DNS 给的官方地址在这条线上也时好时坏。手工改 hosts 只能管两三天, 而且改
hosts 还要管理员权限。
这个程序不碰 hosts、不碰 DNS、不要管理员权限:
1. 在本机开一个小代理, 只接管 github.com 一类的流量;
2. 由它直连当前**实测能握手**的 GitHub 官方 IP;
3. 死掉的 IP 立刻踢掉, 每 5 分钟重测一轮补回来;
4. 系统代理设成它自带的 PAC, 只有 GitHub 域名走代理, 其他一律直连。
怎么判断一个 IP 是真 GitHub
--------------------------
不是 ping(很多机器禁 ICMP, 而且 ping 通不代表能开网页), 而是真做 TLS
握手并校验证书: 证书里必须带 github.com。证书不对一律判死。
为什么不用管理员
----------------
代理监听 127.0.0.1 的高端口, 改的是 HKCU 下的 Internet Settings(当前用户),
开机自启写的是 HKCU 的 Run 项。都在用户权限范围内。
用法
----
双击 exe -> 自动打开控制面板
exe --silent -> 不弹窗, 后台静默运行(开机自启用这个)
控制面板里「停用并还原」-> 把系统代理恢复成你原来的样子
"""
import atexit
import ctypes
import ipaddress
import json
import os
import platform # 一键反馈包: 系统版本要写清楚(2026-10-24)
import collections
import queue
import re
import select
import socket
import socketserver
import ssl
import sys
import threading
import time
import urllib.parse
import urllib.request
import uuid
import webbrowser
from concurrent.futures import ThreadPoolExecutor
from ctypes import wintypes # 升级结果气泡要用(Shell_NotifyIcon 的结构体)
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
# Ed25519 验签(升级通道加固,2026-10-16):同目录的 _ed25519.py(纯 Python、零第三方依赖)。
# 用 try/except 包住是**故意**的:以后万一打包漏了它,程序照样能跑,只是升级这条路
# 一律拒绝(fail-closed,见 update_sig_available / _verify_update_sig)—— 绝不放行没验签的升级。
try:
import _ed25519
UPDATE_SIG_AVAILABLE = True
except Exception:
_ed25519 = None
UPDATE_SIG_AVAILABLE = False
APP_NAME = '狐径'
APP_VERSION = '1.2.5'
# 署名(一处定义,界面/日志/属性/README 都用它,避免各写各的)
AUTHOR = '海风(kele551)'
AUTHOR_ASCII = 'HaiFeng (kele551)'
HOMEPAGE = 'github.com/kele551/FoxPath'
PROXY_PORT = 8787
PANEL_PORT = 8788
# 【2026-10-30 学 FastGithub 的「端口被占就向上顺延」】以前 8787 被别的程序占了
# (另一个代理、本机某服务、甚至 Hyper-V 动态保留段)就直接弹错误框退出 ——
# 对用户来说等于"这软件跟别的东西不能共存"。现在按这份备用名单依次试。
# 名单是**手挑的**,不是 bind(0) 拿随机端口:浏览器对"代理端口"也有一份
# restricted-port 黑名单(1080 SOCKS、6665-6669 IRC、6379/11214/27017 这类),
# 撞上就是 ERR_UNSAFE_PORT(桌面壁纸 2026-10 踩过同一坑),所以宁可写死一串。
# 顺延只影响**新启动**的实例:PAC 由本程序实时生成,里面写的就是本次真正在听的端口。
PROXY_PORT_ALTS = (8789, 8797, 8897, 18787, 28787)
_PROXY_PORT = [PROXY_PORT] # 运行时真正在听的端口,bind 成功后写定
CHECK_INTERVAL = 300 # 5 分钟重测一轮
# 转发通道的"空闲上限": 只要两端还有数据流动就一直续期, 不是总时长上限(审查 H-4)。
RELAY_IDLE_TIMEOUT = 600
# 【2026-10-30 改·稳定不掉线】传输中的"每读看门狗": 上游一旦在活跃传输里沉默满这么久
# (默认 15 秒, 既没有 FIN 也没有任何字节), 就判断流、记另一本账、踢 IP、关连接 —— 浏览器
# 收到半截连接会自动重试, 落到干净 IP 上。这是补上"大页面搬了一半卡死"这类静默挂死
# (反馈包里 github.com 20~24 秒超时那几发): 之前中继只在双方静默满 600 秒才收摊, 上游
# 不关连接只是沉默时, 中继一直卡在 select 等数据, 客户端 20 秒先超时放弃, 中继既没判死
# 也没换 IP。15 秒对正常 GitHub 传输极宽松(哪怕慢速大文件也会在 15 秒内送出几 KB),
# 不会误杀; 而对"搬了一半不动"的坏链路能在客户端放弃前先动手, 把失败变成一次无感重试。
STALL_WATCHDOG = 15.0
# 【2026-11-13 新增·首字节更快撒手】上面那 15 秒只管"已经在搬字节"的传输阶段。
# 浏览器真正的"转圈"发生在**首字节**这一段: 请求已经发完、上游一个字节都不回。
# 本机 2026-11-13 实测(github.com 首页 10 次, 走本机代理):
# 成功那 8 次首字节 1.01~9.33 秒(平均 4.24 秒); 失败那 2 次要到 7.45 / 12.23 秒才暴露;
# 而浏览器自己约 10 秒就放弃 → 用户看到的是"转半天 → 白屏 → 手动刷新才出来"。
# 所以首字节单独给 6 秒: 只切"明显不给"的那批(失败样本都在 7.5 秒之后), 又不会误杀
# "再等两三秒就轮到"的慢连接(9.33 秒那发就是等出来的)。
# 判死后**只降权、不清出候选池**(见 _tunnel 里的 _fb 分支): 这类 IP 是时好时坏,
# 一棒子打死会把仅有的候选清光 —— 2026-10-30 把看门狗算进"首次即踢"时已经踩过这个坑。
FIRST_BYTE_WAIT = 6.0
# 【2026-11-13 记·试过把传输段收紧到 8 秒,实测否掉了,已回退 15 秒】
# 想法: 浏览器自己对单连接约 10 秒放弃, 15 秒慢半拍, 收紧到 8 秒能更早换 IP。
# 实测(14:5x, 线路恢复期, 走本机代理):
# ① raw.githubusercontent.com 的 github_direct.py(490,885 B) 下两次:
# 第一次 8.2 秒、0 字节就断(正是 8 秒看门狗干的), 第二次 12.2 秒才整份下完;
# ② 同一时段首字节有 7.99 秒才回来、后面完全正常的一发。
# 说明 8 秒会掐掉"慢但会来"的连接, 把本来能成的变成失败 —— 这是净损失, 不是优化。
# 结论: 传输段维持 15 秒(正常 GitHub 传输不会静默 15 秒; 真卡死的那批本来也要等浏览器重试)。
# 所以这里不再单独定义"传输段上限", 继续用上面的 STALL_WATCHDOG。
# 【2026-10-30 新增·握手阶段透明重试】
# CONNECT 隧道里客户端握着自己那半边 TLS: 一旦 200 established 发出、客户端开始 TLS,
# 中途换 IP 续传在架构上不可能(应用层数据不能重放)。但**握手阶段**(客户端发 ClientHello、
# 等 ServerHello)可以透明换 IP —— 把 ClientHello 缓冲下来, 上游若静默/超时不应答就关掉它、
# 换下一个候选、把同一份 ClientHello 重放过去, 客户端完全无感。这只兜得住"握手阶段就死"
# 的 0 字节/静默上游类; 搬了一半被 RST 的中途断流只能靠选路别选中高丢包 IP 来规避
# (见 _rank_key 与 _connect_pool 排序)。
_TUNNEL_RETRY = 3 # 握手阶段最多换几个候选再放弃
_TUNNEL_HS_TIMEOUT = 5.0 # 等上游 ServerHello 的时限, 超时即判死换人
# PAC 只代理这几台主机;代理内部也用同一份名单,保证"PAC 会送来的"和
# "代理愿意走 IP 池的"完全一致。githubassets / api.github.com 一律 DIRECT ——
# 它们直连本来就通,走代理反而会坏(见 README 已知边界)。
#
# 【2026-10-24 接管范围核实(UI 整改顺带做)—— 实测证据在 logs\UI整改-20261024\】
# · codeload.github.com —— **纳入**。它跟 github.com 是同一批官方 web 服务器:
# 用候选池 IP + SNI=codeload.github.com 实测,证书覆盖 *.github.com、HTTP 301
# (正常行为)。所以它走 HEALTH 池,**没有新增任何 IP**。
# · gist.github.com —— **暂不纳入,列 TODO**。本机 DNS 被污染(解析出 2001::1 /
# 78.16.49.15),改用 6 个官方 IP、每个试 2 次,SNI=gist.github.com 一律在
# 握手阶段 ConnectionResetError;而同一批 IP 用 SNI=github.com 全部 200。
# 换 IP 解决不了这一层,接管过去也白搭 —— 不写占位 IP、也不假装能加速。
# · githubassets.com —— **保持 DIRECT,列 TODO**。它解析到 185.199.108.215
# (在官方 web 段 185.199.108.0/22 内),但证书是 *.githubassets.com,与 raw 池
# 校验的 githubusercontent 证书不同;本机实测 TLS 194ms、本来就快,没必要动。
# 【2026-10-26 加·uploads.github.com】发布附件**上传**用的域以前不在这份清单里,浏览器一律直连,
# 于是出现"页面能开、传附件却撞运气"(v1.2.3 发版当天实测:裸连 WinError 10060、走代理偶发 502)。
# 收进来的判据和当年 codeload 用的是同一条:拿池里的 IP + SNI=uploads.github.com 发一次 GET /,
# 实测 5 个地址(140.82.112.3 / 140.82.113.4 / 140.82.116.3 / 20.205.243.166 / 20.27.177.113)
# TLS 全部握手成功并回 **301**(而不是 500 Domain Not Found)—— 说明这些服务器真的服务这个域。
# api.github.com 仍然**故意不在**这个名单里:候选池全是 web 段地址,拿它连 api 是错的。
PROXY_HOSTS = ('github.com', 'www.github.com', 'codeload.github.com', 'uploads.github.com')
# 2026-10-09 用户要求:把 GitHub Pages 也纳入接管范围。
# 页面挂在 *.github.io 上(用户名.github.io),用的还是 185.199.108.0/22 那一段,
# 所以与 raw/附件**共用同一份 IP 池**;但**证书期望值必须单独设** ——
# 实测这一段服务的是 *.github.io 证书,拿 githubusercontent.com 那套去校验,
# 会把这一池的好 IP 全部误杀(见下面的 HEALTH_PAGES)。
GITHUB_IO_SUFFIX = '.github.io'
# 2026-10-08 按用户需求(GitHub issue #1)增加:raw 文件与发布附件下载。
# **它们必须走自己的 IP 池**(见下面 CANDIDATE_IPS_RAW):官方 meta 里
# 185.199.108.0/22 才是这些主机的段;塞进 github.com 的池子会连到错误服务器,
# 页面脚本会全废(这正是以前"不能代理 githubusercontent"的真正原因)。
# 【2026-10-11 改·avatars/camo 纳入】本机实测推翻了上面"刻意直连"的老结论:
# 同一张 avatars 头像 282KB,直连 5.7~14.2s(均值 10.68s),
# 走本机代理 1.1~4.7s(均值 2.65s)——快约 4 倍,4 轮全胜。
# 原因:avatars / camo 与 raw/objects **同一段 185.199.108.0/22**(本机解析
# 全是 185.199.108~111.133),证书也是同一张 *.githubusercontent.com,
# 所以放进 raw 池既不会连错服务器、也不会撞证书。
# 意义:GitHub 页面上有几十个头像/camo 图片,它们直连被堵时,主站 HTML 就算
# 秒开,页面也会一直转圈 —— 这正是"加速了但网页还是打不开"的另一半原因。
# githubassets 仍保持直连:它不在 githubusercontent 域(证书不同),
# 且本机实测 0.27s 本来就快,没必要动。
PROXY_HOSTS_RAW = ('raw.githubusercontent.com', 'objects.githubusercontent.com',
'avatars.githubusercontent.com', 'camo.githubusercontent.com')
# GitHub 官方 IP 池(2026-10-08 从 api.github.com/meta 全量对齐)。
# 以前只有 15 个手写地址:联通/移动拉不到 /meta 时就只剩这些旧地址,
# 很多已经不通 —— 那正是"某些线路用不了"的根因。现在把官方公布的全带上,
# 即使在线同步失败也有足够多的候选。IPv6 单独一组:移动/联通的 IPv6
# 常常反而是通的,而以前的代码直接 continue 掉了 IPv6。
CANDIDATE_IPS = [
'20.201.28.151', '20.205.243.166', '20.205.243.161', '20.87.245.0',
'20.87.245.2', '4.237.22.38', '4.237.22.36', '4.228.31.150',
'4.228.31.144', '20.207.73.82', '20.207.73.81', '20.27.177.113',
'20.27.177.119', '20.200.245.247', '20.200.245.244', '20.175.192.147',
'20.233.83.145', '20.233.83.148', '20.29.134.23', '20.29.134.22',
'20.199.39.232', '20.217.135.5', '20.217.135.1', '4.225.11.194',
'4.225.11.199', '4.208.26.197', '4.208.26.193', '20.26.156.215',
'20.26.156.213', '172.182.252.133', '172.182.252.130', '4.249.131.164',
'48.202.248.40', '48.204.201.5', '140.82.112.3', '140.82.112.4',
'140.82.113.3', '140.82.113.4', '140.82.114.3', '140.82.114.4',
'140.82.115.3', '140.82.115.4', '140.82.116.3', '140.82.116.4',
'20.27.177.114',
]
# github.com 的真实 IPv6 地址。官方 JSON 里只给网段(2606:50c0::/32),
# 国内 DNS 又拿不到 AAAA,所以这里写实测可用的地址;
# 移动/联通的 IPv6 常常反而是通的,IPv4 全灭时这一组就是活路。
# 本机实测3个握手通过
# raw.githubusercontent.com / objects.githubusercontent.com 专用池
# (官方 web 段里的 185.199.108.0/22;本机已用 SNI=raw.githubusercontent.com
# 实测证书覆盖 githubusercontent.com,见 CHANGELOG)
CANDIDATE_IPS_RAW = [
'185.199.108.133',
'185.199.109.133',
'185.199.110.133',
'185.199.111.133',
]
# IPv6 候选(**目前为空是有意的**):
# 2026-10-08 实测:官方 meta 只给网段(2606:50c0::/32、2a0a:a440::/29),
# 照着网段猜出来的地址在 HTTP 层返回 "500 Domain Not Found" —— 说明它们并不真的
# 服务 github.com,写进去只会白占探测名额。真实地址从两个地方来:
# 1) 用户自己 DNS 解析出的 AAAA(见 dns_fallback_ips,很多家宽 DNS 会给);
# 2) 将来我们往 meta.json 的 web6 字段里放实测可用的地址。
CANDIDATE_IPS_V6 = []
# 【2026-10-11 实测·IPv6 只有 raw 和 Pages 用得上, 主站用不上】
# 今天下午 IPv4 全线拥塞时实测的对比(同一个确定存在的资源
# raw.githubusercontent.com/github/gitignore/main/Python.gitignore):
# IPv4 185.199.108.133 / 109.133 -> 超时失败
# IPv6 2606:50c0:800x::154 -> 200, 5588 字节, 0.15~0.17 秒
# 同一时刻 github.io 也一样: IPv6 2606:50c0:800x::153 访问 facebook.github.io
# 返回 200, 而 IPv4 185.199.108.133 连不上 —— IPv6 走的是另一条路, 不跟 IPv4 一起堵。
# 反过来 **github.com 主站根本没有 IPv6**: 扫了 144 个候选(2606:50c0:8000~8003::140~175),
# 没有能对 github.com 返回 2xx/3xx 的(::153 是 Pages 的节点, 拿 github.com 去问一律 404)。
# 所以下面两组只喂给 raw / pages 池, 主站池(CANDIDATE_IPS_V6)继续留空。
CANDIDATE_IPS_RAW_V6 = [
'2606:50c0:8000::154', '2606:50c0:8001::154',
'2606:50c0:8002::154', '2606:50c0:8003::154',
]
CANDIDATE_IPS_PAGES_V6 = [
'2606:50c0:8000::153', '2606:50c0:8001::153',
'2606:50c0:8002::153', '2606:50c0:8003::153',
]
# 池子入参: raw 用"IPv4 + raw 的 IPv6", pages 用"IPv4 + pages 的 IPv6"。
# 两组不能混 —— ::154 的证书不含 *.github.io, 塞进 pages 池只会被判死、白占探测名额。
CANDIDATE_IPS_RAW_ALL = CANDIDATE_IPS_RAW + CANDIDATE_IPS_RAW_V6
CANDIDATE_IPS_PAGES_ALL = CANDIDATE_IPS_RAW + CANDIDATE_IPS_PAGES_V6
# 【2026-10-12 新增 · IPv6 闸门】本机没有 IPv6 出口线路时, IPv6 候选会整排超时:
# 每轮白占 8 个探测名额、体检变慢, "IPv6 可用 0/N" 也会永远难看。先探一下
# "本机到底有没有 IPv6 路由" —— UDP connect 不发任何包, 只查路由表, 立即返回,
# 开销约等于零。失败只关 60 秒(别被一次网络打嗝永久锁死), 成功缓存 5 分钟。
_IPV6_GATE = [None, 0.0] # [结果, 上次探测时刻]
_IPV6_GATE_LOCK = threading.Lock()
def local_has_ipv6():
now = time.time()
with _IPV6_GATE_LOCK:
res, ts = _IPV6_GATE
if res is not None and now - ts < (300 if res else 60):
return res
ok = False
for probe in ('2606:50c0:8000::154', '2a0a:a440::1'):
try:
s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
try:
s.connect((probe, 443)) # UDP 不发包, 仅查 IPv6 路由
ok = True
finally:
s.close()
if ok:
break
except OSError:
ok = False
with _IPV6_GATE_LOCK:
_IPV6_GATE[0], _IPV6_GATE[1] = ok, time.time()
return ok
# ── 手动指定 IP(2026-10-08 加)──────────────────────────────────────────────
# 现场救急用:自动探测一个都不通时,让用户填一个他这条线能连上的官方 IP。
# 存在数据目录里,重启后仍然生效;填空 = 清除。
MANUAL_IP_FILE = 'manual-ip.txt'
_MANUAL_IP = [''] # 内存缓存,免得每次都用磁盘
_META_SOURCE = [''] # 上次成功同步 IP 表的来源(体检信息里要报出来)
# 【2026-10-11 修·"网页又打不开"的最后一公里】
# 体检排第一的 IP 常常在两次体检之间悄悄死掉。实测 12:18:06 体检报
# "20.27.177.119 = 224ms 排第一",同一分钟直连它 TCP 就超时;而真正能通的
# 20.200.245.247 排在后面。转发若死守"体检名次"串行试错,就要先撞死 IP、
# 各等一轮超时 —— github 实测 8.15 秒才返回 200,浏览器体感就是"打不开"。
# 这里记下"上一次真正连通成功的 IP",下次转发优先复用:同一个 IP 几分钟内
# 基本还是通的,于是首次连接就命中,不再串行试错。
_LAST_GOOD = {'github': '', 'raw': '', 'pages': ''}
# 【2026-10-11 修·失败计数降权】体检每 5 分钟重排一次名次, 会把刚被证实**已经连不上**
# 的 IP 又排回第一(实测: 12:23 体检把 20.27.177.113 排第一, 12:26 转发又去撞它)。
# 这里的失败计数**跨体检周期保留**: 连续失败过的往后靠, 一旦成功就清零。
# 于是"时好时坏"的 IP 会自动让位给稳定的, 不用等下一次体检。
_FAIL_CNT = {}
_FAIL_LOCK = threading.Lock()
def _fail_note(ip):
with _FAIL_LOCK:
_FAIL_CNT[ip] = _FAIL_CNT.get(ip, 0) + 1
def _fail_clear(ip):
with _FAIL_LOCK:
_FAIL_CNT.pop(ip, None)
def _fail_rank(ip):
with _FAIL_LOCK:
return _FAIL_CNT.get(ip, 0)
# 【2026-10-30 新增·选路评分改写】
# 老写法排序只看 _fail_rank + _stall_rank(见 _connect_pool 里那行 sort), 于是那种
# "握手快、但丢包 67%/100%、正文搬不动/半途断流"的 IP 永远排在前头、每轮都抢赢 ——
# 反馈包里"最快 20.207.73.82(366ms) 实测丢包 67%、140.82.113.3/112.3 丢包 100%"就是它。
# 现在把**丢包抽样**和**实测吞吐**、**证据新鲜度**一起算进排序(丢包此前只展示不参与选路,
# 见 2216 行注释; 这次把它接进来)。分值越小越优先。
def _rank_key(ip):
if not ip:
return 1e9
# 握手/建连失败: 每欠一次 +1(这种 IP 连隧道都建不起来, 透明重试已兜过一遍,
# 仍失败就明确往后靠)
s = float(_fail_rank(ip))
stall = _stall_rank(ip)
if stall:
# 断流比单纯握手失败更坑用户: 建连成功、TLS 也握上了, 正文却搬不动或半途
# 被掐(本机实测 229KB / 278KB / 89KB / 527KB 那几发)—— 浏览器会卡在半路、
# 下载变砖, 比"直接打不开"还难受。一次就足够把它压到所有干净 IP 之后;
# 多一次再加码。CONNECT 架构下中途断流无法透明续传, 唯一的治本是别再选中它。
s += 10.0 * stall
_sb = _stall_bytes(ip)
# 最近一次连一个字节都没搬回来(<4KB, 近乎没开传): 几乎等于死 IP, 再追加重罚
if _sb >= 0 and _sb < _STALL_MIN_BYTES:
s += 20.0
loss, lts = loss_of(ip)
if loss is not None:
# 每 10% 丢包 +2 分; 100% 丢包直接 +20, 推到队尾(必断流/卡死的前兆)
s += (loss / 10.0) * 2.0
kbps = _kbps_of(ip)
if kbps:
# 实测越快越往前: 每快 60 KB/s 减 1 分(不低于 0)
s = max(0.0, s - kbps / 60.0)
# 既没吞吐台账、证据又旧(>2 倍吞吐 TTL)的往后靠, 逼着优先用刚验过的
if kbps is None and lts and (time.time() - lts) > _KBPS_TTL * 2:
s += 1.0
return s
# ------------------------------------------------------- 这次是池子赢还是直连赢
# 让"直连参与竞速"这件事在面板上看得见: 否则用户只看到快了, 不知道为什么快,
# 出问题时也没法判断是池子选错了还是外面那条路本来就堵。
_ROUTE = {} # key -> {'src': 'pool'|'sys', 'ip': ..., 'ts': ...}
def _note_route(key, ip, sys_ips):
src = 'sys' if ip in (sys_ips or ()) else 'pool'
_ROUTE[key] = {'src': src, 'ip': ip, 'ts': time.time()}
return src
# ------------------------------------------------------- 连续全灭 = 别再傻等
# 【2026-10-24 新增·快速失败(用户批准的改法②)】
# 用户的原始痛点: "国外仓库一直打不开", 而每发请求都要走完
# 抢连 4 秒 + 兜底系统解析 10 秒 ≈ 14 秒才拿到一个"连不上"。
# 一次就算了, 可浏览器打开 github 会并发十几条请求, 每条都这么等, 体感就是
# 整个站点卡死。这里记"全灭"的次数: 上一条请求池子和兜底一起死了, 说明
# **这条链路此刻根本不通**, 下一条兜底时没必要再等满 10 秒 —— 压到 3 秒,
# 让请求快速失败、让浏览器自己重试; 一旦连上就立刻恢复原来的宽松预算。
#
# 【2026-10-26 修·账要按时间窗记, 不能"一连通就清零"】
# 装机版今天的日志把老写法的漏洞暴露得很清楚(12:35~12:38 四分钟里 6 次):
# "连接 github.com 失败: timed out(兜底等了 10.0 秒, 连续第 1 次全灭)"
# 次次都是"第 1 次"、次次都等满 10 秒。原因是这条线是**时好时坏**而不是全死:
# 12:37:35 有一条请求"复用刚验过的通道"成功了 —— 按老规矩这一成功就把计数清零,
# 于是紧接着那几条撞死的请求又各自重新交一遍 10 秒学费(四分钟里白等 60 秒)。
# 现在改成记**时间戳**: 60 秒窗口内只要还有全灭的账, 预算就是 3 秒; 一次连通
# 只抹掉最旧的两笔(链路确实在好转), 全抹干净才恢复 10 秒。
_SYS_FAIL = {} # key -> [全灭时刻, ...](只留最近 _SYS_FAIL_WINDOW 秒内的)
_SYS_FAIL_LOCK = threading.Lock()
_SYS_FAIL_WINDOW = 60.0 # 记账窗口: 抽风一般持续几分钟, 60 秒内的账都还该起作用
_SYS_FAIL_HOLD = 20.0 # 一次连通只作废"距今超过 20 秒"的账, 20 秒内的仍然算数
_SYS_FAIL_SHORT_TIMEOUT = 3.0
_SYS_FAIL_FULL_TIMEOUT = 10.0
# 【2026-11-13 记·做过又撤掉的"熔断放行", 别再重做一遍】
# 想法: 60 秒内满 3 次全灭就"熔断"90 秒, 熔断期内主站请求直接关连接, 让浏览器按 PAC 的
# "; DIRECT" 走系统解析 —— 等于把用户"手动关掉加速"那一步自动化。
# 撤掉的原因(装机实测 16:00~16:18):
# 1) 一次都没触发: 并发十几条页面请求里只要有一条建连成功, _sys_fail_reset 就把熔断清掉,
# 熔断几乎活不到下一条请求(日志里 30 分钟 0 次"熔断中");
# 2) 就算触发也不解决问题: 本机直连 github.com 实测 21 秒超时, 放给 DIRECT 只是把"代理里
# 等 6~15 秒"换成"浏览器里等 21 秒", 体感反而更糟 —— 用户当天报"越修越糟糕"。
# 结论: 放行这条路不成立, 提速只能靠"挑一条真跑得完的通道"(见 _kbps_pick 前的断流否决)。
def _sys_fail_recent(key, now=None):
"""窗口内还有几笔全灭的账(0 = 这段时间一直是通的)。顺手把过期的账剪掉。"""
if now is None:
now = time.time()
with _SYS_FAIL_LOCK:
lst = _SYS_FAIL.get(key)
if not lst:
return 0
keep = [t for t in lst if now - t <= _SYS_FAIL_WINDOW]
if keep:
_SYS_FAIL[key] = keep
else:
_SYS_FAIL.pop(key, None)
return len(keep)
def _sys_fail_bump(key):
"""记一笔"刚刚整条链路全灭", 返回窗口内累计的第几笔。"""
now = time.time()
with _SYS_FAIL_LOCK:
lst = [t for t in _SYS_FAIL.get(key, []) if now - t <= _SYS_FAIL_WINDOW]
lst.append(now)
_SYS_FAIL[key] = lst
return len(lst)
def _sys_fail_reset(key):
"""连上了 —— 只作废"距今超过 _SYS_FAIL_HOLD 秒"的陈账, 20 秒内的仍然算数。
老写法在这里是 `_SYS_FAIL[key] = (0, now)` 直接清零, 于是时好时坏的链路永远
攒不起账: 今天 12:35~12:38 四分钟里 6 次"兜底等了 10.0 秒, 连续第 1 次全灭",
每一次都是刚被别的一条请求的成功清零过。现在一次成功最多让时间往前翻一页,
抹不掉刚刚那次全灭 —— 链路真恢复了(20 秒内没有新的全灭 + 又连通一次)才放宽。
"""
now = time.time()
with _SYS_FAIL_LOCK:
lst = [t for t in _SYS_FAIL.get(key, []) if now - t <= _SYS_FAIL_WINDOW]
lst = [t for t in lst if now - t < _SYS_FAIL_HOLD]
if lst:
_SYS_FAIL[key] = lst
else:
_SYS_FAIL.pop(key, None)
return len(lst)
def _sys_fail_budget(key):
"""退回系统解析时最多等多久: 近期全灭过就压短。
门槛为什么定"近期失败过"而不是"连着两次": 记的本来就是**整条链路全灭**
(池子抢连 + 系统解析一起死),一次就说明这条路径此刻不通,第二次再去等满
10 秒纯属重复交学费。恢复期靠 `_sys_fail_reset` 的衰减逐步放宽。
"""
if _sys_fail_recent(key) >= 1:
return _SYS_FAIL_SHORT_TIMEOUT
return _SYS_FAIL_FULL_TIMEOUT
# 轻探日志的节流: 页面一开就是十几条请求, 每条都记一行会把有用日志冲掉。
_FAST_LOG = {}
_FAST_LOG_LOCK = threading.Lock()
def _fast_log_ok(key, gap=30.0):
now = time.time()
with _FAST_LOG_LOCK:
if now - _FAST_LOG.get(key, 0.0) < gap:
return False
_FAST_LOG[key] = now
return True
# ------------------------------------------- 建连总时限 + 抢连闸门 (2026-10-26)
# 【为什么要限总时长】
# 本机 foxpath.log 12:35~12:38 那段抽风期里,一条打不开的请求要走完
# 抢连(4 秒) → 广撒网 16 个(1.5 秒) → 两轮轻探(1.5+2.2 / 2.2+2.6) → 兜底(10 秒)
# 端到端实测 **19.4 秒**才回一句"连不上"(AB 对照脚本 fp_ab_probe.py 第 14 轮)。
# 浏览器的耐心远短于此:转 20 秒用户就认为"网页打不开",关掉标签走人。
# 现在给每次建连一个总预算,走完"最该试的那一手"就到点收摊、明确记一行
# "建连到点放弃" —— 失败还是失败,但从 20 秒缩到 8 秒以内,用户点一下刷新
# 就能赶上下一条刚被后台验活的通道。
_CONNECT_DEADLINE = 8.0
# 【为什么要给抢连上闸门】
# _prescan 早就是全局单闸门(一轮只允许一批 HTTP 实测),可**TCP 抢连不是**:
# 浏览器开一个 GitHub 页面并发十几条 CONNECT,池子恰好没新鲜证据(fast)时,
# 每条请求各自对 6 个(全灭后广撒网是 16 个)IP 发起握手 —— 一条页面就是
# 90~250 个并发 SYN,全砸在同一条已经拥塞的国际链路上。10-11 记过的
# "并发 14 个探测互相挤占带宽"是同一个坑,只是规模这次由浏览器决定。
# 现在同一个池同时只放**一条**请求做抢连;其余的先等它的结论(最多 _RACE_WAIT
# 秒,抢到刚验活的通道就复用),等不到就只单独轻试一个 IP,不再放第二批 SYN。
_RACE_LOCK = threading.Lock()
_RACE_BUSY = {} # key -> 占用时刻(用来识别"占用者挂了没交还")
_RACE_STALE = 12.0 # 超过这么久没交还就当他已经不在了,闸门必须能自己解开
_RACE_WAIT = 2.5 # 别人在抢连时,我最多等它的结论这么久
_RACE_WAIT_SLICE = 0.1
def _race_take(key):
"""占住这个池的抢连名额;已经被别人占着就返回 False(不排队占坑,别拖)。"""
now = time.time()
with _RACE_LOCK:
since = _RACE_BUSY.get(key)
if since is not None and now - since < _RACE_STALE:
return False
_RACE_BUSY[key] = now
return True
def _race_release(key):
with _RACE_LOCK:
_RACE_BUSY.pop(key, None)
def _race_gate_left(key):
"""名额什么时候能空出来(给人判断还要不要等); 没人占就是 0。"""
now = time.time()
with _RACE_LOCK:
since = _RACE_BUSY.get(key)
if since is None:
return 0.0
if now - since >= _RACE_STALE:
return 0.0
return max(0.0, _RACE_STALE - (now - since))
# ==================================================== 网页级实测台账 (2026-10-26)
# 【为什么要在体检之外再记一本账】
# 面板那格"可用 31/50"是**体检**数出来的: 挨个 IP 做 TCP + TLS + GET /, 答了话就算
# 可用。2026-10-26 下午把这把尺子的漏洞钉死了(本机 foxpath.log + 同一分钟手工实测):
# · 16:42 体检报"可用 6/50", 同一分钟手工实测 github.com/kele551/hotnews 走代理:
# 6 发里 5 发直接被回 502, 剩下 1 发 13.5 秒才回首字节;
# · 手工触发全池重测之后变 5 发 3 中(3.4s / 4.2s / 9.7s), 另外 2 发是
# **收到 229KB / 278KB 之后断流** —— 握手对、证书对、GET / 也答了, 正文下到一半死。
# 结论: "IP 可用"和"网页打得开"是两本账, 后者才是用户感知的那一本。所以这里只记
# **真实转发**(浏览器那条 CONNECT)的结局, 一条探测都不掺:
# ok —— 隧道建起来、也确实往浏览器搬过数据, 最后由一端正常收尾
# stall —— 连上了但正文没搬动: 一个字节都没来 / 半途断流 / 空闲到点
# refused —— 建连就没成, 代理只能回 502
# badgw —— 对面回 4xx/5xx 假前门(证书对、页面必定报错)
WEB_OK, WEB_STALL, WEB_REFUSED, WEB_BADGW = 'ok', 'stall', 'refused', 'badgw'
_WEB_LEDGER = {} # key -> [(ts, kind, ms, nbytes)] 只留最近 _WEB_MAX 条
# 【2026-10-30 学 FastGithub 的实时流量统计】本次会话的总账, 只加不减(不像台账那样过窗就丢)。
# 面板「本次会话」那一格与一键反馈包都读它 —— 用户问得最多的其实不是"有几个 IP 活着",
# 而是"你到底帮我干了多少活、有没有白忙"。
_SESS = {'tunnels': 0, 'bytes': 0, 'ok': 0, 'fail': 0}
_WEB_LOCK = threading.Lock()
_WEB_WINDOW = 180.0 # 只按最近 3 分钟判定 —— 这条链路半衰期就几分钟, 老账会骗人
_WEB_MAX = 60
_WEB_MIN_SAMPLES = 3 # 少于 3 次不下结论, 只说"样本还少"
# 【2026-10-26 三点整改·① 配套的两个数】
_CONNECT_DEADLINE_MAX = 20.0 # 实测"慢但能成"时允许等多久(浏览器等得起, 用户等不起 30 秒)
_CONNECT_RETRY_ROUNDS = 2 # 一轮全灭之后, 预算还剩就再转几轮(每轮都过抢连闸门)
_CONNECT_RETRY_MIN_LEFT = 2.2 # 剩余预算少于这个就不再开新一轮
_CONNECT_RETRY_BACKOFF = 0.6 # 每轮起跑前等这么久, 给 hot_refresh/自愈把新鲜结论写进来的时间
_DIRECT_PROBE_FRESH = 150.0 # 直连探测结果的有效期(后台每 60 秒探一次, 取 2.5 个周期)
def _pool_key_for(host):
"""这个域名归哪个池管 —— 建连时限、网页台账、日志都要按池取, 别再各写一份判断。"""
try:
if is_raw_host(host):
return 'raw'
if is_pages_host(host):
return 'pages'
if is_github_host(host):
return 'github'
except Exception:
return ''
return ''
def _web_record(key, kind, ms=0.0, nbytes=0):
"""记一笔真实转发的结局。key 为空(非 GitHub 域名)时不记, 免得脏了三池的账。
顺手给"本次会话"累一笔总账(_SESS) —— 这本账只加不减、不过窗口就过期,
为的是面板能回答"这次帮用户搬了多少东西"。放在这儿是因为这是转发结果
**唯一**的落点: 热路径不用新加锁(跟着 _WEB_LOCK 走), 也漏不了一笔。
"""
if not key or kind not in (WEB_OK, WEB_STALL, WEB_REFUSED, WEB_BADGW):
return False
now = time.time()
with _WEB_LOCK:
rows = _WEB_LEDGER.setdefault(key, [])
rows.append((now, kind, max(0.0, float(ms or 0.0)), int(nbytes or 0)))
if len(rows) > _WEB_MAX:
del rows[:-_WEB_MAX]
# 顺手剪掉过窗口的老账(只留 _WEB_WINDOW 秒内的)
stale = [r for r in rows if now - r[0] > _WEB_WINDOW]
if stale:
_WEB_LEDGER[key] = rows = rows[len(stale):]
_SESS['tunnels'] += 1
_SESS['bytes'] += int(nbytes or 0)
if kind == WEB_OK:
_SESS['ok'] += 1
else:
_SESS['fail'] += 1
return True
def sess_stats():
"""本次会话的总账(给面板与反馈包)。字节只算隧道里从上游搬回来的那一段。"""
with _WEB_LOCK:
return dict(_SESS)
def _human_bytes(n):
n = int(n or 0)
if n < 1024:
return '%d B' % n
if n < 1024 * 1024:
return '%.1f KB' % (n / 1024.0)
if n < 1024 * 1024 * 1024:
return '%.1f MB' % (n / 1024.0 / 1024.0)
return '%.2f GB' % (n / 1024.0 / 1024.0 / 1024.0)
def sess_line():
"""一行话: 这次会话搬了多少、成几次、败几次。"""
s = sess_stats()
return ('本次会话转发 %d 条隧道 · 搬回 %s · 成 %d 败 %d'
% (s['tunnels'], _human_bytes(s['bytes']), s['ok'], s['fail']))
def _web_rows(key, now=None):
"""窗口内的真实转发样本( oldest → newest )。"""
if now is None:
now = time.time()
with _WEB_LOCK:
rows = list(_WEB_LEDGER.get(key) or [])
return [r for r in rows if now - r[0] <= _WEB_WINDOW]
def _web_median_ms(rows):
ok = sorted(r[2] for r in rows if r[1] == WEB_OK)
if not ok:
return None
n = len(ok)
return ok[n // 2] if n % 2 else (ok[n // 2 - 1] + ok[n // 2]) / 2.0
def _web_counts(key, now=None):
"""(总数, 成, 断流, 连不上, 假前门, 中位耗时秒 or None) —— 判定的唯一口径。"""
rows = _web_rows(key, now)
n = len(rows)
ok = sum(1 for r in rows if r[1] == WEB_OK)
stall = sum(1 for r in rows if r[1] == WEB_STALL)
refused = sum(1 for r in rows if r[1] == WEB_REFUSED)
badgw = sum(1 for r in rows if r[1] == WEB_BADGW)
med = _web_median_ms(rows)
return n, ok, stall, refused, badgw, (None if med is None else med / 1000.0)
def _web_verdict(key, now=None):
"""把真实转发的账换算成一句人话: (代码, 文字, 明细)。
门槛为什么这么定: 这条链路是**时好时坏**, 一次成功说明不了什么, 一次失败也
说明不了什么, 所以按窗口内的**成功占比**分档, 而不是"最后一条成没成"。
样本不足 3 条时不下结论 —— 装机后第一次点开页面就被判"打不开"是冤枉。
"""
n, ok, stall, refused, badgw, med = _web_counts(key, now)
last = ('%.1f 秒' % med) if med is not None else '—'
if n == 0:
return ('unknown', '还没实测', '最近 %d 分钟没有真实网页请求' % int(_WEB_WINDOW / 60))
rate = float(ok) / n
if ok == 0:
return ('down', '打不开',
'%d 次全失败: 连不上 %d · 断流 %d · 假前门 %d' % (n, refused, stall, badgw))
if n < _WEB_MIN_SAMPLES:
return ('few', '样本还少', '最近只跑了 %d 次(成 %d),暂不下结论' % (n, ok))
if rate >= 0.8:
return ('good', '能打开', '最近 %d 次成 %d · 中位 %s' % (n, ok, last))
if rate >= 0.5:
# 措辞对着账走: 这一格现在唯一的职责是说实话。
# 失败主要发生在握手阶段(refused)却说"常断流",等于把用户往错误的方向上支使
# (断流→刷新两下就好;连不上→多半是整条链路的事)。
# 计数一律用"连不上"而不是"502": 502 是浏览器上看到的结果, 连不上才是原因,
# 明细里再说一次 502 会让人以为有两种不同的故障。
lab = max((('断流', stall), ('连不上', refused), ('假前门', badgw)),
key=lambda x: x[1])[0]
return ('slow', '能打开,但常%s' % lab,
'最近 %d 次成 %d · 断流 %d · 连不上 %d · 假前门 %d · 中位 %s'
% (n, ok, stall, refused, badgw, last))
return ('flaky', '时通时断',
'最近 %d 次只成 %d · 连不上 %d · 断流 %d · 假前门 %d' % (n, ok, refused, stall, badgw))
def _web_status(key, now=None):
"""/api/status 与面板用的一等公民指标: 最近 3 分钟**真实转发**的结论。"""
n, ok, stall, refused, badgw, med = _web_counts(key, now)
code, text, detail = _web_verdict(key, now)
return {'code': code, 'text': text, 'detail': detail, 'n': n, 'ok': ok,
'stall': stall, 'refused': refused, 'badgw': badgw,
'median_ms': (None if med is None else int(med * 1000)),
'window': int(_WEB_WINDOW)}
def _web_dl_budget(key, base=None, now=None):
"""这次建连最多等多久 —— 用真实转发的账来定, 不再拍一个死数。
· 最近成功的那几发本身就要 6~13 秒(本机 16:43 实测), 还按 8 秒一刀切就会把
"再等两三秒就轮到刚验活的通道"那一批直接判死, 浏览器只收到一句 502;
所以按实测中位给两倍余量, 上限 _CONNECT_DEADLINE_MAX。
· 反过来, 窗口内几乎全灭时**不要**把预算抬到 20 秒: 那正是链路拥塞的时候,
每条请求都等满 20 秒等于自己压自己(_prescan / hot_refresh 记过同一个坑),
8 秒到点撒手、把带宽留给后台重测才对。
"""
base = _CONNECT_DEADLINE if base is None else base
rows = _web_rows(key, now)
med = _web_median_ms(rows)
out = base
if med is not None:
out = max(base, min(_CONNECT_DEADLINE_MAX, med / 1000.0 * 2.0))
n = len(rows)
fail = sum(1 for r in rows if r[1] != WEB_OK)
if n >= 4 and fail and float(fail) / n >= 0.8:
out = min(out, base)
return out
def _direct_is_dead(key='github', now=None):
"""后台那条"绕开狐径直连 github"的探测刚说过不通 —— 就别把预算押在它身上。
判据只用**新鲜**的探测结果(_DIRECT_PROBE_FRESH 秒内), 过期当"不知道",
保持老行为去赌一把兜底: 那条路本来就是"和没装本程序一样"的退路。
"""
if now is None:
now = time.time()
if _DIRECT_OK[0] is not False:
return False
return (now - (_DIRECT_OK[1] or 0.0)) <= _DIRECT_PROBE_FRESH
# ------------------------------------------------------- 断流 IP 的账(和握手失败分开)
# 【2026-10-26 三点整改·②】
# _FAIL_CNT 记的是"握手/建连失败", 一次失败降一名、几次失败靠边站, 这个口径没问题。
# 但"连上了、TLS 也做完了、正文下到一半死掉"是**另一回事**: 这种 IP 在 _FAIL_CNT 里
# 一分钱不欠(握手确实成功了), 于是体检名次照旧靠前、抢连一抢就赢, 赢完浏览器就转圈。
# 本机 2026-10-26 实测的两发就是这样: 收到 229KB / 278KB 之后断流。
# 这里单独记一本账, 只用来**降权**和**让它从池子里下岗**, 不学假前门那样拉黑 15 分钟
# —— 它是时好时坏, 拉黑太狠会把仅有的几个候选也清空(2026-10-25 记过"池子被清空要等
# 5 分钟才补上人"的坑)。
_STALL = {} # ip -> [(时刻, 那次搬了多少字节), ...]
_STALL_LOCK = threading.Lock()
_STALL_WINDOW = 300.0 # 5 分钟内的断流还算数
_STALL_MIN_BYTES = 4096 # 不到 4KB 就断 = 根本没开始传, 比半途断流更差, 权重加倍
_STALL_DEMOTE_RANK = 1 # 有过一次断流就开始降权
_STALL_DROP_POOL = 2 # 窗口内两次断流 = 请出候选池(下一轮体检还会把它捞回来)
def _stall_note(ip, nbytes=0, now=None):
if not ip:
return 0
if now is None:
now = time.time()
with _STALL_LOCK:
lst = [x for x in _STALL.setdefault(ip, []) if now - x[0] <= _STALL_WINDOW]
lst.append((now, int(nbytes or 0)))
_STALL[ip] = lst
return len(lst)
def _stall_rank(ip, now=None):
"""这个 IP 最近断流了几次(顺手把过期账剪掉)。用来和 _fail_rank 一起排候选。"""
if not ip:
return 0
if now is None:
now = time.time()
with _STALL_LOCK:
lst = _STALL.get(ip)
if not lst:
return 0
keep = [x for x in lst if now - x[0] <= _STALL_WINDOW]
if keep:
_STALL[ip] = keep
else:
_STALL.pop(ip, None)
return len(keep)
def _stall_bytes(ip, now=None):
"""最近一次断流时搬了多少字节(0 = 正文压根没来)。没账给 -1。"""
if now is None:
now = time.time()
with _STALL_LOCK:
lst = [x for x in (_STALL.get(ip) or []) if now - x[0] <= _STALL_WINDOW]
return lst[-1][1] if lst else -1
def _stall_clear(ip):
with _STALL_LOCK:
_STALL.pop(ip, None)
# --------------------------------------------------------------- IP 寿命台账
# 【2026-10-11 修·"多久会被锁死、在锁死之前就换掉"】
# 用户的核心诉求不是"死了能救回来",而是**别让它死在手上**:救回来要等超时
# (实测 8~16 秒),浏览器体感就是"打不开";而提前换掉是零感知的。
# 做法是给每个 IP 记一本寿命台账:
# _IP_BORN 这个 IP 第一次**验证成功**的时刻
# _LIFE_SAMPLES 它们各自"从出生到证实死亡"活了多久(秒)
# 于是能算出"这类 IP 平均能撑多久",并在用到 80% 寿命时主动让位给热备 ——
# 也就是**在锁死之前下手**。
_IP_BORN = {} # ip -> 出生时间戳
_LIFE_SAMPLES = [] # 每个元素 = 某个 IP 从出生到死亡的存活秒数
# 冷启动基准 = 300 秒。这不是拍脑袋: 用户实测"结束进程重启又打开了、大概维持了
# 5 分钟", 而第一轮自己的样本要等几个 IP 死掉才攒得出来 —— 在那之前先用这个
# 经验值顶上, 让预防性轮换**从第一次转发就生效**, 而不是先挨几轮锁死攒数据。
DEFAULT_LIFE = 300.0
_AVG_LIFE = [DEFAULT_LIFE]
_IP_LIFE_LOCK = threading.Lock()
# 热备池: 后台持续复检、保证"随时能顶上"的 IP。
# 体检是 5 分钟一轮的全池扫描, 中间那 5 分钟里名次是会过期的;
# 热备池只盯少数几个候选、20 秒复检一次, 始终有一批**刚刚验过**的 IP 待命。
_HOT = {'github': [], 'raw': [], 'pages': []} # key -> [(ip, 复检时间戳)]
_HOT_LOCK = threading.Lock()
# ---- IP 吞吐台账: 每个 IP 量出来的真实下载速度 --------------------------------
# 【2026-10-11 修·把吞吐判据下沉到池内】
# 上一轮只修了"直连 vs 候选池"这一层对决, 池内几十个 IP 之间仍然按 TCP 握手
# 抢连 —— 于是实测出现"后台明明判池内有 194 KB/s, 端到端却走了 10.87 秒",
# 因为抢连让某个握手快、传不动的 IP 赢了。
# 现在给**每个验过的 IP** 都记一笔吞吐, 转发时先看台账: 谁实测最快就直连谁,
# 不再让握手速度替我们做决定。只测主站(github.com): raw / Pages 的 GET / 返回
# 404、拿不到字节, 硬测会误判成"传不动"。
_KBPS = {} # ip -> (kbps, ts)
_KBPS_LOCK = threading.Lock()
_KBPS_TTL = 150.0 # 吞吐会随时间变, 超过这个时间没重测就不作数
_KBPS_DIRECT_MIN = 60 # 低于这个速度谈不上"快", 不值得绕开抢连
_KBPS_WIN_RATIO = 1.15 # 池内取最快时要领先次快 15%, 否则可能是测量噪声
# 【2026-10-24 新增】"实测成过"的新鲜窗口。定 90 秒的依据是这台机器的实测:
# IP 寿命台账跑出来 148~227 秒(面板"IP 寿命基准"那一行), 而体检周期最长 5 分钟 ——
# 窗口取到寿命的一半以内, 才不会把"上一轮活着、这一轮早死了"的 IP 算成能打开页面。
_FRESH_WINDOW = 90.0
# 【2026-11-13 新增】"复用刚验过的通道"这条快路自己的窗口, 比 _FRESH_WINDOW 收得更紧。
# 依据(本机 foxpath.log 2026-11-13):
# 15:38:41 复用刚验过的通道 172.182.252.133(证据 19 秒前), 跳过握手抢连
# 15:38:43 在用 IP 172.182.252.133 复检已不通, 提前下岗
# → 这条请求被一路拖到 15 秒静默看门狗才断, 用户体感就是"网页卡着不动";
# 而同一分钟手动关开加速(= 清掉这份信任)之后, 轻探 498ms 就找到活的通道。
# 链路抽风时 90 秒前的证据根本不算证据。一次页面打开十几个并发请求都在 20 秒内跑完,
# 所以 25 秒足够覆盖同一波; 超过就重验一次 —— 重验只花 1.5~2 秒, 比撞死路便宜。
_TRUST_FRESH = 25.0
# 【2026-10-24 新增·"证书对、页面不给你"的那类 IP,实测拉黑】
# 起因:装机版跑起来后 github.com 满屏 400("Bad request · GitHub"),可狐径自己一路绿灯。
# 实测(本地复现脚本 fx_probe_replica,一次性探针、不入库,连打 8 发)抓到两个这样的 IP:
# 20.200.245.244 → 8/8 次 400(握手最快,体检报 256ms)
# 20.205.243.161 → 8/8 次 400(体检报 281ms)
# 20.200.245.247 / 140.82.113.4 / 20.29.134.23 / 4.249.131.164 / 20.205.243.166 → 8/8 次 200
# 它们是 Azure 前门里**不负责 github.com 页面**的节点:TCP 通、TLS 通、证书里也确实有
# github.com,所以体检只要有一发把它放过去,抢连(只比握手快慢)就会反复选中它。
# 而转发是加密隧道,里面那个 400 我们根本看不见 —— 日志一切正常,用户却打不开仓库。
_BAD_EDGE = {} # ip -> (拉黑到期时刻, 实测到的状态码)
_BAD_EDGE_TTL = 900.0 # 15 分钟后放它重新参加体检(这类节点会换人)
_BAD_EDGE_LOCK = threading.Lock()
def _bad_edge_note(ip, status=''):
"""实测回 4xx/5xx 的 IP 记进黑名单,带上状态码好从日志里看出为什么被拉黑。"""
if not ip:
return
with _BAD_EDGE_LOCK:
_BAD_EDGE[ip] = (time.time() + _BAD_EDGE_TTL, status)
def _bad_edge(ip):
"""这个 IP 还在黑名单里吗?过期就顺手清掉。"""
with _BAD_EDGE_LOCK:
exp = _BAD_EDGE.get(ip)
if not exp:
return False
if time.time() >= exp[0]:
_BAD_EDGE.pop(ip, None)
return False
return True
# 【2026-10-24 三修·④b 探针要有全局闸门】
# ④b 那轮"先发 GET / 问谁真答话"是**每条转发请求各自起一个并发 3~4 的批次**。
# 单看一条请求没错,可浏览器打开一个 GitHub 页面就是十几条并发请求:池子刚好
# 处于 fast(90 秒没 HTTP 证据)时,就是十几个批次同时探同一批 IP —— 探测自己把
# 国际链路挤拥塞,fast 就更解不开,单请求从 ~12 秒涨到 ~16 秒以上。
# 这和 10-11 记过的"并发 14 个探测互相拖慢"是同一个坑,只是这次规模由浏览器决定。
# 全局只放一轮在跑:抢不到闸门的那些请求**直接跳过实测**、按老路做 TCP 抢连,
# 不多等一秒(实测批次本身要 1.5~3.5 秒,串行排队只会更糟)。
_PRESCAN_LOCK = threading.Lock()
_PRESCAN_BUSY = [False]
def _prescan_take():
"""占④b 探针的坑;已经有人在测就返回 False(调用方跳过实测,别排队等)。"""
with _PRESCAN_LOCK:
if _PRESCAN_BUSY[0]:
return False
_PRESCAN_BUSY[0] = True
return True
def _prescan_release():
with _PRESCAN_LOCK:
_PRESCAN_BUSY[0] = False
def _kbps_set(ip, kbps):
if not ip or not kbps:
return
with _KBPS_LOCK:
_KBPS[ip] = (kbps, time.time())
def _kbps_of(ip):
"""这个 IP 的实测吞吐; 没量过或已过期返回 None。"""
if not ip:
return None
with _KBPS_LOCK:
hit = _KBPS.get(ip)
if not hit:
return None
kbps, ts = hit
if time.time() - ts >= _KBPS_TTL:
return None
return kbps
def _kbps_ts(ip):
with _KBPS_LOCK:
hit = _KBPS.get(ip)
return hit[1] if hit else 0.0
def _kbps_forget(ip):
"""这个 IP 刚让我们赌输了 —— 立刻作废它的证据, 别让下一次转发再赌一遍。
【2026-10-11 修·赌输要付学费, 但只付一次】
实测第 6 次经狐径 28.01s、直连只要 21.07s: 台账里那笔"107 KB/s"还在
150 秒有效期内, 可链路其实已经断了, 于是每一发请求都先白等一轮超时。
赌输一次就把它忘了, 代价从"每次都赔"降到"只赔一次"。
"""
if not ip:
return
with _KBPS_LOCK:
_KBPS.pop(ip, None)
def _kbps_pick(cand):
"""从候选里挑"实测吞吐最快"和"次快"的两个 —— 用来决定是否跳过抢连、直接连。
没量过 / 证据过期 / 最近在连着失败的, 一律不参与。
"""
best, second = None, None
for x in cand or ():
k = _kbps_of(x)
if not k or _fail_rank(x):
continue
if best is None or k > best[1]:
second, best = best, (x, k)
elif second is None or k > second[1]:
second = (x, k)
return best, second
def _ip_born(ip):
"""IP 第一次被证实可用 —— 记下出生时刻。"""
if not ip:
return
with _IP_LIFE_LOCK:
if ip not in _IP_BORN:
_IP_BORN[ip] = time.time()
def _ip_died(ip, sure=False):
"""IP 已证实失效: 结掉它这一生, 把"活了多久"记进样本。
2026-10-11 实测抓到的漏子: 热备复检发现**当前正在用的那个 IP** 已经不通,
把它从寿命台账里注销了, 却**没让它下岗** —— _LAST_GOOD 还指着它, 于是
下次转发照样优先去撞这个死 IP, 而且面板还显示它"刚出生、还能撑满一整段"。
所以这里一旦发现死的就是在用的那个, 立刻让它下岗。
"""
if not ip:
return
for _k in ('github', 'raw', 'pages'):
if _LAST_GOOD.get(_k) == ip:
_LAST_GOOD[_k] = ''
log('在用 IP %s 复检已不通, 提前下岗(下次转发改抢新鲜的)' % ip)
with _IP_LIFE_LOCK:
born = _IP_BORN.pop(ip, None)
if not born:
return
life = time.time() - born
# 太短的多半是偶发丢包误判, 计进去会把平均值拉得毫无意义
if life >= 10:
_LIFE_SAMPLES.append(life)
if len(_LIFE_SAMPLES) > 40:
_LIFE_SAMPLES.pop(0)
_AVG_LIFE[0] = sum(_LIFE_SAMPLES) / len(_LIFE_SAMPLES)
def _avg_life():
with _IP_LIFE_LOCK:
return _AVG_LIFE[0]