Allow nonempty free-node releases regardless of count drop

This commit is contained in:
hellcat 2026-10-20 12:17:00 +08:00
parent 2398318be6
commit 03c6c9e93a
5 changed files with 27 additions and 26 deletions

View File

@ -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'));
}
}

View File

@ -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');

View File

@ -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'),

View File

@ -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()`拒绝继续自动推送(不删除已落盘文件)。用户随后明确要求移除骤减保护,实施变更见本节上方现行行为及后续部署验收;本段保留事故发生时的证据,不代表仍须等待上游恢复。

View File

@ -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 {