feat: единая админка runtime-конфигов и indpic (#22)
deploy-preland / deploy zoomstage.online (push) Successful in 1m1s

This commit is contained in:
Panfilov Konstantin committed 2026-10-25 11:33:56 +03:00
1 parent 5b6b2d2053
commit 9515c1c62b
52 files changed
+2459 -34

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
use phpseclib3\Net\SSH2;
use phpseclib3\Net\SFTP;
use phpseclib3\Crypt\PublicKeyLoader;
final class Transport
{
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);
// Аргументы проходят shell quoting, обработчик не принимает команды от клиента.
$runner=$code."\necho json_encode(\\PrelandAdmin\\Remote::execute(json_decode(base64_decode(\$argv[1]),true,64,JSON_THROW_ON_ERROR)),JSON_THROW_ON_ERROR);";
$command='php -d display_errors=0 -r '.escapeshellarg($runner).' '.escapeshellarg(base64_encode(json_encode($payload,JSON_THROW_ON_ERROR)));
// Большие наборы доставляются SFTP в приватный inbox: argv/ARG_MAX не используется.
if(strlen($command)>60000) {
$sftp=$this->sftp();$parent=dirname($this->target['root']);$private=$parent.'/.preland-admin-'.substr(hash('sha256',$this->target['root']),0,16);
$this->call(['op'=>'test']);$inbox=$private.'/inbox-'.bin2hex(random_bytes(16));
if(!$sftp->put($inbox,json_encode($payload,JSON_THROW_ON_ERROR)))throw new Problem('Не удалось доставить подготовленный набор.');$sftp->chmod(0600,$inbox);
$runner=$code."\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='php -d display_errors=0 -r '.escapeshellarg($runner).' '.escapeshellarg($inbox);
}
$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); }
}
if(!($result['ok']??false)) {if(!empty($result['conflict']))throw new Conflict();throw new Problem($result['error']??'Ошибка транспорта.');}return $result;
}
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 {return $this->call(['op'=>'test']);}
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 {
$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]);
}
}