本机统一网络代理层 + AI 软件网络适配 + 密钥治理(Windows / PowerShell 工具链)。
- 一个入口:mihomo(Clash.Meta)承担全部代理职责;端口表固定,冲突即报错(见 docs/PORTS.md)
- 订阅管线:
nodes refresh聚合多源免费订阅、剥离危险字段,生成data/merged.yaml - 档位管理:
apply -profile direct|proxy|github切换系统代理 / Git / npm / 环境变量,带快照可回滚 - LLM 网关:本机 new-api 实例(端口表 8093)作为模型 API 统一入口
- AI 可达性:
netenv-mcp.ps1提供只读、输出脱敏的 MCP 状态检测 - 密钥治理:订阅 URL 只进 Windows 凭据管理器;GitHub token 验证 / 清理;AES-256 7z 归档(见 docs/SECRETS.md)
.\netenv.ps1 bootstrap # 首次:镜像下载 mihomo / new-api 并 SHA256 校验
.\netenv.ps1 doctor # 只读体检
.\netenv.ps1 status # 状态摘要
.\netenv.ps1 config set-secret NetEnv/sub-free-nodes <订阅URL> # 订阅 URL 只进凭据管理器
.\netenv.ps1 nodes refresh # 聚合订阅 → data/merged.yaml
.\netenv.ps1 apply -profile proxy命令全表:doctor / status / apply / nodes / clients / adopt / migrate / setup / install / uninstall / bootstrap / clean / config / secrets / mcp,详见 .\netenv.ps1 --help。
netenv.ps1 主入口(调度器 + 实例锁)
netenv-mcp.ps1 MCP stdio server(JSON-RPC 2.0,默认只读)
fix-browser-proxy.ps1 一键切档修"浏览器打不开 GitHub"(github 档 systemProxy=false,浏览器直连即超时)并做落盘验收
lib/ core.ps1 + 命令模块 + supervisor/supervisor-loop(常驻自愈)+ run-supervisor-hidden.vbs(零窗口启动器)
config/ netenv.json / sources.json / clients.json(默认值,不含密钥)
docs/ 文档(见下方索引)
tests/ Pester 测试与回归脚本
data/ logs/ backups/ export/ 运行态与归档(.gitignore,不入库)
- GitHub 可用性保底:
github.com、两个内容域(raw.githubusercontent.com/objects.githubusercontent.com)以及三个凭据端点 (api.github.com/codeload.github.com/ssh.github.com)全部交给github-adaptive组,组内同时持有github-node/proxy-select/DIRECT, 按 github 自身端点探活后自动择通择优 —— 直连抖动时自动改走节点,节点挂了自动回直连。 凭据端点原设计强制DIRECT,2026-09-11 因"直连抖动时没有任何退路,git clone与 device-flow 登录直接失败"改为同组保底。⚠️ 知情选择:可用性优先,等于接受凭据流量经免费节点(TLS 仍端到端加密,但出口节点可见目标元数据);回退步骤见 docs/GITHUB-SAFETY.md。 github-adaptive组的选点依据必须是 github 自身端点,默认https://github.com/robots.txt,可由config/netenv.json的subscription.githubUrlTest.url覆盖。 用google/gstatic之类的通用 URL 会得出错误结论 —— 节点能通 google 不代表能通 github(实测同一时刻 DIRECT 探活 504 而节点 921 ms)。- GPT 长期可达(地区判据,不能只按延迟选点):
chatgpt.com/openai.com/oaistatic.com/oaiusercontent.com/chatgpt.livekit.cloud统一交给gpt-adaptive组(组内gpt-node/proxy-select/DIRECT)。⚠️ 不能沿用通用组"按延迟选点":实测 mihomo 的/delay只看连通、不看状态码(api.openai.com的 401/403、chatgpt.com的 403 挑战都返回正延迟),于是延迟最低的香港节点必被选中 —— 而香港是 OpenAI 不支持地区, 症状是 TCP/TLS 全通、api.openai.com却返回unsupported_country_region_territory。 实测同一次重测速就把出口从 SG(401 可用)换成 HK(403 不可用)。 因此gpt-node是 select 而非 url-test:由supervisor-loop的 GPT 专项探针用真实响应码逐个复测候选 (200/401/429=可达;403=不可达,并区分是否地区封锁),第一个通过者胜出并写入data/gpt-state.json, 每轮重放(mihomo 重载后 select 组会回到默认成员)。⚠️ 控制器 JSON 不带 charset,节点名含 emoji:PS 5.1 的Invoke-WebRequest会按 ISO-8859-1 解码成乱码, 再拿去选节点必然匹配不到 —— 涉及节点名的控制器调用一律走Get-NetEnvJsonUtf8/Invoke-NetEnvJsonPut。 - 敏感域名直连:
sensitiveDomains + githubAuthDomains生成DOMAIN-SUFFIX,<domain>,DIRECT规则。⚠️ 同一域名不得同时出现在直连列表与githubAdaptiveDomains:DIRECT 规则在前会胜出,自适应规则永远不可达(曾是真实缺陷)。 - 订阅仅 HTTPS,剥离
script等危险字段;订阅 URL 存 Windows 凭据管理器,不入盘 - 代理面不对外:mihomo 的
mixed-port/http/external-controller均绑定127.0.0.1,allow-lan: false - TLS 协议必须含 TLS 1.3:
core.ps1顶部Set-NetEnvTlsProtocol把Tls13并进系统原有协议位(SystemDefault→Tls13;已是Tls12的机器 →Tls12, Tls13),枚举不存在时降级到Tls12且不抛错。⚠️ 本机链路上 TLS 1.2 握手被干扰:2026-10-14 实测三个订阅源在nodes refresh里全部报基础连接已经关闭: 发送时发生错误,而同一 URL 走curl.exe直连返回 http=200 并取回完整正文(277776 / 640791 / 21674 字节);在 .NET 里显式指定Tls12仍失败、改Tls13立刻 200。 症状是"订阅看起来过期、GitHub 却还能打开"——能打开只是github-adaptive组的DIRECT成员在兜底,924 个节点实际全部不可达,节点池冻结 106 小时、doctor报 4 项红而status照样报绿。 修正前请先确认:curl.exe能取回正文而Invoke-WebRequest不能,就是本缺陷;不要先去怀疑订阅 URL 失效或节点全挂。 另注:这类干扰是间歇性的(同一批 URL 连续两轮,一轮 2/6 失败、一轮全成),因此nodes refresh对每个源做 3 次重试;单源失败率约 33% → <5%。 .ps1一律带 UTF-8 BOM:脚本含中文,且计划任务以powershell.exe(Windows PowerShell 5.1)-File方式执行;无 BOM 时 5.1 会按 ANSI/GBK 解码导致中文乱码。改动任何.ps1后请复查 BOM。 检查/修补用.\tests\ensure-bom.ps1 -Check(只检查)或.\tests\ensure-bom.ps1(补 BOM,幂等);tests\regression.ps1已把它作为前置守卫, 缺 BOM 时直接中止并点名文件。2026-10-06 实测过一次自伤:编辑工具剥掉 4 个lib\*.ps1的 BOM,生产自愈循环连续 7 分钟报所在位置 ...core.ps1:260 字符: 29,日志里还夹着中文乱码 —— 这类"中文错误文本 + 字符偏移"一律先怀疑无 BOM,而不是逻辑错误。- 兼容 Windows PowerShell 5.1:环境可能只有
powershell.exe而无pwsh.exe。因此脚本内不得使用 PS7 专属语法(::new()构造、??、三元? :、-Parallel等),统一用New-Object与显式if/else。 - 路径全部通用化:不写死用户名或安装盘符 —— 用户目录用
$env:USERPROFILE,仓库内部用$PSScriptRoot推导,外部工具(7z)用Get-NetEnvSevenZip探测,客户端注入点用%USERPROFILE%占位符。换机只需改config/netenv.json。 - 可选外部依赖缺失必须降级而非崩溃:
npm、gh、7-Zip 等未安装时跳过该步并记 WARN;在$ErrorActionPreference='Stop'下直接调用不存在的命令会终止整条流程。
- 双层健康判据:
supervisor.ps1只负责进程存活(端口在听);supervisor-loop.ps1另按health.probeIntervalMinutes做端到端出品探针(真实经代理请求health.probeUrls,任一目标可达即算可用)。 端口在听 ≠ 能上网 —— 实测 423 个节点中仅 8 个能到 google,而端口照样 LISTEN。 判据取多目标 OR 而非单目标:单探 google 会把"google 被墙、github 正常"误判成整机出品不可用, 从而每 5 分钟空刷一次订阅源(真实故障,见logs/20260917.log)。 - github 专项探针(判据独立于上面的 OR):
health.githubProbe.enabled为真时,supervisor-loop每轮再单独探一次 github(默认https://github.com/robots.txt),连败达githubProbe.failThreshold即触发该组重新测速择通(Invoke-NetEnvGithubGroupRecovery→GET /group/<组>/delay),复测通过则记 INFO,recoverMinIntervalMinutes负责退避。- 为什么必须独立:出品判据是 OR,google 通时 github 单独挂掉不会触发任何自愈 ——
实测 2026-09-17
github.com经代理握手失败(git push/clone全废),而health-state.json一路lastOk。 - 为什么恢复动作不是刷订阅:
github-adaptive组是lazy: true(没流量就不测速),一旦某轮被判定alive:false就再没有流量进来、也就永远不再测速,坏状态被冻结;而组内节点其实健康 (同一次测速实测github-node595ms /proxy-select686ms)。触发一次组测速即恢复,代价远低于刷订阅。
- 为什么必须独立:出品判据是 OR,google 通时 github 单独挂掉不会触发任何自愈 ——
实测 2026-09-17
- GPT 专项探针(判据独立,且带"地区"语义):
health.gptProbe为真时supervisor-loop每轮单独探https://api.openai.com/v1/models,连败gptProbe.failThreshold次即按地区判据轮换gpt-node(Invoke-NetEnvGptNodeRotation:先按auto-urltest全池延迟取前 N 个候选,再逐个用真实响应码复测), 复测通过记 INFO,recoverMinIntervalMinutes负责退避。⚠️ gptProbe.failThreshold默认取 1(2026-09-29 实测后收紧):免费节点会闪断 —— 实测 11:35 钉住的hysteria2-1232696196在 11:58 单次探针失败后进入"待判窗", 要再等一个探针周期(5 分钟)才够 2 次失败触发轮换,而用户正好卡在这 5 分钟里(12:00 报"GPT 卡住", 12:03:50 才重放/轮换)。因此 GPT 单次失败即轮换,recoverMinIntervalMinutes由 10 收到 5 (轮换内的全池测速不便宜,靠退避防止硬锤)。副作用:单次抖动也切节点,换来的是"卡顿窗口 ≈ 探针周期"。⚠️ loop 只在启动时读一次配置:supervisor-loop.ps1在进程启动时把health.*解析成变量,之后不再重读; 改完config/netenv.json里的阈值/退避必须重启自愈循环才生效(Stop-Process掉旧实例即可, 计划任务NetEnv-Supervisor-Periodic每 5 分钟会以新配置拉起;该任务以RunLevel=Highest运行, 非提权会话杀不掉它,这是实测踩过的坑)。- 为什么不能并进
health.probeUrls的 OR 判据:google 通只说明"能上网",与 OpenAI 是否接受该出口地区无关; OR 判据下"google 正常但 GPT 全废"不会触发任何自愈(与 github 专项探针同一类盲区)。 - 为什么恢复动作是轮换节点而不是刷订阅:地区不受支持是节点属性,刷订阅换一批节点照样可能全是香港。
- 探针入口隔离(
ports.mihomoProbe,默认7898)—— 2026-10-06 事故的根治:早期实现是直接切线上组gpt-node来逐个候选复测。mihomo 没有"只让某一次请求走某节点"的接口,于是每个候选的复测窗口 (最长ProbeTimeoutSec)内真实流量全程跟着候选走。实测 2026-10-06 16:53:07 探针连败触发轮换, 16:53:27 用户正好在窗口内打开chatgpt.com,流量落到香港候选,Cloudflare 返回unsupported_country_region_territory「无法加载网站」;16:53:32 轮换失败才还原 —— 用户看到的不是"节点挂了",而是轮换本身。 现在lib/nodes.ps1额外生成三样东西:listeners:里一个mixed入口(ports.mihomoProbe)+rules:首条IN-PORT,<port>,gpt-probe+ 与gpt-node同成员的 select 组gpt-probe。 复测全部经探针入口出网,线上组只在某候选真的通过地区判据之后做一次原子切换。⚠️ 三条硬约束:①IN-PORT必须排在规则表最前(落到DOMAIN-SUFFIX/MATCH之后就永不生效); ②探针端口不得与ports内任何既有端口重复(重复会让 mihomo 直接拒绝加载配置); ③gptProbe.probeGroup与ports.mihomoProbe是配套的,只改一个会让复测走错出口。 自检锚点:日志出现GPT 节点轮换:探针专用入口不可用(组 gpt-probe / 端口 0)→ 退回切线上组的旧路径即代表隔离没生效(doctor与tests\netenv.tests.ps1的"GPT 探针入口隔离"用例都会红)。 - 地区封锁名单(
data/gpt-blocklist.json,gptProbe.blocklistHours默认 12):被判region-unsupported的节点写入名单,并在候选排序阶段剔除。必要性同"为什么不是刷订阅"——region-unsupported是节点属性, 不会自己恢复;2026-10-06 一天之内同一个香港节点被重复复测 8 次,等于每次都在线上窗口里再捅一次。 名单带 TTL、过期即失效(地区策略会变,不做永久屏蔽)。 - 候选枯竭时的全池兜底扫描(
gptProbe.poolScanMax默认 40 /poolScanTimeoutSec4 /poolScanBudgetSec120) —— 2026-10-06 18:00 二次故障的修法:那次不是地区问题,而是池子整体枯死 —— 对auto-urltest全部 650 个成员做一次组测速,8 秒只回来 1 个响应(hysteria2-1561765064), 649 个节点已死。旧实现只看预筛榜(rankGroup并行测速的 top N),榜上全军覆没就报"轮换失败", 于是自愈循环只能干等 —— 明明池子里还有几百个没被预筛命中的节点没试过。 现在候选构建完之后,若预筛结果不足且探针隔离可用,就从gpt-probe组的全量成员里 随机起点(Fisher-Yates,poolScanSeed可复现)追加最多poolScanMax个候选,跳过DIRECT/REJECT/GLOBAL、已在候选里的、以及地区名单里的节点;兜底段单点超时降到poolScanTimeoutSec,整体受poolScanBudgetSec预算约束(超预算记 WARN全池兜底扫描预算 …s 用尽后收手)。 另有一处配套放宽:rankGroup首次测速零结果(整组 504)时,用rankRetryTimeoutMs(默认 15 s)重测一次, 因为瞬时抖动会让整组测速假性全灭。⚠️ 兜底扫描只在探针隔离可用时启用:退路模式下每试一个候选都会切线上组,全池扫一遍 等于把用户的流量扔进海选(tests\netenv.tests.ps1有专门用例钉住这条约束)。 调参注意:poolScanMax × poolScanTimeoutSec不应超过poolScanBudgetSec,否则预算先到、扫不到poolScanMax个。 ⚠️ 变量名大小写:PowerShell 不区分大小写。2026-10-06 实测两起静默事故,现由tests\netenv.tests.ps1的"源码静态检查:变量名大小写冲突"用例兜住: ①Invoke-NetEnvGptNodeRotation内局部$probePort = 0与参数$ProbePort是同一个变量, 赋值即把参数清零 → 隔离分支永远走不到,上面那条"退回旧路径"的降级静默生效; ②Save-NetEnvGptBlocklist的参数$Entries被局部$entries = [ordered]@{}清成空表 → 地区名单永远写成空。两者都不抛错,只在运行期表现成"功能没生效"。命名局部变量时不要只靠大小写区分。
- 为什么不能并进
- 系统代理漂移自愈:
supervisor-loop每轮比对 WinINET 实际值与当前档位期望值并重写(5 分钟退避)。 必要性:第三方 VPN / 代理客户端连接时会接管系统代理,而进程探活与出品探针都发现不了 —— 端口照样 LISTEN、经隧道探针照样通,吃系统代理的程序却已全部退回直连(典型的"全绿着坏")。 判据与doctor共用Get-NetEnvSystemProxyState;doctor另有代理绕过清单、VPN 类接管、VPN 类自启项三项,专治 TUN 接管下的探针假阳性(见 docs/TROUBLESHOOTING.md)。 - 降级链:探针连续失败达
health.failThreshold→ 触发nodes refresh(自带"失败保留旧配置") → 经控制器PUT /configs?force=true重载 mihomo(刷新只改data/merged.yaml;运行中的 mihomo 不会自己读新配置, 不重载则刷新出的节点永远不生效)→ 仍不可用且health.autoFallbackToDirect为真时回退apply -profile direct。 状态写入data/health-state.json;其中lastRefreshAt配合health.autoRefreshMinIntervalMinutes(默认 30 分钟) 限制自动刷新频率,避免目标长期不可达时每 5 分钟锤一次订阅源。 - 计划任务
NetEnv-Supervisor(开机)+NetEnv-Supervisor-Periodic(每 5 分钟)→lib\run-supervisor-hidden.vbs→lib\supervisor-loop.ps1:探活并拉起 mihomo / new-apiinstall --autostart需要管理员权限;两条任务都用wscript.exe启动 VBS,不依赖 PATH 里的pwsh.exe(PATH 缺 pwsh 时计划任务会静默失败),且不闪黑窗- VBS 内有单实例守卫:周期任务只在自愈循环已死时补拉,不会出现两个循环抢重启与日志
⚠️ 但 CommandLine 匹配靠不住:计划任务以RunLevel=Highest启动循环,而非管理员会话 读不到高完整性进程的Win32_Process.CommandLine(实测为空),VBS 的 LIKE 守卫因此看不见它 —— 实测 2026-09-24 启动的一个循环与后来的循环并存了 5 天,两边交替写health-state.json, 把 github/GPT 的连败计数反复清零、专项探针阈值形同虚设(日志零痕迹)。因此循环自身另有 命名互斥量单实例守卫(Local\NetEnv.SupervisorLoop,与命令行可见性无关),并每轮写data/loop-heartbeat-<pid>.json心跳;doctor的自愈循环存活/单实例项据此判定 「没在跑」与「跑了多个」,陈旧心跳由活着的实例回收。
- 失败统计只认"已部署"的服务:二进制不存在的可选服务(new-api/Sub-Store)直接跳过, 不参与失败计数(否则未部署的 new-api 会让日志每分钟刷一条 ERROR,把真故障淹没)
- 日志按天轮转、脱敏、UTF-8 编码,保留天数由
logging.rotateDays(默认 14)决定,netenv clean用同一口径(logs/,不入库) - 订阅刷新不是自动的:需手动
nodes refresh(或用你自己的调度;data/sub-state.json里lastSuccessAt= 上次真正重写merged.yaml的时间,updatedAt= 上次刷新尝试的时间)。⚠️ 两个时间戳必须分开记:2026-10-08 17:18 实测 4 个已配置源全部下载失败、merged.yaml一个字节没动 (与sub-state.hash逐字节相同),而旧实现只写updatedAt,doctor的「订阅新鲜度」照样报"8 小时前" —— 节点池实际已冻结 28 小时、订阅源全挂,却一路全绿(又一次"绿着坏")。 现在doctor一律按lastSuccessAt算 age,并在最近一次刷新零产出时把失败源清单写进 detail; 没有该字段的旧状态文件按merged.yaml的落盘时间算(不得退回updatedAt—— 那等于把缺陷原样放回来),merged.yaml缺失直接判红。 - 非管理员可用的只读动作:
doctor/status;adopt -Apply等接管动作需要管理员权限
- 回归:
Import-Module Pester; .\tests\regression.ps1(Pester 3.4,跑 3 轮);.\tests\regression.ps1 -Live会真起 mihomo 并临时切系统代理,仅在需要冒烟时用⚠️ -Live需要独占ports.mihomoHttp(默认 7897):生产 mihomo 正在监听时它会前置中止(exit 2), 而不是给出一个假绿。必要性:临时实例端口冲突会立刻退出,而紧随的"端口在听吗"读到的其实是生产进程, 于是旧实现打印listening=True(PID 指向已死进程)继续跑,还会在finally里对真实档位执行apply --undo—— 2026-10-09 实测。要跑真冒烟:先停生产 mihomo,或把ports.mihomoHttp改到空闲端口。
- 体检:
netenv doctor(失败项退出码 1);doctor --json提供给 MCP 的netenv_doctor_summary - 改完任何
.ps1必须复查 UTF-8 BOM:编辑工具常常顺手剥掉 BOM,5.1 会按 GBK 解码使中文破坏引号配对(doctor有脚本 UTF-8 BOM项兜底) - Windows PowerShell 5.1 实测坑位(改代码前先看,避免重新踩):
@($genericListOfObject)会抛Argument types do not match(List[string]/Object[]正常)→ 统一用.ToArray()Get-Content -Raw默认按 ANSI(GBK) 解码 → 读配置/状态一律走Read-NetEnvFileText(显式 UTF-8,兼容有/无 BOM)curl连接失败时%{http_code}返回000,"3 位数字"不等于成功 → 必须结合退出码判断- 外部命令缺失(
npm/gh/7z)在$ErrorActionPreference='Stop'下会终止整条流程 → 先Get-Command守卫再降级
| 文档 | 内容 |
|---|---|
| docs/PORTS.md | 端口表(固定,冲突即报错) |
| docs/GITHUB-SAFETY.md | GitHub 认证流量与凭据安全细则 |
| docs/SECRETS.md | 密钥盘点 / 验证 / 归档 / 清理 |
| docs/MCP.md | MCP 接入与权限 |
| docs/AI-CLIENTS.md | AI 软件(编辑器 / CLI)代理接入 |
| docs/SETUP-NEW-PC.md | 新机装配 |
| docs/TROUBLESHOOTING.md | 排障 |
本工具链用于网络代理配置管理,使用者自行承担当地法律法规责任;仅限个人设备使用。