ssh) return $this->ssh; $t=$this->target;$ssh=new SSH2($t['host'],$t['port'],20);$key=$ssh->getServerPublicHostKey(); if(!is_string($key)) throw new Problem('Не удалось получить ключ SSH.'); $expected=$t['host_key'];$parts=explode(' ',$key);$fp='SHA256:'.rtrim(base64_encode(hash('sha256',base64_decode($parts[1]??'',true)?:'',true)),'='); if(!hash_equals(rtrim($expected,'='),rtrim(str_starts_with($expected,'SHA256:')?$fp:$key,'='))) throw new Problem('Ключ SSH хоста не совпадает с закреплённым.'); $secret=$t['secret'];if(str_contains($secret,'PRIVATE KEY')){try{$secret=PublicKeyLoader::loadPrivateKey($secret);}catch(\Throwable){throw new Problem('Некорректный приватный ключ.');}} if(!$ssh->login($t['user'],$secret)) throw new Problem('Не удалось войти в SSH.');$ssh->setTimeout(60);return $this->ssh=$ssh; } public function call(array $payload): array { $payload['root']=$this->target['root']; if($this->target['type']==='local') { $roots=array_filter(array_map('trim',explode(',',getenv('ADMIN_LOCAL_ROOTS')?:''))); if(!in_array($this->target['root'],$roots,true))throw new Problem('Локальный путь больше не разрешён.'); $payload['local_private']=$this->store->dir.'/local-'.hash('sha256',$this->target['root']);$result=Remote::execute($payload); } else { $ssh=$this->connect();$code=file_get_contents(__DIR__.'/Remote.php');$code=preg_replace('/^<\?php\s*/','',$code); // Сжат только доверенный helper из поставки; payload остаётся JSON-данными. $entry="\necho json_encode(\\PrelandAdmin\\Remote::execute(json_decode(base64_decode(\$argv[1]),true,64,JSON_THROW_ON_ERROR)),JSON_THROW_ON_ERROR);"; $jsonPayload=json_encode($payload,JSON_THROW_ON_ERROR); // Размер base64 проверяется ДО inlineCommand/escapeshellarg: для ZIP PHP // завершает процесс при аргументе >2 МиБ раньше переключения на inbox. $argumentLength=4*intdiv(strlen($jsonPayload)+2,3); $command=$argumentLength>self::COMMAND_LIMIT?null:self::inlineCommand($code,$entry,base64_encode($jsonPayload)); $inbox=null;$sftp=null; try { // Большие наборы доставляются SFTP в приватный inbox: argv/ARG_MAX не используется. if($command===null||strlen($command)>self::COMMAND_LIMIT) { if(($payload['op']??null)==='test')throw new Problem('SSH bootstrap превышает безопасный предел команды; сократите helper или путь подключения.'); $parent=dirname($this->target['root']);$private=$parent.'/.preland-admin-'.substr(hash('sha256',$this->target['root']),0,16); $candidate=$private.'/inbox-'.bin2hex(random_bytes(16)); // Проверяем конечный bootstrap до рекурсивного preflight: большой helper // не должен бесконечно пытаться доставить сам себя через inbox. $command=self::inboxCommand($code,$candidate); $this->call(['op'=>'test']);$sftp=$this->sftp();$inbox=$candidate; if(!$sftp->put($inbox,$jsonPayload)||!$sftp->chmod(0600,$inbox))throw new Problem('Не удалось доставить подготовленный набор.'); } $timeout=max(1,min(120,(int)(getenv('ADMIN_TRANSPORT_TIMEOUT')?:60)));$ssh->setTimeout($timeout); $output=$ssh->exec($command);$result=is_string($output)?json_decode($output,true):null; if($ssh->isTimeout()||!is_array($result)) { $this->ssh=null;throw new Problem('Неизвестный результат: соединение прервано. Перечитайте фактическое состояние.',504); } } finally { // Helper очищает inbox после чтения; резервная очистка также при обрыве // exec/ошибке загрузки, не подменяя исходную ошибку транспорта. if($inbox!==null&&$sftp!==null)try{$sftp->delete($inbox);}catch(\Throwable){} } } if(!($result['ok']??false)) {if(!empty($result['conflict']))throw new Conflict();throw new Problem($result['error']??'Ошибка транспорта.');}return $result; } private static function inlineCommand(string $code,string $entry,string $argument): string { if(!function_exists('gzdeflate'))throw new Problem('Для SSH-транспорта требуется расширение zlib.'); $compressed=gzdeflate($code.$entry,9);if($compressed===false)throw new Problem('Не удалось подготовить SSH helper.'); $runner='if(!function_exists("gzinflate")){echo json_encode(["ok"=>false,"error"=>"На удалённом PHP отсутствует zlib (gzinflate)."]);exit(1);}eval(gzinflate(base64_decode('.var_export(base64_encode($compressed),true).',true)));'; return 'php -d display_errors=0 -r '.escapeshellarg($runner).' '.escapeshellarg($argument); } private static function inboxCommand(string $code,string $inbox): string { $entry="\n\$inbox=\$argv[1];try{echo json_encode(\\PrelandAdmin\\Remote::execute(json_decode(file_get_contents(\$inbox),true,64,JSON_THROW_ON_ERROR)),JSON_THROW_ON_ERROR);}finally{unlink(\$inbox);}"; $command=self::inlineCommand($code,$entry,$inbox); if(strlen($command)>self::COMMAND_LIMIT)throw new Problem('SSH helper превышает безопасный предел команды; требуется уменьшить bootstrap.'); return $command; } private function sftp(): SFTP { $t=$this->target;$sftp=new SFTP($t['host'],$t['port'],20);$key=$sftp->getServerPublicHostKey();$parts=explode(' ',(string)$key);$fp='SHA256:'.rtrim(base64_encode(hash('sha256',base64_decode($parts[1]??'',true)?:'',true)),'=');$expected=$t['host_key']; if(!is_string($key)||!hash_equals(rtrim($expected,'='),rtrim(str_starts_with($expected,'SHA256:')?$fp:$key,'=')))throw new Problem('Ключ SFTP хоста не совпадает.'); $secret=$t['secret'];if(str_contains($secret,'PRIVATE KEY'))$secret=PublicKeyLoader::loadPrivateKey($secret);if(!$sftp->login($t['user'],$secret))throw new Problem('Не удалось войти в SFTP.');return $sftp; } public function test(): array { $result=$this->call(['op'=>'test']); $read=$this->readConfig(); if (!hash_equals($result['version'],$read['version'])) throw new Conflict(); PercentWeights::validate($read['config']); return $result; } public function readConfig(): array {$r=$this->call(['op'=>'read']);return ['config'=>Config::decode($r['content']),'version'=>$r['version'],'content'=>$r['content']];} public function writeConfig(string $version,string $content): array { $config=Config::decode($content);Config::assertWritable($config);PercentWeights::validate($config); $before=$this->call(['op'=>'read']);if(!hash_equals($version,$before['version']))throw new Conflict(); $this->store->backup($this->target['id'],$before['content']); return $this->call(['op'=>'write','version'=>$version,'content'=>$content]); } }