diff --git a/app/Services/Freenode/Pipeline.php b/app/Services/Freenode/Pipeline.php index 192606af..f96b3134 100644 --- a/app/Services/Freenode/Pipeline.php +++ b/app/Services/Freenode/Pipeline.php @@ -18,6 +18,7 @@ final class Pipeline public static function prepare(): array { + self::store()->assertWriter(); $sources = []; $cycle = strtotime(date('Y-m-d').' '.sprintf('%02d', intdiv((int) date('H'), 6) * 6).':00:00'); foreach (CrawlFreenode::where('status', 1)->get() as $source) { diff --git a/app/Services/Freenode/ReleaseStore.php b/app/Services/Freenode/ReleaseStore.php index 1f64e448..68ceb5ac 100644 --- a/app/Services/Freenode/ReleaseStore.php +++ b/app/Services/Freenode/ReleaseStore.php @@ -14,11 +14,25 @@ final class ReleaseStore public function locked(callable $callback, bool $exclusive = true): mixed { + if ($exclusive) $this->assertWriter(); $lock = fopen($this->root.'/publish.lock', 'c'); if (!$lock || !flock($lock, $exclusive ? LOCK_EX : LOCK_SH)) throw new RuntimeException('release_lock_failed'); try { return $callback(); } finally { flock($lock, LOCK_UN); fclose($lock); } } + public function assertWriter(): void + { + // Atomic replacement inherits the process UID, not the old file owner. + // A root maintenance invocation must not strand 0600 manifests away from cron. + if (function_exists('posix_geteuid')) { + clearstatcache(true, $this->root); + $owner = fileowner($this->root); + if ($owner === false || posix_geteuid() !== $owner) { + throw new RuntimeException('release_writer_identity_mismatch:expected_uid='.($owner === false ? 'unknown' : $owner)); + } + } + } + public function stage(array $result, string $date): array { return $this->locked(function () use ($result, $date) { diff --git a/docs/maintenance/FREENODE.md b/docs/maintenance/FREENODE.md index b05233d8..75c56075 100644 --- a/docs/maintenance/FREENODE.md +++ b/docs/maintenance/FREENODE.md @@ -256,3 +256,15 @@ SubCheck任务`2bb85091bcc34d4dba234e47fb98172f`于22日18:18发现cfn最新文 SubCheck正式复检任务`28ca01e368dd4255843646407b95927c`于北京时间2026-10-23 12:26:15启动,状态passed/pass_server_files_only:时效pass,三格式均0异常,Clash及Sing-box固定核心pass;V2Ray为URI静态检查,未声称整份核心加载。未测节点连通或GUI导入,未做线上页面验收。检测器自身未改代码(现场HEAD`428b533ae34b610d0a18241995eb776b25458ebb`);复检沿用既有任务/通知配置,未重发文章。 备份及回滚依据位于master01`/var/backups/master01-uri-fix-20261023/`:代码回退点`8f7a40208f4ebd80c79bcc147cc1388cd220be5f`及完整旧批次清单、原并行Git状态。产物回滚须显式激活上述旧批次并再同步Nodehub,不能仅回退Git。原3项线上并行修改已保留。 + +### 2026-10-24 发布清单属主错误 + +用户提供09:03:02 cron ErrorException:`ReleaseStore.php:142`读取`storage/app/freenode-v2/current.json`报Permission denied。最初白色机SSH在认证前关闭,未绕过跳板;随后按用户要求重试恢复连接,线上HEAD为`676196388df7c302e5cbfa14cbdb54f364cdccd8`。 + +现场确认私有根目录属于www-data、0700,只有`current.json`和`previous.json`属于root:root、0600,mtime为23日12:25:11;今日候选及整个versions目录均未发现root属主异常。原子替换文件会继承执行进程UID,root维护发布会产生cron无法读取的新0600清单;本次未以chmod放宽秘密文件权限。 + +备份旧uid/gid/mode、清单SHA及生产Git并行状态到`/var/backups/master01-release-permissions-20261024/`,只把上述两文件chown为www-data:www-data,原0600及字节哈希不变。实际www-data随后成功读取current/previous,并对current与当日pending的全部文件校验SHA。故障前current停在23日`20261023042429-92aef664ed63`;今日12:07候选`20261024040706-866cdfb41e0a`(URI1855、Clash1850、Sing-box1762)已通过校验且仍在30分钟窗口内,因此以www-data补发布刚被权限阻断的当前轮次,不重新采集或放宽时效门禁。 + +防复发:ReleaseStore在任何独占写锁之前核对实际有效UID与私有根目录属主,身份不一致抛`release_writer_identity_mismatch:expected_uid=33`,不会先创建锁或清单;Pipeline prepare入口同样先检查,避免root运行校验后才失败。共享读锁和只读status仍可读取。生产操作统一`runuser -u www-data -- php tools/freenode/manage.php `,不可用root直接写发布状态。新增跨属主回归须root创建uid33目录验证,本地非root跳过;普通回归实际27项61断言(1项root专属测试跳过)。 + +当前状态:权限现场已修复,防复发代码待Git部署及root/cron身份验收;随后核实12:15四站自动分发结果。 diff --git a/tests/Unit/Freenode/ReleaseStoreTest.php b/tests/Unit/Freenode/ReleaseStoreTest.php index 83931edb..5b3419e1 100644 --- a/tests/Unit/Freenode/ReleaseStoreTest.php +++ b/tests/Unit/Freenode/ReleaseStoreTest.php @@ -14,6 +14,15 @@ final class ReleaseStoreTest extends TestCase rmdir($this->dir); } private function fixture(string $value, int $count = 10): array { return ['files' => ['site/v2ray' => $value], 'report' => ['counts' => ['v2ray' => $count]]]; } + public function testForeignOwnerCannotCreateRootOwnedManifests(): void { + if (!function_exists('posix_geteuid') || posix_geteuid() !== 0) self::markTestSkipped('Requires root to create a foreign-owned directory'); + $store = new ReleaseStore($this->dir.'/private', $this->dir.'/public'); + chown($this->dir.'/private', 33); + try { $store->stage($this->fixture('must-not-write'), date('Y-m-d')); self::fail(); } + catch (RuntimeException $e) { self::assertSame('release_writer_identity_mismatch:expected_uid=33', $e->getMessage()); } + self::assertFileDoesNotExist($this->dir.'/private/publish.lock'); + self::assertFileDoesNotExist($this->dir.'/private/pending.json'); + } public function testRollbackRestoresPreviousReleaseAndPublicFile(): void { $store = new ReleaseStore($this->dir.'/private', $this->dir.'/public'); $a = $store->stage($this->fixture('first'), date('Y-m-d')); $store->publishPrepared();