Reject release writes under a different runtime UID

This commit is contained in:
hellcat 2026-10-24 12:15:20 +08:00
parent 676196388d
commit 2b50ac3670
4 changed files with 36 additions and 0 deletions

View File

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

View File

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

View File

@ -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 <action>`,不可用root直接写发布状态。新增跨属主回归须root创建uid33目录验证,本地非root跳过;普通回归实际27项61断言(1项root专属测试跳过)。
当前状态:权限现场已修复,防复发代码待Git部署及root/cron身份验收;随后核实12:15四站自动分发结果。

View File

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