93 lines
7.9 KiB
PHP
93 lines
7.9 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
namespace PrelandAdmin;
|
|
use phpseclib3\Net\SSH2;
|
|
use phpseclib3\Net\SFTP;
|
|
use phpseclib3\Crypt\PublicKeyLoader;
|
|
final class Transport
|
|
{
|
|
private const COMMAND_LIMIT=7000;
|
|
private ?SSH2 $ssh=null;
|
|
public function __construct(private Store $store, public readonly array $target) {}
|
|
private function connect(): SSH2 {
|
|
if($this->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]);
|
|
}
|
|
}
|