feat(admin): remember login for 90 days and support REG SSH limits
deploy-preland / deploy zoomstage.online (push) Successful in 57s
deploy-preland / deploy zoomstage.online (push) Successful in 57s
This commit is contained in:
1 parent
d04b40519e
commit
522aaf8554
13 files changed
+318
-29
No files matched your search
@@ -5,6 +5,9 @@
|
||||
проверяется по контракту CPanel и доставляется на выбранные хостинги. Изменение контента
|
||||
не требует Git/push/deploy; `admin/` не входит в публичную поставку прелендингов.
|
||||
|
||||
Редактирование реквизитов отключено по поручению владельца: `requisites` видны
|
||||
в сводке, но не создают полей редактора и не входят в «Выбрать все поля».
|
||||
|
||||
Спецификация и план — `docs/superpowers/{specs,plans}/2026-10-25-preland22-*` в репозитории,
|
||||
копии и фактический статус — в базе знаний, основной реестр — preland#22.
|
||||
|
||||
@@ -48,12 +51,25 @@ PHP-сессии хранятся в `state/sessions` с режимом0700, ф
|
||||
При первом переходе со старого ephemeral backing существующие session-файлы
|
||||
нужно перенести приватно до пересоздания; сам код не мигрирует старый `/tmp`.
|
||||
|
||||
Необязательная галочка «Запомнить меня на 90 дней» создаёт persistent cookie
|
||||
`preland_admin` с HttpOnly/Secure/SameSite=Strict. Срок фиксируется при входе,
|
||||
чтения его не продлевают. Данные такого входа лежат отдельно в
|
||||
`state/remember-sessions`0700, файлы0600. Без галочки остаются session-only cookie
|
||||
и прежний час бездействия. Анонимные обращения не создают долгоживущих файлов.
|
||||
«Выйти» удаляет серверный сеанс и cookie немедленно; пароль не сохраняется.
|
||||
|
||||
UID/GID приложения — 997:987. Приложение получает приватный state и ровно два local
|
||||
mount: `/targets/zoompixel.ru`, `/targets/zoomstage.online`. Весь `/srv/preland`, Docker
|
||||
socket, root SSH key и база основного сайта в приложение не передаются. Права публикации
|
||||
на каждом корне проверяются реальным временным файлом/rename при проверке подключения;
|
||||
нельзя включать запись до успешной проверки.
|
||||
|
||||
REG.RU ограничивает размер SSH exec-команды: длинный inline helper разрывал
|
||||
соединение после успешного входа. Доверенный helper передаётся сжатым zlib;
|
||||
финальная команда ограничена7000байт, большие payload идут в закрытый SFTP-inbox.
|
||||
На удалённом PHP требуется `gzinflate`; отсутствие возвращает понятную ошибку.
|
||||
Содержимое inbox удаляется и при нормальном завершении, и резервно при обрыве.
|
||||
|
||||
Два REG.RU-хостинга подключаются SFTP/SSH с их обычными ограниченными учётными записями.
|
||||
Нужны абсолютный корень, host key либо SHA256 fingerprint, пароль или приватный ключ,
|
||||
доступный через stdin CLI `bin/target-import.php`. Секрет хранится 0600 в приватном state,
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
"name": "zoomzilla/preland-admin",
|
||||
"description": "Защищённое управление runtime прелендингов",
|
||||
"license": "proprietary",
|
||||
"require": {"php": "^8.3", "ext-json": "*", "ext-zip": "*", "ext-imagick": "*", "phpseclib/phpseclib": "^3.0"},
|
||||
"require": {"php": "^8.3", "ext-json": "*", "ext-zip": "*", "ext-imagick": "*", "ext-zlib": "*", "phpseclib/phpseclib": "^3.0"},
|
||||
"config": {"sort-packages": true},
|
||||
"scripts": {"test": ["php tests/backend-review.php", "php tests/link-builder.php", "php tests/backend-http.php", "php tests/session-persistence.php"], "test:worker": "php tests/backend-worker.php"}
|
||||
"scripts": {"test": ["php tests/backend-review.php", "php tests/link-builder.php", "php tests/backend-http.php", "php tests/session-persistence.php", "php tests/remember-auth.php", "php tests/transport-command-limit.php"], "test:worker": "php tests/backend-worker.php"}
|
||||
}
|
||||
Generated
+3
-2
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "5f68a3c22e983ea2df00b8c661b975a4",
|
||||
"content-hash": "571e6007d448594fcee9719e8947dd65",
|
||||
"packages": [
|
||||
{
|
||||
"name": "paragonie/constant_time_encoding",
|
||||
@@ -246,7 +246,8 @@
|
||||
"php": "^8.3",
|
||||
"ext-json": "*",
|
||||
"ext-zip": "*",
|
||||
"ext-imagick": "*"
|
||||
"ext-imagick": "*",
|
||||
"ext-zlib": "*"
|
||||
},
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.6.0"
|
||||
|
||||
@@ -5,3 +5,4 @@
|
||||
/* Overview and link builder retain the existing CPanel / Bootstrap surface. */
|
||||
.overview-wrapper{margin-top:15px}.overview-table{min-width:760px;table-layout:fixed}.overview-table th:first-child{width:110px}.overview-table th:not(:first-child){min-width:190px}.overview-table th,.overview-table td{vertical-align:top;overflow-wrap:anywhere}.overview-table th:first-child,.overview-table td:first-child{position:sticky;left:0;background:#f5f5f5;z-index:1}.overview-table caption{text-align:left;padding:8px 0}.overview-domain{font-size:15px;margin-bottom:6px}.overview-status{font-size:12px;font-weight:400;margin:8px 0}.overview-weights{list-style:none;padding:0;margin:7px 0 0}.overview-weights li{margin:3px 0}.overview-warning{color:#8a6d3b;margin-top:8px;font-size:12px}.overview-error{color:#a94442}.overview-table .toolbar{gap:6px;margin-bottom:0}.builder-domain{display:block;max-width:450px;margin:15px 0}.builder-field{display:block}.builder-field select{margin-top:5px}#builder-fields{margin:15px 0}#builder-result{margin-top:18px}#builder-url{font-family:monospace}#builder-state{margin-top:10px}#builder-warnings .alert{padding:10px}#builder-save{margin-top:5px}@media(max-width:600px){.overview-table{min-width:720px}.overview-table th:first-child{width:90px}.overview-table .btn{padding:4px 8px}.builder-domain{max-width:100%}}
|
||||
.overview-plain{white-space:pre-wrap}
|
||||
.login-panel .remember-choice{display:flex;align-items:center;gap:6px;font-weight:400}.login-panel .remember-choice input{flex:0 0 auto;margin:0}
|
||||
@@ -218,7 +218,15 @@
|
||||
const fields=state.fields.slice(groupFieldsStart);section.prepend(button('Выбрать группу '+marker,()=>{fields.forEach(f=>f.check.checked=true);updateSummary();}));
|
||||
}
|
||||
function renderConfig(){state.fields=[];const container=$('config-fields');container.replaceChildren();const read=readCurrent();if(!read)return;const config=read.config;
|
||||
for(const [key,value]of Object.entries(config))if(key!=='utm')typed(container,[key],value,key==='host'?'Основной сайт':key==='requisites'?'Реквизиты':key);
|
||||
for(const [key,value]of Object.entries(config))if(key!=='utm'){
|
||||
if(key==='requisites'){
|
||||
// По просьбе владельца (#22/comment3770) редактирование реквизитов отключено.
|
||||
// Ключ config и отображение в сводке сохраняются; в state.fields реквизитов нет.
|
||||
// typed(container,[key],value,'Реквизиты');
|
||||
continue;
|
||||
}
|
||||
typed(container,[key],value,key==='host'?'Основной сайт':key);
|
||||
}
|
||||
if(config.utm&&typeof config.utm==='object'){
|
||||
for(const marker of markers){const key=Object.hasOwn(config.utm,marker)?marker:aliases[marker]&&Object.hasOwn(config.utm,aliases[marker])?aliases[marker]:marker==='banch'&&Object.hasOwn(config.utm,'chob')?'chob':null;if(key)utmGroup(container,key,config.utm[key],marker);else container.append(el('div',{class:'field-block'},el('h2',{text:marker+'= UTM'}),el('p',{class:'text-muted',text:'Группа отсутствует в этом config.json; автоматически не добавляется.'})));}
|
||||
for(const [key,value]of Object.entries(config.utm))if(!markers.includes(key)&&!Object.values(aliases).includes(key)&&key!=='chob')typed(container,['utm',key],value,key);
|
||||
@@ -252,7 +260,7 @@
|
||||
async function pollJob(id,dialog){pollTimers.delete(id);const r=await api('jobs?id='+encodeURIComponent(id));const job=r.job;state.jobs=state.jobs.filter(j=>j.id!==id);state.jobs.unshift(job);renderJobs();if(dialog&&$('indpic-result-modal').open){modal('Результат проверки indpic',(job.results||[]).map(v=>targetName(v.target)+'\n'+(statuses[v.status]||v.status)+'\n'+(v.message||'')).join('\n\n')||'Ожидание…');}if(job.status!=='completed'){clearTimeout(pollTimers.get(id));pollTimers.set(id,setTimeout(()=>run(()=>pollJob(id,dialog)),1400));}else{clearTimeout(pollTimers.get(id));pollTimers.delete(id);const configChanged=['apply','restore'].includes(job.type);if(configChanged)for(const result of job.results||[])if(result.status==='success'){state.reads.delete(result.target);run(()=>refreshOverviewTarget(result.target));}if((job.results||[]).some(r=>r.status==='success'))notice('Задание завершено. Результаты показаны отдельно для каждого домена.'+(configChanged?' Перед следующим изменением перечитайте домен.':''),'info');if(state.source){await loadSets();await loadBackups();}updateSummary();}}
|
||||
async function loadBackups(){const box=$('backups');box.replaceChildren();if(!state.source)return;const targetId=state.source;const r=await api('backups?target='+encodeURIComponent(targetId));if(targetId!==state.source)return;for(const backup of r.backups||[]){box.append(el('div',{class:'toolbar'},el('span',{class:'break',text:(backup.created_at?formatDate(backup.created_at):'')+' · '+backup.id}),button('Восстановить',async()=>{if(!confirm('Восстановить резерв '+backup.id+' на '+targetName(state.source)+'?'))return;const read=readCurrent();if(!read)throw new Error('Перед восстановлением перечитайте текущий домен.');const result=await api('restore',{target:state.source,id:backup.id,version:read.version});await showJob(result.job);})));}if(!(r.backups||[]).length)box.append(el('p',{class:'text-muted',text:'Резервных копий пока нет.'}));}
|
||||
function renderConnections(){const list=$('connection-list');list.replaceChildren();for(const t of state.targets){list.append(el('div',{class:'connection'},el('span',{class:'break',text:t.name+' · '+targetName(t.id)+' · '+t.type+' · '+(t.enabled?'включено':'отключено')+(t.verified?' · соединение проверено':' · требуется проверка')+(t.has_secret?' · секрет сохранён':'')}),button('Изменить',()=>{const form=$('connection-form');form.reset();for(const key of ['id','name','domain','environment','type','host','port','user','root','host_key'])form.elements[key].value=t[key]??'';form.elements.enabled.checked=!!t.enabled;form.elements.id.readOnly=true;$('connection-title').textContent='Изменить подключение';form.elements.name.focus();}),button('Проверить соединение',async()=>{const result=await api('targets/test',{id:t.id});modal('Проверка соединения',result.message||result.result?.message||'Соединение и пути проверены.');}),button('Удалить',async()=>{if(confirm('Удалить подключение '+t.domain+'? Сайт и файлы останутся.')){await api('targets',{id:t.id},'DELETE');if(state.source===t.id){state.source='';state.fields=[];$('config-fields').replaceChildren();}await loadState();}},'danger')));}}
|
||||
$('login-form').addEventListener('submit',event=>{event.preventDefault();run(async()=>{const form=event.currentTarget;const submit=form.querySelector('button');submit.disabled=true;try{const data=await api('login',{username:form.username.value,password:form.password.value});state.csrf=data.csrf||'';form.password.value='';await loadState();notice('','info');}finally{submit.disabled=false;}});});
|
||||
$('login-form').addEventListener('submit',event=>{event.preventDefault();run(async()=>{const form=event.currentTarget;const submit=form.querySelector('button');submit.disabled=true;try{const data=await api('login',{username:form.username.value,password:form.password.value,remember:form.remember.checked});state.csrf=data.csrf||'';form.password.value='';await loadState();notice('','info');}finally{submit.disabled=false;}});});
|
||||
$('logout').addEventListener('click',()=>run(async()=>{await api('logout',{});for(const timer of pollTimers.values())clearTimeout(timer);pollTimers.clear();showLogin();notice('Сеанс завершён.','info');}));
|
||||
$('source-target').addEventListener('change',event=>run(()=>openTarget(event.target.value)));
|
||||
$('reload').addEventListener('click',()=>run(async()=>{if(!state.source)throw new Error('Выберите домен.');if(state.fields.some(f=>f.check.checked)&&!confirm('Перечитать домен и сбросить несохранённый выбор полей?'))return;await openTarget(state.source,true);}));
|
||||
|
||||
@@ -19,7 +19,10 @@ try {
|
||||
if($raw!==''){$object=json_decode($raw,false,32,JSON_THROW_ON_ERROR);if(!$object instanceof stdClass)throw new Problem('Ожидался объект запроса.');$data=(array)$object;}
|
||||
}else $data=$_POST;
|
||||
}
|
||||
if($path==='/api/login'&&$method==='POST')reply($auth->login((string)($data['username']??''),(string)($data['password']??''),$_SERVER['REMOTE_ADDR']??'unknown'));
|
||||
if($path==='/api/login'&&$method==='POST'){
|
||||
if(array_key_exists('remember',$data)&&!is_bool($data['remember']))throw new Problem('Поле «Запомнить меня» должно быть логическим значением.');
|
||||
reply($auth->login((string)($data['username']??''),(string)($data['password']??''),$_SERVER['REMOTE_ADDR']??'unknown',$data['remember']??false));
|
||||
}
|
||||
$user=$auth->requireUser();
|
||||
if($method!=='GET')$auth->csrf($_SERVER['HTTP_X_CSRF_TOKEN']??'');
|
||||
if($path==='/api/logout'&&$method==='POST'){$auth->logout();reply([]);}
|
||||
|
||||
+53
-12
@@ -3,19 +3,49 @@ declare(strict_types=1);
|
||||
namespace PrelandAdmin;
|
||||
final class Auth
|
||||
{
|
||||
private const REMEMBER_SECONDS=90*86400;
|
||||
private static bool $rememberSession=false;
|
||||
public function __construct(private Store $store) {}
|
||||
public static function session(): void {
|
||||
if(session_status()===PHP_SESSION_ACTIVE)return;
|
||||
$directory=self::sessionDirectory();
|
||||
if(ini_get('session.save_handler')!=='files'||session_save_path($directory)===false)throw new Problem('Файловое хранилище сессий недоступно.',503);
|
||||
ini_set('session.use_strict_mode','1');ini_set('session.use_only_cookies','1');
|
||||
ini_set('session.gc_maxlifetime','3600');ini_set('session.gc_probability','1');ini_set('session.gc_divisor','100');
|
||||
session_name('preland_admin');
|
||||
session_set_cookie_params(['lifetime'=>0,'path'=>'/','secure'=>getenv('ADMIN_COOKIE_SECURE')!=='0','httponly'=>true,'samesite'=>'Strict']);
|
||||
$rememberDirectory=self::sessionDirectory('remember-sessions',false);
|
||||
$incoming=$_COOKIE['preland_admin']??null;
|
||||
$validId=is_string($incoming)&&preg_match('/^[a-zA-Z0-9,-]{22,256}$/D',$incoming)===1;
|
||||
if(!$validId)unset($_COOKIE['preland_admin']);
|
||||
$remember=false;
|
||||
if($validId&&$rememberDirectory!==null){
|
||||
$candidate=$rememberDirectory.'/sess_'.$incoming;clearstatcache(true,$candidate);
|
||||
if(is_link($candidate))throw new Problem('Символические ссылки файлов сессий запрещены.',503);
|
||||
$stat=@lstat($candidate);$remember=$stat!==false&&($stat['mode']&0170000)===0100000;
|
||||
}
|
||||
self::configure($remember?$rememberDirectory:$directory,$remember);
|
||||
if(!@session_start())throw new Problem('Не удалось открыть приватное хранилище сессий.',503);
|
||||
// Между проверкой SID и session_start файл мог быть отозван logout другого запроса.
|
||||
// Новый анонимный SID не должен оставаться в долгоживущем каталоге.
|
||||
if($remember&&(session_id()!==$incoming||empty($_SESSION['user'])||!is_int($_SESSION['remember_until']??null))){
|
||||
self::destroy(false);self::configure($directory,false);
|
||||
if(!@session_start())throw new Problem('Не удалось открыть приватное хранилище сессий.',503);
|
||||
}
|
||||
}
|
||||
private static function configure(string $directory,bool $remember): void {
|
||||
if(ini_get('session.save_handler')!=='files'||session_save_path($directory)===false)throw new Problem('Файловое хранилище сессий недоступно.',503);
|
||||
ini_set('session.use_strict_mode','1');ini_set('session.use_only_cookies','1');
|
||||
ini_set('session.gc_maxlifetime',(string)($remember?self::REMEMBER_SECONDS:3600));ini_set('session.gc_probability','1');ini_set('session.gc_divisor','100');
|
||||
session_name('preland_admin');
|
||||
session_set_cookie_params(['lifetime'=>$remember?self::REMEMBER_SECONDS:0]+self::cookieOptions());
|
||||
self::$rememberSession=$remember;
|
||||
}
|
||||
private static function cookieOptions(): array {return ['path'=>'/','domain'=>(string)ini_get('session.cookie_domain'),'secure'=>getenv('ADMIN_COOKIE_SECURE')!=='0','httponly'=>true,'samesite'=>'Strict'];}
|
||||
private static function destroy(bool $clearCookie): void {
|
||||
$_SESSION=[];
|
||||
if(session_status()===PHP_SESSION_ACTIVE&&!@session_destroy())throw new Problem('Не удалось отозвать сессию.',503);
|
||||
unset($_COOKIE['preland_admin']);session_id('');
|
||||
if($clearCookie)setcookie('preland_admin','',['expires'=>time()-3600]+self::cookieOptions());
|
||||
self::$rememberSession=false;
|
||||
}
|
||||
/** Готовит приватный backing до Store: не создаёт данные в небезопасном state. */
|
||||
private static function sessionDirectory(): string {
|
||||
private static function sessionDirectory(string $bucket='sessions',bool $create=true): ?string {
|
||||
$state=rtrim(getenv('ADMIN_STATE_DIR')?:'/var/lib/preland-admin','/');
|
||||
if($state===''||!str_starts_with($state,'/')||str_contains($state,'\\')||preg_match('/[\x00-\x1f]/',$state))throw new Problem('Для сессий требуется абсолютный приватный путь состояния.',503);
|
||||
$parts=explode('/',substr($state,1));
|
||||
@@ -24,17 +54,21 @@ final class Auth
|
||||
foreach(explode(',',getenv('ADMIN_LOCAL_ROOTS')?:'') as $root)$publicRoots[]=trim($root);
|
||||
foreach($publicRoots as $root){$root=rtrim($root,'/');if($root!==''&&($state===$root||str_starts_with($state,$root.'/')))throw new Problem('Сессии нельзя хранить в публичном каталоге.',503);}
|
||||
$current='';
|
||||
foreach([...$parts,'sessions'] as $part){
|
||||
foreach([...$parts,$bucket] as $part){
|
||||
$current.='/'.$part;clearstatcache(true,$current);
|
||||
if(is_link($current))throw new Problem('Символические ссылки в пути сессий запрещены.',503);
|
||||
if(!is_dir($current)&&!@mkdir($current,0700)&&!is_dir($current))throw new Problem('Не удалось подготовить приватное хранилище сессий.',503);
|
||||
if(!is_dir($current)){
|
||||
if(file_exists($current))throw new Problem('Не удалось подготовить приватное хранилище сессий.',503);
|
||||
if(!$create)return null;
|
||||
if(!@mkdir($current,0700)&&!is_dir($current))throw new Problem('Не удалось подготовить приватное хранилище сессий.',503);
|
||||
}
|
||||
clearstatcache(true,$current);
|
||||
if(is_link($current)||realpath($current)!==$current)throw new Problem('Небезопасный путь хранилища сессий.',503);
|
||||
}
|
||||
if(!@chmod($current,0700)||!is_readable($current)||!is_writable($current))throw new Problem('Приватное хранилище сессий недоступно.',503);
|
||||
return $current;
|
||||
}
|
||||
public function login(string $username,string $password,string $ip): array {
|
||||
public function login(string $username,string $password,string $ip,bool $remember=false): array {
|
||||
$key=hash('sha256',$ip);$this->store->locked('login',function() use($username,$password,$key){
|
||||
$attempts=$this->store->read('login-attempts.json');$now=time();$entry=$attempts[$key]??['count'=>0,'since'=>$now];if($entry['since']<$now-900)$entry=['count'=>0,'since'=>$now];
|
||||
if($entry['count']>=10)throw new Problem('Слишком много попыток. Подождите 15 минут.',429);
|
||||
@@ -44,11 +78,18 @@ final class Auth
|
||||
if(!$valid){$entry['count']++;$attempts[$key]=$entry;$attempts=array_filter($attempts,static fn($e)=>$e['since']>$now-900);$this->store->write('login-attempts.json',$attempts);throw new Problem('Неверный логин или пароль.',401);}
|
||||
unset($attempts[$key]);$this->store->write('login-attempts.json',$attempts);
|
||||
});
|
||||
session_regenerate_id(true);$_SESSION=['user'=>$username,'csrf'=>bin2hex(random_bytes(32)),'last'=>time()];return ['user'=>$username,'csrf'=>$_SESSION['csrf']];
|
||||
// Каталог проверяется после пароля, но до уничтожения действующего входа.
|
||||
$directory=self::sessionDirectory($remember?'remember-sessions':'sessions');
|
||||
self::destroy(false);self::configure($directory,$remember);
|
||||
if(!@session_start())throw new Problem('Не удалось создать новую сессию.',503);
|
||||
$now=time();$_SESSION=['user'=>$username,'csrf'=>bin2hex(random_bytes(32)),'last'=>$now];
|
||||
if($remember)$_SESSION['remember_until']=$now+self::REMEMBER_SECONDS;
|
||||
return ['user'=>$username,'csrf'=>$_SESSION['csrf']];
|
||||
}
|
||||
public function requireUser(): string {
|
||||
if(empty($_SESSION['user'])||($_SESSION['last']??0)<time()-3600){$_SESSION=[];throw new Problem('Войдите в админку.',401);}$_SESSION['last']=time();return $_SESSION['user'];
|
||||
$now=time();$expired=self::$rememberSession?(!is_int($_SESSION['remember_until']??null)||$_SESSION['remember_until']<=$now):(($_SESSION['last']??0)<$now-3600);
|
||||
if(empty($_SESSION['user'])||$expired){if(self::$rememberSession||!empty($_SESSION['user']))self::destroy(true);else $_SESSION=[];throw new Problem('Войдите в админку.',401);}$_SESSION['last']=$now;return $_SESSION['user'];
|
||||
}
|
||||
public function csrf(string $token): void {if(!$token||!hash_equals($_SESSION['csrf']??'', $token))throw new Problem('Сессия формы истекла. Обновите страницу.',403);}
|
||||
public function logout(): void {$_SESSION=[];session_regenerate_id(true);}
|
||||
public function logout(): void {self::destroy(true);}
|
||||
}
|
||||
+32
-9
@@ -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 хоста не совпадает.');
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require dirname(__DIR__).'/bootstrap.php';
|
||||
function rememberCheck(bool $condition,string $message): void {if(!$condition)throw new RuntimeException($message);echo "PASS {$message}\n";}
|
||||
$rememberRoot=sys_get_temp_dir().'/remember-auth-'.bin2hex(random_bytes(8));mkdir($rememberRoot,0700,true);
|
||||
$rememberState=$rememberRoot.'/state';$rememberServers=[];
|
||||
function rememberServer(string $label,?string $state=null): int {
|
||||
global $rememberRoot,$rememberState,$rememberServers;
|
||||
$env=getenv();$env['ADMIN_STATE_DIR']=$state??$rememberState;$env['ADMIN_USERNAME']='remember-fixture';$env['ADMIN_PASSWORD_HASH']=password_hash('remember-fixture-password',PASSWORD_DEFAULT);$env['ADMIN_COOKIE_SECURE']='0';$env['PHP_CLI_SERVER_WORKERS']='4';
|
||||
$port=random_int(19000,29000);$log=fopen($rememberRoot.'/'.$label.'.log','a');$default=$rememberRoot.'/ephemeral-'.$label;mkdir($default,0700);
|
||||
$p=proc_open([PHP_BINARY,'-d','session.save_path='.$default,'-S','127.0.0.1:'.$port,'-t',dirname(__DIR__).'/public',dirname(__DIR__).'/public/index.php'],[0=>['pipe','r'],1=>$log,2=>$log],$pipes,null,$env);if(!is_resource($p))throw new RuntimeException('server failed');fclose($pipes[0]);$rememberServers[$label]=[$p,$log];
|
||||
$until=microtime(true)+5;do{$s=@fsockopen('127.0.0.1',$port);if($s){fclose($s);return $port;}usleep(20000);}while(microtime(true)<$until);throw new RuntimeException('server not ready');
|
||||
}
|
||||
function rememberStop(string $label): void {global $rememberServers;if(!isset($rememberServers[$label]))return;[$p,$log]=$rememberServers[$label];proc_terminate($p);proc_close($p);fclose($log);unset($rememberServers[$label]);}
|
||||
function rememberRequest(int $port,string $path,string $cookie='',?array $body=null,?string $csrf=null): array {
|
||||
$h=['Content-Type: application/json'];if($cookie!=='')$h[]='Cookie: '.$cookie;if($csrf!==null)$h[]='X-CSRF-Token: '.$csrf;
|
||||
$ctx=stream_context_create(['http'=>['method'=>$body===null?'GET':'POST','header'=>implode("\r\n",$h),'content'=>$body===null?'':json_encode((object)$body,JSON_THROW_ON_ERROR),'ignore_errors'=>true,'timeout'=>10]]);$text=file_get_contents('http://127.0.0.1:'.$port.$path,false,$ctx);$headers=$http_response_header??[];preg_match('~HTTP/\S+ (\d+)~',$headers[0]??'',$m);
|
||||
$setCookie=null;foreach($headers as $header)if(str_starts_with(strtolower($header),'set-cookie: preland_admin=')){$setCookie=substr($header,12);$cookie=explode(';',$setCookie)[0];}
|
||||
clearstatcache();return [(int)($m[1]??0),json_decode((string)$text,true,64,JSON_THROW_ON_ERROR),$cookie,$setCookie];
|
||||
}
|
||||
function rememberLogin(int $port,mixed $remember=false,string $cookie=''): array {return rememberRequest($port,'/api/login',$cookie,['username'=>'remember-fixture','password'=>'remember-fixture-password','remember'=>$remember]);}
|
||||
function rememberFile(string $cookie,bool $remember=true): string {global $rememberState;return $rememberState.'/'.($remember?'remember-sessions':'sessions').'/sess_'.substr($cookie,strlen('preland_admin='));}
|
||||
function rememberEdit(string $cookie,string $field,int $value): void {$file=rememberFile($cookie);$text=file_get_contents($file);$new=preg_replace('/'.preg_quote($field,'/').'\|i:\d+;/', $field.'|i:'.$value.';', $text,1,$count);if($count!==1)throw new RuntimeException('Missing fixture session field '.$field);file_put_contents($file,$new);}
|
||||
function rememberBacking(string $cookie): array {
|
||||
global $rememberState;
|
||||
$code='require $argv[1];$_COOKIE["preland_admin"]=$argv[2];PrelandAdmin\\Auth::session();echo json_encode(["bucket"=>basename(session_save_path()),"gc"=>(int)ini_get("session.gc_maxlifetime"),"cookie_lifetime"=>session_get_cookie_params()["lifetime"]]);session_write_close();';
|
||||
$env=getenv();$env['ADMIN_STATE_DIR']=$rememberState;$env['ADMIN_COOKIE_SECURE']='0';$p=proc_open([PHP_BINARY,'-r',$code,dirname(__DIR__).'/bootstrap.php',substr($cookie,strlen('preland_admin='))],[0=>['pipe','r'],1=>['pipe','w'],2=>STDERR],$pipes,null,$env);fclose($pipes[0]);$out=stream_get_contents($pipes[1]);fclose($pipes[1]);if(proc_close($p)!==0)throw new RuntimeException('Backing inspection failed');return json_decode($out,true,64,JSON_THROW_ON_ERROR);
|
||||
}
|
||||
try{
|
||||
$a=rememberServer('a');
|
||||
[$status]=rememberRequest($a,'/api/state');rememberCheck($status===401&&!is_dir($rememberState.'/remember-sessions'),'anonymous request does not create long-lived backing');
|
||||
[$status]=rememberRequest($a,'/api/login','',['username'=>'remember-fixture','password'=>'wrong','remember'=>true]);rememberCheck($status===401&&!is_dir($rememberState.'/remember-sessions'),'failed remembered login does not create long-lived backing');
|
||||
foreach(['true',1,null,[],(object)['value'=>true]] as $bad){[$status]=rememberLogin($a,$bad);rememberCheck($status===422,'remember input requires strict boolean');}
|
||||
[$status,$regular,$regularCookie,$header]=rememberLogin($a,false);rememberCheck($status===200&&!str_contains(strtolower((string)$header),'expires=')&&!str_contains(strtolower((string)$header),'max-age='),'unchecked login issues browser-session cookie');
|
||||
$normalFile=rememberFile($regularCookie,false);rememberCheck(is_file($normalFile)&&!is_dir($rememberState.'/remember-sessions'),'regular login remains in short-lived backing');
|
||||
$backing=rememberBacking($regularCookie);rememberCheck($backing===['bucket'=>'sessions','gc'=>3600,'cookie_lifetime'=>0],'regular backing keeps GC one hour and browser-session lifetime');
|
||||
$legacy='preland_admin='.bin2hex(random_bytes(16));$legacyCsrf=bin2hex(random_bytes(32));file_put_contents(rememberFile($legacy,false),'user|'.serialize('remember-fixture').'csrf|'.serialize($legacyCsrf).'last|'.serialize(time()));
|
||||
[$status,$oldState]=rememberRequest($a,'/api/state',$legacy);rememberCheck($status===200&&$oldState['csrf']===$legacyCsrf&&is_file(rememberFile($legacy,false)),'existing d04-style ordinary session continues without migration or deletion');
|
||||
$before=time();[$status,$login,$cookie,$header]=rememberLogin($a,true,$regularCookie);
|
||||
rememberCheck($status===200&&preg_match('/expires=([^;]+)/i',(string)$header,$match)===1&&abs(strtotime($match[1])-($before+7776000))<3,'checked login issues native session cookie expiring 90 days from login');
|
||||
rememberCheck(str_contains(strtolower($header),'httponly')&&str_contains(strtolower($header),'samesite=strict'),'remembered cookie preserves HttpOnly and SameSite Strict');
|
||||
rememberCheck($cookie!==$regularCookie&&!is_file($normalFile)&&is_file(rememberFile($cookie)),'regular to remember login destroys old backing and creates fresh native SID');
|
||||
$backing=rememberBacking($cookie);rememberCheck($backing===['bucket'=>'remember-sessions','gc'=>7776000,'cookie_lifetime'=>7776000],'only authenticated remember bucket uses ninety-day GC and cookie lifetime');
|
||||
[$status]=rememberRequest($a,'/api/state',$regularCookie);rememberCheck($status===401,'old regular cookie cannot be reused after remembered login');
|
||||
$text=file_get_contents(rememberFile($cookie));rememberCheck(!str_contains($text,'remember-fixture-password')&&!str_contains($text,'password'),'remembered server session stores no password');
|
||||
preg_match('/remember_until\|i:(\d+);/',$text,$untilMatch);$absolute=(int)($untilMatch[1]??0);rememberCheck($absolute>=$before+7776000&&$absolute<=time()+7776000,'server stores absolute remember-until bound from login');
|
||||
rememberEdit($cookie,'last',time()-7200);[$status,$state,$same,$refresh]=rememberRequest($a,'/api/state',$cookie);
|
||||
rememberCheck($status===200&&$state['csrf']===$login['csrf']&&$same===$cookie&&$refresh===null,'remembered session survives old idle timestamp without rotating cookie or CSRF');
|
||||
preg_match('/remember_until\|i:(\d+);/',file_get_contents(rememberFile($cookie)),$after);rememberCheck((int)$after[1]===$absolute,'ordinary read does not slide absolute remember deadline');
|
||||
rememberStop('a');$b=rememberServer('b');[$status,$body]=rememberRequest($b,'/api/state',$cookie);rememberCheck($status===200&&$body['csrf']===$login['csrf'],'remembered login survives fresh server and separate ephemeral session path');
|
||||
$clients=[];$outputs=[];$code='$ctx=stream_context_create(["http"=>["header"=>"Cookie: ".$argv[2],"ignore_errors"=>true,"timeout"=>5]]);$body=file_get_contents($argv[1],false,$ctx);echo json_encode(["body"=>json_decode($body,true),"set_cookie"=>count(array_filter($http_response_header,fn($h)=>str_starts_with(strtolower($h),"set-cookie:")))]);';
|
||||
for($i=0;$i<4;$i++){$path=$rememberRoot.'/parallel-'.$i.'.json';$out=fopen($path,'w');$p=proc_open([PHP_BINARY,'-r',$code,'http://127.0.0.1:'.$b.'/api/state',$cookie],[0=>['pipe','r'],1=>$out,2=>STDERR],$pipes);fclose($pipes[0]);$clients[]=[$p,$out];$outputs[]=$path;}
|
||||
foreach($clients as [$p,$out]){if(proc_close($p)!==0)throw new RuntimeException('Parallel request failed');fclose($out);}foreach($outputs as $path){$parallel=json_decode(file_get_contents($path),true,64,JSON_THROW_ON_ERROR);rememberCheck(($parallel['body']['user']??null)==='remember-fixture'&&($parallel['body']['csrf']??null)===$login['csrf']&&$parallel['set_cookie']===0,'parallel remembered requests preserve authentication, native cookie and CSRF');}
|
||||
$other=rememberServer('other',$rememberRoot.'/other-state');[$status]=rememberRequest($other,'/api/state',$cookie);rememberCheck($status===401,'remember cookie cannot authenticate against unrelated state');
|
||||
// Абсолютный срок важнее свежего last; пограничный срок ещё до 90 дней допустим.
|
||||
rememberEdit($cookie,'remember_until',time()+5);rememberEdit($cookie,'last',time()-7200);[$status]=rememberRequest($b,'/api/state',$cookie);rememberCheck($status===200,'remembered session before absolute deadline ignores regular idle expiry');
|
||||
rememberEdit($cookie,'remember_until',time()-1);rememberEdit($cookie,'last',time());[$status,$body,$ignored,$expiredHeader]=rememberRequest($b,'/api/state',$cookie);rememberCheck($status===401&&!is_file(rememberFile($cookie))&&str_contains(strtolower((string)$expiredHeader),'expires='),'absolute expiry destroys remembered backing and clears cookie despite fresh last');
|
||||
[$status,$login,$cookie]=rememberLogin($b,true);$longFile=rememberFile($cookie);[$status,$newLogin,$newCookie,$header]=rememberLogin($b,false,$cookie);rememberCheck($status===200&&$newCookie!==$cookie&&!is_file($longFile)&&is_file(rememberFile($newCookie,false))&&!str_contains(strtolower((string)$header),'expires='),'remember to regular login destroys long-lived SID and restores session-only cookie');
|
||||
[$status]=rememberRequest($b,'/api/state',$cookie);rememberCheck($status===401,'old remembered cookie cannot be reused after regular login');
|
||||
$normal=rememberFile($newCookie,false);$raw=file_get_contents($normal);file_put_contents($normal,preg_replace('/last\|i:\d+;/','last|i:'.(time()-3601).';',$raw));[$status]=rememberRequest($b,'/api/state',$newCookie);rememberCheck($status===401,'unchecked login still enforces one-hour idle expiry');
|
||||
[$status,$login,$cookie]=rememberLogin($b,true);$file=rememberFile($cookie);[$status]=rememberRequest($b,'/api/logout',$cookie,[],'invalid');rememberCheck($status===403&&is_file($file),'remembered logout requires existing CSRF');
|
||||
[$status,$body,$ignored,$clear]=rememberRequest($b,'/api/logout',$cookie,[],$login['csrf']);rememberCheck($status===200&&!is_file($file)&&str_contains(strtolower((string)$clear),'expires=')&&str_contains(strtolower((string)$clear),'httponly')&&str_contains(strtolower((string)$clear),'samesite=strict'),'logout immediately destroys remembered SID and clears same cookie flags');
|
||||
[$status]=rememberRequest($b,'/api/state',$cookie);rememberCheck($status===401,'captured pre-logout remembered cookie cannot be replayed');
|
||||
$longCount=count(glob($rememberState.'/remember-sessions/sess_*')?:[]);for($i=0;$i<3;$i++)rememberRequest($b,'/api/state','preland_admin='.bin2hex(random_bytes(16)));rememberCheck(count(glob($rememberState.'/remember-sessions/sess_*')?:[])===$longCount,'arbitrary anonymous SIDs create no 90-day files');
|
||||
rememberRequest($b,'/api/login','',['username'=>'remember-fixture','password'=>'wrong','remember'=>true]);rememberCheck(count(glob($rememberState.'/remember-sessions/sess_*')?:[])===$longCount,'failed remembered login creates no long-lived files in existing bucket');
|
||||
rememberCheck((fileperms($rememberState.'/remember-sessions')&0777)===0700&&(fileperms($rememberState.'/sessions')&0777)===0700,'both backing directories keep private 0700 modes');
|
||||
[$status,$normal,$normalCookie]=rememberLogin($b,false);$normalPath=rememberFile($normalCookie,false);$link=rememberFile($normalCookie,true);symlink($normalPath,$link);[$status]=rememberRequest($b,'/api/state',$normalCookie);rememberCheck($status===503&&is_file($normalPath),'remembered SID symlink is rejected without reading regular backing through it');unlink($link);
|
||||
[$status,$fresh,$freshCookie]=rememberLogin($b,true,$normalCookie);rememberCheck((fileperms(rememberFile($freshCookie))&0777)===0600,'authenticated remember file has private 0600 mode');
|
||||
mkdir($rememberRoot.'/external',0700);mkdir($rememberRoot.'/unsafe',0700);symlink($rememberRoot.'/external',$rememberRoot.'/unsafe/remember-sessions');$unsafe=rememberServer('unsafe',$rememberRoot.'/unsafe');[$status]=rememberRequest($unsafe,'/api/state');rememberCheck($status===503&&glob($rememberRoot.'/external/*')===[],'remember-directory symlink fails closed without external writes');
|
||||
echo "Remember auth contract complete\n";
|
||||
}finally{foreach(array_keys($rememberServers) as $label)rememberStop($label);}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?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;
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/* preland#22/comment3770. Real local Docker HTTP + persistent Chromium profile.
|
||||
* Cookie values/passwords are never written to evidence. */
|
||||
const {chromium}=require('playwright-core');
|
||||
const assert=require('node:assert/strict');
|
||||
const fs=require('node:fs');const os=require('node:os');const path=require('node:path');
|
||||
(async()=>{
|
||||
const profile=fs.mkdtempSync(path.join(os.tmpdir(),'preland-remember-'));
|
||||
const origin=process.env.ADMIN_URL||'http://admin',output=process.env.UI_OUTPUT||'/tmp';
|
||||
const launch=()=>chromium.launchPersistentContext(profile,{executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox'],baseURL:origin,viewport:{width:1440,height:1000}});
|
||||
let context=await launch();const errors=[];let page=await context.newPage();page.on('pageerror',e=>errors.push(e.message));
|
||||
try{
|
||||
await page.goto(origin);
|
||||
const remember=page.getByLabel('Запомнить меня на 90 дней',{exact:true});
|
||||
assert.equal(await remember.count(),1,'remember checkbox exists');assert.equal(await remember.isChecked(),false,'unchecked HTML default');
|
||||
await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin');await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password');await remember.check();
|
||||
const loginRequest=page.waitForRequest(r=>r.url().endsWith('/api/login')&&r.method()==='POST');await page.getByRole('button',{name:'Войти',exact:true}).click();assert.equal((await loginRequest).postDataJSON().remember,true,'explicit boolean true');
|
||||
await page.locator('#workspace').waitFor({state:'visible'});
|
||||
const cookies=await context.cookies(origin),now=Date.now()/1000;
|
||||
const durable=cookies.find(c=>process.env.REMEMBER_COOKIE?c.name===process.env.REMEMBER_COOKIE:c.httpOnly&&c.expires>now+86400*60);
|
||||
assert.ok(durable,'persistent HttpOnly remember cookie');assert.ok(Math.abs((durable.expires-now)-90*86400)<600,'expires approximately 90 days');assert.equal(durable.httpOnly,true);assert.equal(durable.sameSite,'Strict');
|
||||
const target=process.env.TARGET_ID||'a';
|
||||
const read=async()=>{const r=await page.request.get('/api/config?target='+target).then(r=>r.json());assert.ok(r.ok,r.error);return r;};const before=await read();
|
||||
assert.ok(Array.isArray(before.config.requisites)&&before.config.requisites.length,'fixture requisites required');
|
||||
await page.locator('#overview-table tr[data-overview-extra=requisites]').getByText(before.config.requisites[0],{exact:false}).first().waitFor();
|
||||
await page.locator('#source-target').selectOption(target);await page.getByLabel('Основной сайт',{exact:true}).waitFor();
|
||||
assert.equal(await page.locator('#config-fields').getByLabel('Реквизиты',{exact:true}).count(),0,'requisites editor hidden');
|
||||
assert.equal(await page.locator('#config-fields input[aria-label^="Применить поле requisites"]').count(),0,'requisites absent from editable fields');
|
||||
await page.getByRole('button',{name:'Выбрать все поля формы',exact:true}).click();assert.doesNotMatch(await page.locator('#change-summary').innerText(),/requisites/);await page.getByRole('button',{name:'Снять выбор полей',exact:true}).click();
|
||||
const after=await read();assert.equal(after.version,before.version);assert.deepEqual(after.config,before.config,'runtime unchanged');
|
||||
await page.screenshot({path:output+'/remember-requisites-editor.png',fullPage:true});
|
||||
await context.close();context=await launch();page=await context.newPage();page.on('pageerror',e=>errors.push(e.message));await page.goto(origin);await page.locator('#workspace').waitFor({state:'visible'});assert.equal(await page.locator('#login-panel').isVisible(),false,'reopen authenticates without password');
|
||||
await page.getByRole('button',{name:'Выйти',exact:true}).click();await page.locator('#login-panel').waitFor({state:'visible'});
|
||||
await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin');await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password');await page.getByLabel('Запомнить меня на 90 дней',{exact:true}).uncheck();
|
||||
const shortRequest=page.waitForRequest(r=>r.url().endsWith('/api/login')&&r.method()==='POST');await page.getByRole('button',{name:'Войти',exact:true}).click();assert.equal((await shortRequest).postDataJSON().remember,false,'explicit boolean false');await page.locator('#workspace').waitFor({state:'visible'});
|
||||
const shortCookies=await context.cookies(origin);assert.ok(!shortCookies.some(c=>c.name===durable.name&&c.expires>Date.now()/1000+86400),'unchecked login does not retain 90-day cookie');
|
||||
assert.deepEqual(errors,[]);fs.writeFileSync(output+'/remember-evidence.json',JSON.stringify({cookie:{name:durable.name,expires:durable.expires,httpOnly:durable.httpOnly,sameSite:durable.sameSite,secure:durable.secure},configVersion:after.version,pageErrors:errors,reopenAuthenticated:true,uncheckedRememberFalse:true},null,2));
|
||||
console.log('PASS actual remember90 checkbox/boolean/cookie/persistent reopen + session login + readonly requisites/overview/config unchanged');
|
||||
}finally{await context.close();}
|
||||
})().catch(error=>{console.error(error);process.exit(1);});
|
||||
@@ -0,0 +1,9 @@
|
||||
/* preland#22/comment3770: requisites are overview-only, never selected for apply. */
|
||||
const {chromium}=require('playwright-core');const assert=require('node:assert/strict');
|
||||
(async()=>{
|
||||
const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']});
|
||||
try{const page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://admin'});await page.goto(process.env.ADMIN_URL||'http://admin');await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin');await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor({state:'visible'});
|
||||
const target=process.env.TARGET_ID||'a',read=()=>page.request.get('/api/config?target='+target).then(r=>r.json());const before=await read();assert.ok(before.ok,before.error);await page.locator('#source-target').selectOption(target);await page.getByLabel('Основной сайт',{exact:true}).waitFor();assert.equal(await page.locator('#config-fields input[aria-label^="Применить поле requisites"]').count(),0,'requisites not in editable fields');
|
||||
await page.getByRole('button',{name:'Выбрать все поля формы',exact:true}).click();assert.doesNotMatch(await page.locator('#change-summary').innerText(),/requisites/);await page.getByRole('button',{name:'Снять выбор полей',exact:true}).click();assert.ok(await page.locator('#overview-table tr[data-overview-extra=requisites]').count());const after=await read();assert.equal(after.version,before.version);assert.deepEqual(after.config,before.config);console.log('PASS requisites overview-only / select all excludes / config unchanged');
|
||||
}finally{await browser.close();}
|
||||
})().catch(error=>{console.error(error);process.exit(1);});
|
||||
@@ -6,7 +6,7 @@ $adminJsVersion = substr(hash_file('sha256', __DIR__ . '/../public/assets/admin.
|
||||
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Прелендинги — UTM и наборы изображений</title><link rel="stylesheet" href="/assets/admin.css?v=<?= $adminCssVersion ?>"><script src="/assets/admin.js?v=<?= $adminJsVersion ?>" defer></script></head>
|
||||
<body><header class="navbar"><div class="container nav-inner"><strong>Прелендинги</strong><span>UTM и наборы изображений</span><button id="logout" class="btn btn-default" hidden>Выйти</button></div></header>
|
||||
<main class="container"><div id="notice" role="status" aria-live="polite" hidden></div>
|
||||
<section id="login-panel" class="panel login-panel"><h1>Вход в админку</h1><form id="login-form"><label>Логин<input class="form-control" name="username" autocomplete="username" required></label><label>Пароль<input class="form-control" name="password" type="password" autocomplete="current-password" required></label><button class="btn btn-primary" type="submit">Войти</button></form></section>
|
||||
<section id="login-panel" class="panel login-panel"><h1>Вход в админку</h1><form id="login-form"><label>Логин<input class="form-control" name="username" autocomplete="username" required></label><label>Пароль<input class="form-control" name="password" type="password" autocomplete="current-password" required></label><label class="checkbox remember-choice"><input name="remember" type="checkbox"> Запомнить меня на 90 дней</label><button class="btn btn-primary" type="submit">Войти</button></form></section>
|
||||
<div id="workspace" hidden><h1>Настройка прелендингов</h1><nav class="tabs" aria-label="Разделы"><a href="#overview">Сводка</a><a href="#link-builder">Конструктор ссылки</a><a href="#settings">Настройки</a><a href="#indpic">indpic</a><a href="#connections">Подключения</a><a href="#history">Журнал и резерв</a></nav>
|
||||
<section id="overview" class="panel"><h2>Сводка текущих конфигураций</h2><p class="text-muted">Сохранённые runtime-конфиги каждого домена на момент чтения. Несохранённые изменения редактора сюда не попадают.</p><button id="overview-refresh" type="button" class="btn btn-default">Обновить сводку</button><div class="table-responsive overview-wrapper"><table id="overview-table" class="table table-bordered overview-table"><caption class="text-muted">Default и все варианты с весами. Таблицу можно прокручивать по горизонтали.</caption><thead></thead><tbody></tbody></table></div></section>
|
||||
<section id="link-builder" class="panel"><h2>Конструктор ссылки</h2><p class="text-muted">Выберите значения UTM или «Рандом». Последний сформированный выбор хранится отдельно для каждого домена. Конфигурация, веса и default не меняются.</p><label class="builder-domain">Домен ссылки<select id="builder-target" class="form-control"><option value="">Выберите домен</option></select></label><p id="builder-state" class="text-muted" role="status" aria-live="polite">Выберите домен.</p><div id="builder-fields" class="form-grid"></div><div id="builder-warnings" aria-live="polite"></div><button id="builder-save" type="button" class="btn btn-primary" disabled>Сформировать и сохранить</button><div id="builder-result" hidden><label>Готовая ссылка<input id="builder-url" class="form-control" type="url" readonly spellcheck="false"></label><div class="toolbar"><button id="builder-copy" type="button" class="btn btn-default">Скопировать</button><a id="builder-open" class="btn btn-default" target="_blank" rel="noopener noreferrer">Открыть ссылку</a></div><p id="builder-saved-at" class="text-muted"></p></div></section>
|
||||
|
||||
Reference in new issue
Block a user