98 lines
8.4 KiB
PHP
98 lines
8.4 KiB
PHP
<?php
|
|
/** #22: сервер разрывает SSH exec длиннее 8 KiB; helper и payload не теряются. */
|
|
declare(strict_types=1);
|
|
namespace phpseclib3\Net {
|
|
class SSH2 {
|
|
public static array $commands=[];
|
|
public static bool $withoutZlib=false;
|
|
public static bool $dropInbox=false;
|
|
public function __construct(...$args) {}
|
|
public function getServerPublicHostKey(): string {return 'ssh-ed25519 '.base64_encode('fixture-key');}
|
|
public function login(...$args): bool {return true;}
|
|
public function setTimeout($seconds): void {}
|
|
public function isTimeout(): bool {return false;}
|
|
public function exec(string $command): string|false {
|
|
self::$commands[]=$command;
|
|
if(strlen($command)>8192)return false; // фактическая граница REG.RU, старая реализация падает
|
|
if(self::$dropInbox&&str_contains($command,'/inbox-'))return false;
|
|
if(self::$withoutZlib)$command=preg_replace('/^php /','php -d disable_functions=gzinflate ',$command);
|
|
$process=proc_open($command,[0=>['pipe','r'],1=>['pipe','w'],2=>['pipe','w']],$pipes);
|
|
fclose($pipes[0]);$output=stream_get_contents($pipes[1]);$error=stream_get_contents($pipes[2]);fclose($pipes[1]);fclose($pipes[2]);proc_close($process);
|
|
if($error!=='')throw new \RuntimeException($error);
|
|
return $output;
|
|
}
|
|
}
|
|
class SFTP extends SSH2 {
|
|
public static array $uploads=[];
|
|
public function put(string $path,string $bytes): bool {self::$uploads[]=$path;return file_put_contents($path,$bytes)!==false;}
|
|
public function chmod(int $mode,string $path): bool {return chmod($path,$mode);}
|
|
public function delete(string $path): bool {return !file_exists($path)||unlink($path);}
|
|
}
|
|
}
|
|
namespace {
|
|
require dirname(__DIR__).'/bootstrap.php';
|
|
use PrelandAdmin\{Store,Transport,Problem};
|
|
use phpseclib3\Net\{SSH2,SFTP};
|
|
function ensure(bool $condition,string $message): void {if(!$condition)throw new RuntimeException($message);echo 'PASS: '.$message."\n";}
|
|
$fixture=sys_get_temp_dir().'/preland22-command-'.bin2hex(random_bytes(6));mkdir($fixture);mkdir($fixture.'/site');
|
|
$site=$fixture.'/site';file_put_contents($site.'/config.json','{"host":"initial.example","utm":{}}');
|
|
$store=new Store($fixture.'/state');
|
|
$target=['id'=>'limit','type'=>'sftp','host'=>'fixture','port'=>22,'user'=>'fixture','secret'=>'fixture','host_key'=>'ssh-ed25519 '.base64_encode('fixture-key'),'root'=>$site];
|
|
$transport=new Transport($store,$target);
|
|
try {
|
|
$transport->test();$before=$transport->readConfig();
|
|
ensure($before['config']->host==='initial.example','compressed helper reads under SSH 8KiB limit');
|
|
$transport->writeConfig($before['version'],'{"host":"small.example","utm":{}}');
|
|
ensure($transport->readConfig()['config']->host==='small.example','compressed RPC performs CAS write/read');
|
|
$large=json_encode(['host'=>'large.example','utm'=>(object)[],'padding'=>str_repeat('abcdefgh',3000)],JSON_THROW_ON_ERROR);
|
|
$before=$transport->readConfig();$transport->writeConfig($before['version'],$large);
|
|
ensure($transport->readConfig()['content']===$large,'large payload uses inbox and preserves bytes');
|
|
ensure(count(SFTP::$uploads)===1,'large payload uploaded through SFTP exactly once');
|
|
foreach(SFTP::$uploads as $path)ensure(!file_exists($path),'SFTP inbox removed after RPC');
|
|
$maximum=max(array_map('strlen',SSH2::$commands));
|
|
ensure($maximum<=7000,'all final shell commands stay within 7000 bytes (maximum '.$maximum.')');
|
|
$commandCount=count(SSH2::$commands);$uploads=count(SFTP::$uploads);
|
|
// Inline test может оказаться длиннее inbox runner из-за base64 длинного
|
|
// root, даже когда сам inbox помещается: этот preflight нельзя рекурсировать.
|
|
$longRootTarget=$target;$longRootTarget['root']='/'.str_repeat('a',1800);
|
|
try{(new Transport($store,$longRootTarget))->test();throw new RuntimeException('oversized test recursed');}
|
|
catch(Problem $error){ensure(str_contains($error->getMessage(),'SSH bootstrap'),'oversized test RPC rejected before inbox/preflight recursion');}
|
|
ensure(count(SSH2::$commands)===$commandCount&&count(SFTP::$uploads)===$uploads,'oversized test performs no exec/upload');
|
|
$bootstrap=new ReflectionMethod(Transport::class,'inboxCommand');
|
|
try{$bootstrap->invoke(null,'/*'.base64_encode(random_bytes(16000)).'*/',$site.'/inbox-test');throw new RuntimeException('oversized bootstrap accepted');}
|
|
catch(Problem $error){ensure(str_contains($error->getMessage(),'bootstrap'),'oversized helper rejected before recursive preflight');}
|
|
ensure(count(SSH2::$commands)===$commandCount&&count(SFTP::$uploads)===$uploads,'oversized bootstrap performs no remote call/upload');
|
|
$before=$transport->readConfig();SSH2::$dropInbox=true;
|
|
try{$transport->writeConfig($before['version'],$large);throw new RuntimeException('dropped exec accepted');}
|
|
catch(Problem $error){ensure(str_contains($error->getMessage(),'Неизвестный результат'),'dropped inbox exec remains unknown result');}
|
|
SSH2::$dropInbox=false;
|
|
foreach(SFTP::$uploads as $path)ensure(!file_exists($path),'inbox cleaned even when exec is interrupted');
|
|
SSH2::$withoutZlib=true;
|
|
try{$transport->test();throw new RuntimeException('missing zlib accepted');}catch(Problem $error){ensure(str_contains($error->getMessage(),'zlib'),'missing gzinflate returns clear structured error');}
|
|
SSH2::$withoutZlib=false;
|
|
// Настоящие 90 изображений: два слоя base64 дают аргумент заметно больше PHP shell-limit 2 MiB.
|
|
$sourceSet=dirname(__DIR__,2).'/1';$files=[];
|
|
foreach(array_keys(\PrelandAdmin\IndpicSetStorage::expectedDimensions()) as $filename){$bytes=file_get_contents($sourceSet.'/'.$filename);$files[$filename]=['data'=>base64_encode($bytes),'sha256'=>hash('sha256',$bytes)];}
|
|
$hugePayload=['op'=>'set-write','number'=>3,'files'=>$files,'expected_manifest'=>null];
|
|
ensure(count($files)===90&&strlen(base64_encode(json_encode($hugePayload,JSON_THROW_ON_ERROR)))>2097152,'real 90-image set-write payload exceeds native 2 MiB shell argument limit');
|
|
$uploadsBeforeHuge=count(SFTP::$uploads);$configBeforeHuge=$transport->readConfig();
|
|
$transport->call($hugePayload);
|
|
ensure(\PrelandAdmin\Remote::manifest($site.'/3')===\PrelandAdmin\Remote::manifest($sourceSet),'multi-MiB set-write activates all 90 real files with exact hashes');
|
|
ensure(count(SFTP::$uploads)===$uploadsBeforeHuge+1,'multi-MiB set-write uses exactly one SFTP inbox');
|
|
$hugeInbox=SFTP::$uploads[array_key_last(SFTP::$uploads)];
|
|
ensure(!file_exists($hugeInbox),'multi-MiB set-write inbox removed after actual RPC');
|
|
ensure(max(array_map('strlen',SSH2::$commands))<=7000,'multi-MiB set-write final exec stays below bootstrap limit');
|
|
ensure($transport->readConfig()['version']===$configBeforeHuge['version'],'image upload preserves config and the 2 MiB config contract');
|
|
// Обрыв до исполнения большой команды не должен трогать опубликованный набор; inbox удаляется.
|
|
$hugePayload['expected_manifest']=\PrelandAdmin\Remote::manifest($site.'/3');$keptManifest=$hugePayload['expected_manifest'];SSH2::$dropInbox=true;
|
|
try{$transport->call($hugePayload);throw new RuntimeException('dropped multi-MiB set-write accepted');}
|
|
catch(Problem $error){ensure($error->status===504,'interrupted multi-MiB set-write remains unknown, not false success');}
|
|
finally{SSH2::$dropInbox=false;}
|
|
ensure(\PrelandAdmin\Remote::manifest($site.'/3')===$keptManifest,'interrupted multi-MiB exec preserves previously active set');
|
|
ensure(!file_exists(SFTP::$uploads[array_key_last(SFTP::$uploads)]),'interrupted multi-MiB set-write cleans private inbox');
|
|
} finally {
|
|
$iterator=new RecursiveIteratorIterator(new RecursiveDirectoryIterator($fixture,FilesystemIterator::SKIP_DOTS),RecursiveIteratorIterator::CHILD_FIRST);
|
|
foreach($iterator as $file)$file->isDir()?rmdir($file->getPathname()):unlink($file->getPathname());rmdir($fixture);
|
|
}
|
|
}
|