diff --git a/app/Services/Freenode/Pipeline.php b/app/Services/Freenode/Pipeline.php index 219d3d3d..192606af 100644 --- a/app/Services/Freenode/Pipeline.php +++ b/app/Services/Freenode/Pipeline.php @@ -43,6 +43,6 @@ final class Pipeline $clashTemplate = \Symfony\Component\Yaml\Yaml::parseFile(public_path('freenode/template/clash.txt')); $validator = new NativeValidator(config('freenode.singbox_binary'), config('freenode.mihomo_binary'), $clashTemplate, config('freenode.geoip_directory')); $result = (new Builder())->build($sources, $sites, $validator); - return self::store()->stage($result, date('Y-m-d'), (float) config('freenode.minimum_ratio', 0.5)); + return self::store()->stage($result, date('Y-m-d')); } } diff --git a/app/Services/Freenode/ReleaseStore.php b/app/Services/Freenode/ReleaseStore.php index bc874b37..1f64e448 100644 --- a/app/Services/Freenode/ReleaseStore.php +++ b/app/Services/Freenode/ReleaseStore.php @@ -19,13 +19,11 @@ final class ReleaseStore try { return $callback(); } finally { flock($lock, LOCK_UN); fclose($lock); } } - public function stage(array $result, string $date, float $minimumRatio = 0.5): array + public function stage(array $result, string $date): array { - return $this->locked(function () use ($result, $date, $minimumRatio) { - $previous = $this->current(); + return $this->locked(function () use ($result, $date) { foreach ($result['report']['counts'] as $format => $count) { - $baseline = $previous['counts'][$format] ?? $this->legacyCount($format, $date); - if ($count < 1 || $baseline > 0 && $count < $baseline * $minimumRatio) throw new RuntimeException('count_drop_guard:'.$format); + if ($count < 1) throw new RuntimeException('empty_release_format:'.$format); } $id = gmdate('YmdHis').'-'.bin2hex(random_bytes(6)); $dir = $this->root.'/versions/'.$id; @@ -134,20 +132,6 @@ final class ReleaseStore return $manifest; } - private function legacyCount(string $format, string $date): int - { - $path = $this->publicRoot.'/base/'.$format.'/'.$date.'.txt'; - if (!is_file($path)) return 0; - $s = file_get_contents($path); - try { - return match ($format) { - 'v2ray' => count(array_filter(explode("\n", Codec::decode64($s)))), - 'singbox' => max(0, count(json_decode($s, true, 512, JSON_THROW_ON_ERROR)['outbounds'] ?? []) - 3), - 'clash' => count(\Symfony\Component\Yaml\Yaml::parse($s)['proxies'] ?? []), - }; - } catch (\Throwable) { throw new RuntimeException('invalid_baseline:'.$format); } - } - private function safeKey(string $key): void { if (!preg_match('~^[a-zA-Z0-9_.-]+/(v2ray|clash|singbox)$~D', $key) || str_contains($key, '..')) throw new RuntimeException('invalid_artifact_key'); diff --git a/config/freenode.php b/config/freenode.php index 73639b4e..d3cfc327 100755 --- a/config/freenode.php +++ b/config/freenode.php @@ -3,7 +3,6 @@ return [ 'engine' => env('FREENODE_ENGINE', 'legacy'), - 'minimum_ratio' => (float) env('FREENODE_MINIMUM_RATIO', 0.5), 'singbox_binary' => env('FREENODE_SINGBOX_BINARY', '/opt/master01-freenode/bin/sing-box'), 'mihomo_binary' => env('FREENODE_MIHOMO_BINARY', '/opt/master01-freenode/bin/mihomo'), 'geoip_directory' => env('FREENODE_GEOIP_DIRECTORY', '/opt/master01-freenode/data'), diff --git a/docs/maintenance/FREENODE.md b/docs/maintenance/FREENODE.md index 846f10b5..73da4f27 100644 --- a/docs/maintenance/FREENODE.md +++ b/docs/maintenance/FREENODE.md @@ -142,7 +142,7 @@ merge 下仍有旧命名目录,不能据目录名推断当前站点归属。fn - `Builder.php`:从各启用来源的 URI 与 Clash 输入独立建模,分别去重、保留多来源;Sing-box 从 URI 模型渲染,Clash 保留来源节点字段并沿用服务器现有 Clash 策略模板。当前没有声称已统一识别跨 URI/Clash 的同一物理节点。 - `Renderer.php`:Sing-box 采用现代配置(原生验收目标 1.14.3),保留私网/中国规则直连、其余代理的路由意图;不再用复制真实节点的方式制造宣传占位。旧版 Sing-box/Hiddify 的具体兼容性须按产品内核验收,不能承诺所有客户端版本。 - `NativeValidator.php`:Sing-box/Mihomo 每种格式先整份检查;失败时优先按内核报错索引隔离坏节点,未知错误才二分定位,最后再验整份;整批原生校验限制240秒,超时保留旧产物。原生错误输出不进入日志或报告,报告只记录节点哈希及排除分类。不会启动内核代理服务或接管宿主路由。 -- `ReleaseStore.php`:私有版本目录、SHA-256 清单、文件锁、数量骤减保护(默认低于上一版/旧产物的 50% 阻止发布)、逐文件原子替换兼容产物、原子切换当前清单。内部同步读取不可变版本,不能读半成品;兼容公开路径是逐文件原子更新,不能声称整个目录跨请求具备全局事务原子性。 +- `ReleaseStore.php`:私有版本目录、SHA-256 清单、文件锁、空结果拒绝、逐文件原子替换兼容产物、原子切换当前清单。2026-10-20按用户要求移除按上一版数量比例拦截的逻辑及`FREENODE_MINIMUM_RATIO`配置;非空且通过其他校验的产物不再因数量下降被拒绝。内部同步读取不可变版本,不能读半成品;兼容公开路径是逐文件原子更新,不能声称整个目录跨请求具备全局事务原子性。 - 首次发布前自动保留将被替换的旧产物版本。默认不迁移/删除数据库或旧代码,回滚代码和订阅产物是两个独立动作。 - 原时刻调度保留:采集后 :06 准备并原生校验,:09 不再重复旧合并,:12 发布准备批次,:15 按原接口分发。来源必须属于本轮六小时采集周期,失败则不覆盖上一份产物;准备批次超过 30 分钟不可发布。超过 24 小时的批次不继续自动推送。 - V2 不更新旧 freenode_pool.content 列;来源/排除/节点关系的权威运行报告在私有版本 report.json,旧后台池展示不能当作 V2 当前产物统计。 @@ -156,7 +156,7 @@ merge 下仍有旧命名目录,不能据目录名推断当前站点归属。fn 运行目录需由实际 PHP/调度身份可写。发布前记录旧 Git 提交、并行修改、当前引擎配置及兼容文件哈希。切回旧链路须先停止/避开本轮生成发布,在配置中恢复 `FREENODE_ENGINE=legacy`,清除相应配置缓存,再恢复保留的旧产物;仅回退 Git 不会自动撤回已分发到 Nodehub/各站的文件,必要时按原接口再次同步旧产物。不能声称本地恢复等于全部下游已回滚。 -本地验证命令:`TEST_SINGBOX=/path/to/sing-box TEST_MIHOMO=/path/to/mihomo php -d error_reporting=22527 vendor/bin/phpunit tests/Unit/Freenode --do-not-cache-result`。当前 15 项、36 断言通过,含真实原生内核检查、坏节点隔离、参数完整性、数量骤减保护、清单篡改拒绝及旧产物恢复。未安装原生内核时该集成测试会跳过,不能把跳过当作原生验收通过。 +本地验证命令:`TEST_SINGBOX=/path/to/sing-box TEST_MIHOMO=/path/to/mihomo php -d error_reporting=22527 vendor/bin/phpunit tests/Unit/Freenode --do-not-cache-result`。2026-10-20本地16项、40断言通过,含真实原生内核检查、坏节点隔离、参数完整性、数量从1324降为1仍能发布及回滚、空结果拒绝、清单篡改拒绝及旧产物恢复。未安装原生内核时该集成测试会跳过,不能把跳过当作原生验收通过。 已完成:服务器内真实输入影子生成、生产切换、16文件旧产物恢复演练、Nodehub四站10文件落盘校验及实际PHP-FPM内部请求验收。尚未完成:用户具体GUI客户端更新、公网下载/CDN链路验收。白色机每日实际连通测试服务不包含在本次生成链切换中,尚未部署。 @@ -167,9 +167,17 @@ merge 下仍有旧命名目录,不能据目录名推断当前站点归属。fn - 用户明确授权向现有Gitea仓库推送并继续部署。功能代码提交为 `7f657cb01792d548fd0e2356b179e7345f79fb0d`;后续维护记录提交不改变生成逻辑。生产分支 `codex/freenode-v2`,旧代码基线 `ddf104f158f4d7da970bdc0c936e650864c1b6e9` 保留在master。上线前原有已删除/未跟踪内容及服务器Clash模板保留,不覆盖并行修改。 - 当前 `.env` 已启用 `FREENODE_ENGINE=v2`;上线时配置缓存不存在,未清空其他缓存、未重启服务、未修改数据库/凭据/网络/反代。 - 运行身份www-data;私有批次根 `storage/app/freenode-v2/`;独立校验器及离线数据 `/opt/master01-freenode/`。完整节点原文只在服务器内处理,不导出到本机或写入报告。 -- 当前批次 `20261018114313-fe0b943ad776`;URI 2009、Clash 1830、Sing-box 1848。准备16个文件(含base、_admin和各站版本),通过固定版本原生校验。输入2个来源,9条无效URI输入被隔离,Sing-box转换阶段36条不支持的协议/传输被排除,原生校验进一步隔离Sing-box 125、Clash 29条;各分类不能相加当作跨格式唯一坏节点数。 +- 上线验收批次 `20261018114313-fe0b943ad776`;URI 2009、Clash 1830、Sing-box 1848。准备16个文件(含base、_admin和各站版本),通过固定版本原生校验。输入2个来源,9条无效URI输入被隔离,Sing-box转换阶段36条不支持的协议/传输被排除,原生校验进一步隔离Sing-box 125、Clash 29条;各分类不能相加当作跨格式唯一坏节点数。 - 本轮真实输入最终校验160次内核调用、106.03秒、GNU time最大RSS 86228 KiB(约84.2 MiB;不是整机总峰值,也不等于并发总和)。初版二分策略2076次调用,已优化;该真实输入性能优先于上文合成样本结果。没有逐节点联网或测速。 - 回滚演练:发布候选→恢复旧产物→逐一确认16文件SHA-256与发布前完全一致→重新发布候选。旧产物版本 `legacy-20261018114425-50ee6b49` 保留。恢复点与引擎原值元数据位于 `/var/backups/master01-freenode-20261018/`。这是产物恢复演练,不声称演练过全部下游代码/业务回滚。 - 已按现有API分发到独立项目 [Nodehub的唯一档案](/Users/hellcat/Documents/GemDocs/AI版/10-项目/nodehub.md)。接收方代码未改,仍返回旧isDone结果,不提供批次/hash ACK;本次在接收服务器只读校验真实落盘文件,四站10文件全部匹配。URI与Sing-box逐字节匹配;Clash经接收端既有首尾空白处理后匹配(末尾换行差异),不声称其原始字节hash相同。未额外手工验收fn_a旧直推接口。 - 实际PHP-FPM Unix socket请求 `/sub/clashfreenode.com/{v2ray,clash,singbox}`:均200,无stderr,返回MIME分别text/plain、application/yaml、application/json,响应体SHA-256与当前批次全部一致。此为服务器内部请求,不冒充公网TLS、CDN或GUI导入验收。 - 本机和生产Linux/PHP 8.4.23均通过15项、36断言测试,含实际固定内核。部署后的正常定时任务下一轮执行尚未等待观察;原有调度时刻未变。 + +### 2026-10-20 数量骤减保护调查 + +用户报告12:06 `count_drop_guard:v2ray`。经白色机SSH在master01内只读复核,部署仍为`2398318b`、引擎v2、最低数量比例0.5。当前已发布批次为`20261019160628-63eb56847dd2`(北京时间10月20日00:06准备),URI 1324、Clash 1282、Sing-box 1261;10月19日多轮定时准备/发布的版本记录也已存在,取代上线时“下一轮尚未观察”的历史限制。 + +12:03采集的`daily_free_vpn`只有537行URI(535行可解析)、518个Clash proxies;`shuaidaoya`仍为10/10。重新在服务器请求两来源的两格式均HTTP 200、curl错误码0,解码/清理BOM后SHA-256与已采集文件完全一致。两来源合并去重后525条URI,仅为已发布1324条的39.65%,低于门槛662条。由此确认本次保护由上游提供的数量减少触发,未发现本机解析大面积丢失或抓取内容与响应不一致;未判断上游数量下降的内部原因或节点真实可用性。 + +该异常发生在写入候选和切换当前指针之前;12:10—12:12调查时当前指针仍是上述1324条批次。调查阶段未降低阈值、未手动发布/重新采集写盘、未改变生产代码或下游文件。用户随后提供12:12的`no_fresh_prepared_release`,是准备失败后发布阶段继续执行的后续异常。`:15`在已发布批次未满24小时期间仍可同步旧批次,超过24小时后`readSite()`拒绝继续自动推送(不删除已落盘文件)。用户随后明确要求移除骤减保护,实施变更见本节上方现行行为及后续部署验收;本段保留事故发生时的证据,不代表仍须等待上游恢复。 diff --git a/tests/Unit/Freenode/ReleaseStoreTest.php b/tests/Unit/Freenode/ReleaseStoreTest.php index 01e03c3c..83931edb 100644 --- a/tests/Unit/Freenode/ReleaseStoreTest.php +++ b/tests/Unit/Freenode/ReleaseStoreTest.php @@ -23,10 +23,20 @@ final class ReleaseStoreTest extends TestCase self::assertSame($a['id'], $store->current()['id']); self::assertSame('first', file_get_contents($this->dir.'/public/site/v2ray/'.date('Y-m-d').'.txt')); } - public function testCountDropDoesNotReplaceCurrent(): void { + public function testCountDropCanPublishAndRollback(): void { + $store = new ReleaseStore($this->dir.'/private', $this->dir.'/public'); + $a = $store->stage($this->fixture('first', 1324), date('Y-m-d')); $store->publishPrepared(); + $b = $store->stage($this->fixture('smaller', 1), date('Y-m-d')); $store->publishPrepared(); + self::assertSame($b['id'], $store->current()['id']); + self::assertSame(['v2ray' => 'smaller'], $store->readSite('site', ['v2ray'])); + $store->activate($store->previous()['id']); + self::assertSame($a['id'], $store->current()['id']); + self::assertSame(['v2ray' => 'first'], $store->readSite('site', ['v2ray'])); + } + public function testEmptyReleaseDoesNotReplaceCurrent(): void { $store = new ReleaseStore($this->dir.'/private', $this->dir.'/public'); $a = $store->stage($this->fixture('first'), date('Y-m-d')); $store->publishPrepared(); - try { $store->stage($this->fixture('empty', 1), date('Y-m-d')); self::fail(); } catch (RuntimeException $e) { self::assertStringContainsString('count_drop_guard', $e->getMessage()); } + try { $store->stage($this->fixture('empty', 0), date('Y-m-d')); self::fail(); } catch (RuntimeException $e) { self::assertSame('empty_release_format:v2ray', $e->getMessage()); } self::assertSame($a['id'], $store->current()['id']); } public function testTamperedFilesCannotBePublished(): void {