feat(admin): remember login for 90 days and support REG SSH limits
deploy-preland / deploy zoomstage.online (push) Successful in 57s

This commit is contained in:
Panfilov Konstantin committed 2026-10-25 19:17:51 +03:00
1 parent d04b40519e
commit 522aaf8554
13 files changed
+316 -27

No files matched your search

+32 -9
View File
@@ -6,6 +6,7 @@ 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 {
@@ -26,23 +27,45 @@ final class Transport
}
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)));
// Сжат только доверенный 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);";
$command=self::inlineCommand($code,$entry,base64_encode(json_encode($payload,JSON_THROW_ON_ERROR)));
$inbox=null;$sftp=null;
try {
// Большие наборы доставляются 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);
if(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,json_encode($payload,JSON_THROW_ON_ERROR))||!$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 хоста не совпадает.');