From 65a99b19393caabb2b9493042ce1d4e4003b7c1d Mon Sep 17 00:00:00 2001 From: ochenstarik-ui <267932263+ochenstarik-ui@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:08:51 +0700 Subject: [PATCH 1/2] feat: add selectable Windows PuTTY panel installer and editable catalog --- .github/workflows/bash-syntax.yml | 13 ++ .gitignore | 7 + README.md | 4 + docs/readme/README.ru.md | 4 + windows/panels/Build-Package.ps1 | 32 +++++ windows/panels/Catalog.ps1 | 89 +++++++++++++ windows/panels/Install.cmd | 5 + windows/panels/Install.ps1 | 169 ++++++++++++++++++++++++ windows/panels/README.txt | 71 ++++++++++ windows/panels/Test-Catalog.ps1 | 82 ++++++++++++ windows/panels/connections.example.json | 17 +++ 11 files changed, 493 insertions(+) create mode 100644 windows/panels/Build-Package.ps1 create mode 100644 windows/panels/Catalog.ps1 create mode 100644 windows/panels/Install.cmd create mode 100644 windows/panels/Install.ps1 create mode 100644 windows/panels/README.txt create mode 100644 windows/panels/Test-Catalog.ps1 create mode 100644 windows/panels/connections.example.json diff --git a/.github/workflows/bash-syntax.yml b/.github/workflows/bash-syntax.yml index a3d8776..1ef765b 100644 --- a/.github/workflows/bash-syntax.yml +++ b/.github/workflows/bash-syntax.yml @@ -65,3 +65,16 @@ jobs: - name: Test step 2 UFW boot readiness run: bash tests/test-step2-ufw-boot.sh + + panel-catalog: + runs-on: ubuntu-latest + container: + # Official .NET8 LTS SDK includes PowerShell; pinned MCR manifest, not a mutable tag. + image: mcr.microsoft.com/dotnet/sdk@sha256:ada801965b46b26af97d6d366300ec06ca793c581259664c226253d9da55d254 + env: + POWERSHELL_TELEMETRY_OPTOUT: '1' + steps: + - name: Checkout + uses: https://code.forgejo.org/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - name: Test generic panel catalog and installer syntax + run: pwsh -NoLogo -NoProfile -File windows/panels/Test-Catalog.ps1 diff --git a/.gitignore b/.gitignore index 5bd4e1a..73ef016 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,10 @@ id_ed25519* .vscode/ .DS_Store Thumbs.db + +# Private Windows panel catalogs and generated packages +/windows/panels/connections.json +/windows/panels/catalog.json* +/windows/panels/*.zip +/windows/panels/backup-*/ +*.ppk diff --git a/README.md b/README.md index c724603..12c0faf 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,10 @@ A modular set of Bash installers for the initial setup, security hardening, admi > **Manage multiple servers:** use the companion [Server Monitor Manager](https://github.com/ochenstarik-ui/server-monitor-manager) project to add servers, collect reports, and manage several hosts from one application. It is currently in early development and should not yet be treated as production-ready. +## Windows panel shortcuts + +The [Windows PuTTY installer](windows/panels/README.txt) lets you choose servers, create private 3x-ui panel tunnels and browser shortcuts, and add or edit servers without rebuilding the installer. Export/import the local catalog to set up another PC. Private catalogs and keys do not belong in this repository. + ## Quick installation Obtain an approved full commit SHA and the archive SHA-256 through a trusted release/review channel. The digest is specific to the selected source: Forgejo and GitHub archives can differ. Do not use a branch name or obtain the expected digest from the downloaded archive itself. Until a reviewed release publishes these values, this procedure requires the operator to supply them. diff --git a/docs/readme/README.ru.md b/docs/readme/README.ru.md index 7354072..614c3c5 100644 --- a/docs/readme/README.ru.md +++ b/docs/readme/README.ru.md @@ -6,6 +6,10 @@ Набор независимых Bash-скриптов для первоначальной настройки Ubuntu Server. Можно запустить только нужные модули или выполнить их по порядку для полной настройки. +## Ярлыки панелей на Windows + +[Общий установщик PuTTY](../../windows/panels/README.txt) позволяет выбрать серверы, создать приватные туннели к 3x-ui и ярлыки браузера. Новые серверы добавляются в интерфейсе без пересборки. Каталог можно экспортировать и импортировать на другом ПК; рабочие каталоги и ключи в репозиторий не помещаются. + ## Быстрая установка Получите полный SHA одобренного коммита и SHA-256 архива из доверенного канала релиза или ревью. Контрольная сумма зависит от источника: архивы Forgejo и GitHub могут различаться. Не используйте имя ветки и не вычисляйте «ожидаемую» сумму из самого скачанного архива. Пока проверенный релиз не опубликовал эти значения, оператор должен указать их сам. diff --git a/windows/panels/Build-Package.ps1 b/windows/panels/Build-Package.ps1 new file mode 100644 index 0000000..1a28ce8 --- /dev/null +++ b/windows/panels/Build-Package.ps1 @@ -0,0 +1,32 @@ +param([Parameter(Mandatory=$true)][string]$OutputPath, [string]$CatalogPath) +$ErrorActionPreference='Stop' +. (Join-Path $PSScriptRoot 'Catalog.ps1') +$output=[IO.Path]::GetFullPath($OutputPath) +if ([IO.File]::Exists($output)) { throw 'Output already exists. Choose a new ZIP name.' } +$catalog=$null +if ($CatalogPath) { $catalog=Read-PanelCatalog ([IO.Path]::GetFullPath($CatalogPath)) } +$staging=Join-Path ([IO.Path]::GetTempPath()) ('panel-kit-'+[Guid]::NewGuid().ToString('N')) +[IO.Directory]::CreateDirectory($staging) | Out-Null +try { + if ($env:OS -eq 'Windows_NT') { + $acl=New-Object Security.AccessControl.DirectorySecurity + $sid=[Security.Principal.WindowsIdentity]::GetCurrent().User; $acl.SetOwner($sid); $acl.SetAccessRuleProtection($true,$false) + foreach ($principal in @($sid,[Security.Principal.SecurityIdentifier]::new('S-1-5-18'))) { + $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($principal,'FullControl','ContainerInherit,ObjectInherit','None','Allow')) + } + Set-Acl -LiteralPath $staging -AclObject $acl + } else { & chmod 700 -- $staging; if ($LASTEXITCODE -ne 0) { throw 'Cannot protect staging directory.' } } + $files=@('Install.cmd','Install.ps1','Catalog.ps1','Build-Package.ps1','Test-Catalog.ps1','README.txt','connections.example.json') + foreach ($name in $files) { Copy-Item -LiteralPath (Join-Path $PSScriptRoot $name) -Destination $staging } + # CMD uses explicit UTF-8 file reads for PowerShell. Ship conventional CRLF batch text. + $cmd=Join-Path $staging 'Install.cmd' + [IO.File]::WriteAllText($cmd, ([IO.File]::ReadAllText($cmd) -replace '\r?\n',"`r`n"),[Text.Encoding]::ASCII) + if ($catalog) { Write-PanelCatalog (Join-Path $staging 'connections.json') $catalog } + $hashes=@() + foreach ($file in Get-ChildItem -LiteralPath $staging -File | Sort-Object Name) { $hashes += ((Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant()+' '+$file.Name) } + [IO.File]::WriteAllText((Join-Path $staging 'SHA256SUMS.txt'),($hashes -join "`r`n")+"`r`n",[Text.Encoding]::ASCII) + Add-Type -AssemblyName System.IO.Compression.FileSystem + [IO.Compression.ZipFile]::CreateFromDirectory($staging,$output) + if ($env:OS -ne 'Windows_NT') { & chmod 600 -- $output; if ($LASTEXITCODE -ne 0) { throw 'Cannot protect output ZIP.' } } + Write-Output ('Created '+$output) +} finally { if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force } } diff --git a/windows/panels/Catalog.ps1 b/windows/panels/Catalog.ps1 new file mode 100644 index 0000000..32042ca --- /dev/null +++ b/windows/panels/Catalog.ps1 @@ -0,0 +1,89 @@ +# Pure catalog/registry-plan functions. Compatible with Windows PowerShell 5.1. +Set-StrictMode -Version 2 +function Get-PanelField($Record, [string]$Name, $Default = '') { + $property = $Record.PSObject.Properties[$Name] + if ($null -eq $property -or $null -eq $property.Value) { return $Default } + return $property.Value +} +function ConvertTo-PanelSession($Record) { + $role = [string](Get-PanelField $Record 'Role') + if ($role -cnotmatch '^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$') { throw 'ID: use 1-48 Latin letters, digits, hyphen or underscore.' } + $name = 'KAgent-' + $role + '-panel' + $supplied = [string](Get-PanelField $Record 'Name' $name) + if ($supplied -cne $name) { throw 'Session name must match its ID.' } + $label = [string](Get-PanelField $Record 'Label' $role) + if ([string]::IsNullOrWhiteSpace($label) -or $label.Length -gt 120 -or $label -match '[\x00-\x1f]') { throw 'Invalid display label.' } + $serverAddress = [string](Get-PanelField $Record 'HostName') + $address = $null + if ([Net.IPAddress]::TryParse($serverAddress, [ref]$address)) { + $serverAddress = $address.ToString() + } elseif ($serverAddress.Length -gt 253 -or $serverAddress -cnotmatch '^(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$') { + throw 'Enter a server IP or DNS name, without a URL, path or port.' + } + $userName = [string](Get-PanelField $Record 'UserName') + if ($userName -cnotmatch '^[a-z_][a-z0-9_-]{0,31}$') { throw 'Invalid SSH user name.' } + $ports = @{} + foreach ($field in @('PortNumber', 'LocalPort', 'RemotePort')) { + $value = [string](Get-PanelField $Record $field) + if ($value -cnotmatch '^\d{1,5}$' -or [int]$value -lt 1 -or [int]$value -gt 65535) { throw ('Invalid port: ' + $field) } + $ports[$field] = [int]$value + } + $fingerprint = [string](Get-PanelField $Record 'Fingerprint') + if ($fingerprint -cnotmatch '^SHA256:[A-Za-z0-9+/]{43}$') { throw 'Enter the verified SHA256 SSH host-key fingerprint.' } + $panelPath = [string](Get-PanelField $Record 'PanelPath' '/') + if ($panelPath -cnotmatch '^/[A-Za-z0-9._~/-]*$' -or $panelPath.Contains('//') -or @($panelPath.Split('/') | Where-Object { $_ -eq '.' -or $_ -eq '..' }).Count -gt 0) { + throw 'Panel path must start with / and contain safe path characters only.' + } + return [pscustomobject][ordered]@{ + Role=$role; Name=$name; Label=$label; HostName=$serverAddress; UserName=$userName + PortNumber=$ports.PortNumber; LocalPort=$ports.LocalPort; RemotePort=$ports.RemotePort + Fingerprint=$fingerprint; PanelPath=$panelPath + } +} +function ConvertTo-PanelCatalog($Value) { + $version = Get-PanelField $Value 'SchemaVersion' 1 + if ([string]$version -cne '1') { throw 'Unsupported catalog schema version.' } + if ($null -eq $Value.PSObject.Properties['Sessions']) { throw 'Catalog must contain Sessions.' } + $sessions = @(); $names = @{}; $ports = @{} + foreach ($record in @($Value.Sessions)) { + $entry = ConvertTo-PanelSession $record + if ($names.ContainsKey($entry.Name)) { throw ('Duplicate ID: ' + $entry.Role) } + if ($ports.ContainsKey([string]$entry.LocalPort)) { throw ('Duplicate local port: ' + $entry.LocalPort) } + $names[$entry.Name] = $true; $ports[[string]$entry.LocalPort] = $true; $sessions += $entry + } + return [pscustomobject]@{SchemaVersion=1; Sessions=@($sessions)} +} +function Read-PanelCatalog([string]$Path) { + return ConvertTo-PanelCatalog ([IO.File]::ReadAllText($Path) | ConvertFrom-Json) +} +function Write-PanelCatalog([string]$Path, $Value) { + $catalog = ConvertTo-PanelCatalog $Value + $temporary = $Path + '.' + [Guid]::NewGuid().ToString('N') + '.tmp' + try { + [IO.File]::WriteAllText($temporary, ($catalog | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) + if ([IO.File]::Exists($Path)) { [IO.File]::Replace($temporary, $Path, ($Path + ".previous")) } + else { [IO.File]::Move($temporary, $Path) } + } finally { if ([IO.File]::Exists($temporary)) { [IO.File]::Delete($temporary) } } +} +function Select-PanelSessions($Catalog, [string[]]$Names) { + $catalogData = ConvertTo-PanelCatalog $Catalog + if (@($Names).Count -eq 0) { throw 'Select at least one server.' } + $result = @(); $seen = @{} + foreach ($name in $Names) { + if ($seen.ContainsKey($name)) { throw 'Duplicate selection.' } + $matches = @($catalogData.Sessions | Where-Object { $_.Name -ceq $name }) + if ($matches.Count -ne 1) { throw 'Selected server does not exist in the catalog.' } + $seen[$name] = $true; $result += $matches[0] + } + return $result +} +function Get-PanelRegistryPlan($Record, [string]$KeyPath) { + $entry = ConvertTo-PanelSession $Record + if ([string]::IsNullOrWhiteSpace($KeyPath) -or $KeyPath -match '[\x00-\x1f]') { throw 'Select your existing private key file.' } + return [pscustomobject]@{ + Strings=@{HostName=$entry.HostName; Protocol='ssh'; UserName=$entry.UserName; PublicKeyFile=$KeyPath; PortForwardings=('L127.0.0.1:' + $entry.LocalPort + '=127.0.0.1:' + $entry.RemotePort)} + Numbers=@{PortNumber=$entry.PortNumber; LocalPortAcceptAll=0; RemotePortAcceptAll=0; SshNoShell=1; PingInterval=0; PingIntervalSecs=30; TCPKeepalives=1; AgentFwd=0; GssapiFwd=0; ProxyMethod=0; LogType=0} + Arguments=('-load ' + $entry.Name + ' -N -hostkey ' + $entry.Fingerprint) + PanelUrl=('http://127.0.0.1:' + $entry.LocalPort + $entry.PanelPath) + } +} diff --git a/windows/panels/Install.cmd b/windows/panels/Install.cmd new file mode 100644 index 0000000..f3a9610 --- /dev/null +++ b/windows/panels/Install.cmd @@ -0,0 +1,5 @@ +@echo off +setlocal DisableDelayedExpansion +set "LIGHTWEIGHT_PANEL_SOURCE=%~dp0" +powershell.exe -NoLogo -NoProfile -STA -Command "try { $ErrorActionPreference='Stop'; $root=$env:LIGHTWEIGHT_PANEL_SOURCE; $text=[IO.File]::ReadAllText((Join-Path $root 'Install.ps1')); & ([scriptblock]::Create($text)) -SourceDir $root } catch { Write-Error $_; exit 1 }" +if errorlevel 1 pause diff --git a/windows/panels/Install.ps1 b/windows/panels/Install.ps1 new file mode 100644 index 0000000..0a7e8b3 --- /dev/null +++ b/windows/panels/Install.ps1 @@ -0,0 +1,169 @@ +param([string]$SourceDir = $PSScriptRoot) +$ErrorActionPreference = 'Stop' +. ([scriptblock]::Create([IO.File]::ReadAllText((Join-Path $SourceDir 'Catalog.ps1')))) +if ($env:OS -ne 'Windows_NT') { throw 'Run Install.cmd on Windows.' } +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing +[Windows.Forms.Application]::EnableVisualStyles() +function Show-PanelError($Exception) { + [Windows.Forms.MessageBox]::Show([string]$Exception, '3x-ui panels', 'OK', 'Error') | Out-Null +} +function Protect-PanelDirectory([string]$Path) { + New-Item -ItemType Directory -Path $Path -Force | Out-Null + $acl = New-Object Security.AccessControl.DirectorySecurity + $sid = [Security.Principal.WindowsIdentity]::GetCurrent().User + $acl.SetOwner($sid); $acl.SetAccessRuleProtection($true, $false) + $inherit = [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit' + foreach ($principal in @($sid, [Security.Principal.SecurityIdentifier]::new('S-1-5-18'))) { + $rule = [Security.AccessControl.FileSystemAccessRule]::new($principal, [Security.AccessControl.FileSystemRights]::FullControl, $inherit, [Security.AccessControl.PropagationFlags]::None, [Security.AccessControl.AccessControlType]::Allow) + $acl.AddAccessRule($rule) + } + Set-Acl -LiteralPath $Path -AclObject $acl +} +function Select-PanelFile([string]$Title, [string]$Filter) { + $dialog = New-Object Windows.Forms.OpenFileDialog + $dialog.Title=$Title; $dialog.Filter=$Filter; $dialog.CheckFileExists=$true + if ($dialog.ShowDialog() -ne 'OK') { return $null } + return $dialog.FileName +} +function Edit-PanelSession($Existing) { + $dialog = New-Object Windows.Forms.Form + $dialog.Text='Сервер 3x-ui'; $dialog.ClientSize=New-Object Drawing.Size(640,485) + $dialog.StartPosition='CenterParent'; $dialog.FormBorderStyle='FixedDialog'; $dialog.MaximizeBox=$false; $dialog.MinimizeBox=$false + $fields=@( + @('Role','ID ярлыка (например DK или DE2)',''), @('Label','Название в списке',''), + @('HostName','IP или DNS сервера',''), @('UserName','Пользователь SSH',''), + @('PortNumber','Порт SSH','22'), @('LocalPort','Свободный локальный порт','18055'), + @('RemotePort','Порт панели на сервере','2053'), @('PanelPath','Путь панели (например /secret/)','/'), + @('Fingerprint','SHA256 отпечаток ключа SSH','') + ) + $boxes=@{}; $y=16 + foreach ($field in $fields) { + $label=New-Object Windows.Forms.Label; $label.Text=$field[1]; $label.SetBounds(12,$y+4,265,22); $dialog.Controls.Add($label) + $box=New-Object Windows.Forms.TextBox; $box.SetBounds(280,$y,345,24) + $box.Text=[string](Get-PanelField $Existing $field[0] $field[2]); $boxes[$field[0]]=$box; $dialog.Controls.Add($box); $y+=40 + } + $tip=New-Object Windows.Forms.Label; $tip.Text='Отпечаток проверьте в консоли сервера. Пароли и приватный ключ сюда не вводятся.'; $tip.SetBounds(12,380,610,38); $dialog.Controls.Add($tip) + $ok=New-Object Windows.Forms.Button; $ok.Text='Сохранить'; $ok.SetBounds(405,435,105,30) + $ok.Add_Click({ + try { + $value=[ordered]@{}; foreach ($field in $fields) { $value[$field[0]]=$boxes[$field[0]].Text.Trim() } + $dialog.Tag=ConvertTo-PanelSession ([pscustomobject]$value); $dialog.DialogResult='OK'; $dialog.Close() + } catch { Show-PanelError $_.Exception.Message } + }); $dialog.Controls.Add($ok) + $cancel=New-Object Windows.Forms.Button; $cancel.Text='Отмена'; $cancel.SetBounds(520,435,105,30); $cancel.DialogResult='Cancel'; $dialog.Controls.Add($cancel) + $dialog.AcceptButton=$ok; $dialog.CancelButton=$cancel + if ($dialog.ShowDialog() -eq 'OK') { return $dialog.Tag } + return $null +} +function Install-PanelSelections($Catalog, [string[]]$Names) { + $selected=@(Select-PanelSessions $Catalog $Names) + $putty=$null; $found=Get-Command putty.exe -ErrorAction SilentlyContinue + if ($found) { $putty=$found.Source } + foreach ($folder in @($env:ProgramFiles, ${env:ProgramFiles(x86)})) { + if ($folder -and -not $putty) { + $candidate=Join-Path $folder 'PuTTY\putty.exe' + if (Test-Path -LiteralPath $candidate -PathType Leaf) { $putty=$candidate } + } + } + if (-not $putty) { $putty=Select-PanelFile 'Выберите установленный putty.exe' 'PuTTY|putty.exe' } + if (-not $putty) { return } + $key=Select-PanelFile 'Выберите свой существующий приватный ключ PuTTY (.ppk)' 'PuTTY private key|*.ppk' + if (-not $key) { return } + $backup=Join-Path $script:DataDir ('backup-' + (Get-Date -Format 'yyyyMMdd-HHmmss') + '-' + [Guid]::NewGuid().ToString('N')) + Protect-PanelDirectory $backup + $desktop=[Environment]::GetFolderPath('Desktop'); $shell=New-Object -ComObject WScript.Shell + $completed=@() + try { + foreach ($entry in $selected) { + $plan=Get-PanelRegistryPlan $entry $key + $registry='HKCU:\Software\SimonTatham\PuTTY\Sessions\' + $entry.Name + $native='HKCU\Software\SimonTatham\PuTTY\Sessions\' + $entry.Name + if (Test-Path -LiteralPath $registry) { + & reg.exe export $native (Join-Path $backup ($entry.Role + '-previous.reg')) /y | Out-Null + if ($LASTEXITCODE -ne 0) { throw ('Не удалось сохранить прежнюю сессию ' + $entry.Role) } + } + $shortcutPath=Join-Path $desktop ('3x-ui ' + $entry.Role + '.lnk') + $bookmarkPath=Join-Path $desktop ('Панель ' + $entry.Role + '.url') + foreach ($path in @($shortcutPath,$bookmarkPath)) { if (Test-Path -LiteralPath $path) { Copy-Item -LiteralPath $path -Destination $backup } } + New-Item -Path $registry -Force | Out-Null + foreach ($name in $plan.Strings.Keys) { New-ItemProperty -Path $registry -Name $name -Value $plan.Strings[$name] -PropertyType String -Force | Out-Null } + foreach ($name in $plan.Numbers.Keys) { New-ItemProperty -Path $registry -Name $name -Value $plan.Numbers[$name] -PropertyType DWord -Force | Out-Null } + $shortcut=$shell.CreateShortcut($shortcutPath); $shortcut.TargetPath=$putty; $shortcut.Arguments=$plan.Arguments + $shortcut.WorkingDirectory=Split-Path -Parent $putty; $shortcut.IconLocation=$putty + ',0'; $shortcut.Description=$entry.Label + ' — SSH-туннель панели'; $shortcut.Save() + [IO.File]::WriteAllText($bookmarkPath, "[InternetShortcut]`r`nURL=" + $plan.PanelUrl + "`r`n", [Text.Encoding]::ASCII) + $completed += $entry.Role + } + } catch { + throw ('Установка остановлена: ' + $_.Exception.Message + "`r`nУстановлены: " + ($completed -join ', ') + "`r`nРезервные копии: " + $backup) + } + [Windows.Forms.MessageBox]::Show("Готово: " + ($completed -join ', ') + ".`r`nОткройте ярлык 3x-ui, оставьте PuTTY работающим и откройте ярлык Панель.`r`nПароль панели вводится в браузере.", '3x-ui panels') | Out-Null +} +$script:DataDir=Join-Path $env:LOCALAPPDATA 'KAgentPanels' +Protect-PanelDirectory $script:DataDir +$script:CatalogPath=Join-Path $script:DataDir 'catalog.json' +$sourceCatalog=Join-Path $SourceDir 'connections.json' +$script:Catalog=[pscustomobject]@{SchemaVersion=1; Sessions=@()} +try { + if (Test-Path -LiteralPath $script:CatalogPath) { $script:Catalog=Read-PanelCatalog $script:CatalogPath } + elseif (Test-Path -LiteralPath $sourceCatalog) { $script:Catalog=Read-PanelCatalog $sourceCatalog } +} catch { Show-PanelError ('Каталог не загружен: ' + $_.Exception.Message + '. Исправьте файл или импортируйте другой каталог. Исходный файл сохранён.') } +$form=New-Object Windows.Forms.Form; $form.Text='Панели 3x-ui — установка PuTTY'; $form.ClientSize=New-Object Drawing.Size(850,510) +$form.MinimumSize=$form.Size; $form.MaximumSize=$form.Size; $form.StartPosition='CenterScreen'; $form.MaximizeBox=$false +$title=New-Object Windows.Forms.Label; $title.Text='Отметьте серверы, для которых нужны сессии PuTTY и ярлыки:'; $title.SetBounds(16,16,800,28); $form.Controls.Add($title) +$list=New-Object Windows.Forms.CheckedListBox; $list.CheckOnClick=$true; $list.SetBounds(16,52,818,225); $form.Controls.Add($list) +function Refresh-PanelList { + $list.Items.Clear() + foreach ($entry in $script:Catalog.Sessions) { $null=$list.Items.Add(($entry.Role + ' — ' + $entry.Label + ' — ' + $entry.HostName + ':' + $entry.PortNumber),$false) } +} +function Save-PanelCatalog { Write-PanelCatalog $script:CatalogPath $script:Catalog } +function New-PanelButton([string]$Text,[int]$X,[int]$Y,[int]$Width,$Action) { + $button=New-Object Windows.Forms.Button; $button.Text=$Text; $button.SetBounds($X,$Y,$Width,32); $button.Add_Click($Action); $form.Controls.Add($button) +} +New-PanelButton 'Добавить сервер' 16 294 150 { + $entry=Edit-PanelSession ([pscustomobject]@{}) + if ($entry) { try { $candidate=ConvertTo-PanelCatalog ([pscustomobject]@{Sessions=@($script:Catalog.Sessions)+@($entry)}); Write-PanelCatalog $script:CatalogPath $candidate; $script:Catalog=$candidate; Refresh-PanelList } catch { Show-PanelError $_.Exception.Message } } +} +New-PanelButton 'Изменить' 176 294 120 { + $index=$list.SelectedIndex + if ($index -lt 0) { return } + $entry=Edit-PanelSession $script:Catalog.Sessions[$index] + if ($entry) { try { $values=@($script:Catalog.Sessions); $values[$index]=$entry; $candidate=ConvertTo-PanelCatalog ([pscustomobject]@{Sessions=$values}); Write-PanelCatalog $script:CatalogPath $candidate; $script:Catalog=$candidate; Refresh-PanelList } catch { Show-PanelError $_.Exception.Message } } +} +New-PanelButton 'Убрать из списка' 306 294 155 { + $index=$list.SelectedIndex; if ($index -lt 0) { return } + if ([Windows.Forms.MessageBox]::Show('Убрать сервер из каталога? Уже установленные ярлыки и PuTTY-сессия сохранятся.','3x-ui panels','YesNo') -ne 'Yes') { return } + try { $values=@(); for ($i=0;$i -lt $script:Catalog.Sessions.Count;$i++) { if ($i -ne $index) { $values += $script:Catalog.Sessions[$i] } }; $candidate=ConvertTo-PanelCatalog ([pscustomobject]@{Sessions=$values}); Write-PanelCatalog $script:CatalogPath $candidate; $script:Catalog=$candidate; Refresh-PanelList } catch { Show-PanelError $_.Exception.Message } +} +New-PanelButton 'Импорт каталога' 471 294 170 { + $file=Select-PanelFile 'Выберите каталог подключений' 'JSON catalog|*.json' + if (-not $file) { return } + try { + $imported=Read-PanelCatalog $file; $values=@($script:Catalog.Sessions) + foreach ($entry in $imported.Sessions) { + $found=@($values | Where-Object { $_.Name -ieq $entry.Name }) + if ($found.Count -gt 0) { + if ([Windows.Forms.MessageBox]::Show(('Заменить настройки ' + $entry.Role + '?'),'Импорт','YesNo') -ne 'Yes') { continue } + $values=@($values | Where-Object { $_.Name -ine $entry.Name }) + } + $values += $entry + } + $candidate=ConvertTo-PanelCatalog ([pscustomobject]@{Sessions=$values}); Write-PanelCatalog $script:CatalogPath $candidate; $script:Catalog=$candidate; Refresh-PanelList + } catch { Show-PanelError $_.Exception.Message } +} +New-PanelButton 'Экспорт каталога' 651 294 183 { + $dialog=New-Object Windows.Forms.SaveFileDialog; $dialog.Filter='JSON catalog|*.json'; $dialog.FileName='connections.json' + if ($dialog.ShowDialog() -eq 'OK') { try { Write-PanelCatalog $dialog.FileName $script:Catalog } catch { Show-PanelError $_.Exception.Message } } +} +$note=New-Object Windows.Forms.Label; $note.Text="Новый сервер добавляется без пересборки установщика. Каталог хранится в %LOCALAPPDATA%\KAgentPanels\catalog.json.`r`nВыберите существующий .ppk при установке. Приватный ключ и пароли установщик не копирует и не сохраняет."; $note.SetBounds(16,346,815,65); $form.Controls.Add($note) +New-PanelButton 'Установить выбранные' 16 440 245 { + try { + $names=@(); foreach ($index in $list.CheckedIndices) { $names += $script:Catalog.Sessions[$index].Name } + $null=Select-PanelSessions $script:Catalog $names + Save-PanelCatalog + Install-PanelSelections $script:Catalog $names + } catch { Show-PanelError $_.Exception.Message } +} +New-PanelButton 'Закрыть' 699 440 135 { $form.Close() } +Refresh-PanelList +$null=$form.ShowDialog() diff --git a/windows/panels/README.txt b/windows/panels/README.txt new file mode 100644 index 0000000..56a5e84 --- /dev/null +++ b/windows/panels/README.txt @@ -0,0 +1,71 @@ +Панели 3x-ui: общий установщик сессий PuTTY для Windows + +БЫСТРОЕ НАЧАЛО +1. Распакуйте ZIP целиком в отдельную папку. Не запускайте файл внутри ZIP. +2. Запустите Install.cmd обычным пользователем. Права администратора не нужны. +3. Отметьте нужные серверы. Нажмите «Установить выбранные». +4. Выберите установленный putty.exe, если он не найден, и свой существующий .ppk. +5. На рабочем столе появятся «3x-ui » и «Панель ». + Первый ярлык поднимает SSH-туннель; оставьте окно PuTTY работающим. + Затем второй ярлык открывает страницу панели в браузере. + Пароль приватного ключа при необходимости вводится в PuTTY, пароль панели — в браузере. +Установщик не устанавливает сам PuTTY или 3x-ui и не копирует приватный ключ. +ExecutionPolicy, системные службы, firewall и другие PuTTY-сессии не меняются. + +НОВЫЙ СЕРВЕР БЕЗ ПЕРЕСБОРКИ +После развёртывания 3x-ui: +1. Панель должна слушать 127.0.0.1. Не открывайте её порт наружу ради этого комплекта. +2. Проверьте вход на сервер своим ключом PuTTY. При новом сервере установите именно + публичную часть этого ключа в authorized_keys нужного SSH-пользователя. +3. В консоли сервера получите отпечаток публичного host key, например: + ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub + Нужна строка SHA256:... из доверенной консоли. Не отключайте проверку host key. +4. Откройте Install.cmd → «Добавить сервер». Укажите: + - постоянный ID, например DK, DE2, FI или новый сервер; название — произвольное; + - IP/DNS без https:// и без порта; SSH-пользователя и SSH-порт; + - свободный локальный порт и порт панели на сервере, например 2053; + - путь панели из настроек 3x-ui и проверенный SHA256 отпечаток. +5. Сохраните и установите только нужный сервер. +Для обновления адреса/порта/пути выберите запись → «Изменить» и переустановите её. +Сохраняйте ID прежним, чтобы обновлялся прежний ярлык и прежняя PuTTY-сессия. +Одновременно работающим панелям нужны разные локальные порты. + +КАТАЛОГ И ПЕРЕНОС НА ДРУГОЙ ПК +Рабочий каталог: %LOCALAPPDATA%\KAgentPanels\catalog.json. +Повторный запуск использует сохранённый каталог. «Импорт каталога» добавляет записи +из нового connections.json; замена существующей записи требует подтверждения. +«Экспорт каталога» сохраняет JSON для другого ПК. Импортируйте его там и выберите +ключ .ppk, который установлен на этом ПК и разрешён на соответствующих серверах. +Первый запуск может загрузить connections.json из папки установщика. +Старый каталог предыдущего комплекта RU/DE поддерживается; путь панели по умолчанию /. +Уточните его через «Изменить», если он отличается. +connections.example.json — только шаблон. Его адрес и отпечаток обязательно заменить. +Серверы не привязаны к фиксированному списку стран; код для их добавления не меняется. +«Убрать из списка» удаляет запись только из каталога, не удаляя установленные ярлыки. + +ЗАЩИТА И ВОССТАНОВЛЕНИЕ +Ключ .ppk остаётся на выбранном месте. Пароли и содержимое ключа не сохраняются. +Метаданные включают адреса и приватный путь панели: не публикуйте свой каталог/ZIP. +Каталог защищён ACL текущего пользователя и SYSTEM. Предыдущая версия — catalog.json.previous. +Перед заменой выбранной PuTTY-сессии и её ярлыков создаётся backup-* в той же папке. +Для восстановления сессии импортируйте соответствующий *-previous.reg; старый ярлык +скопируйте из той же backup-папки на рабочий стол. Остальные серверы не переустанавливаются. +Если установка остановится, окно покажет уже установленные записи и папку резервных копий. +Ярлык PuTTY передаёт -hostkey с точным отпечатком и открывает forwarding только на127.0.0.1. +При ручном запуске сохранённой сессии из самого PuTTY также сверяйте host key; +не нажимайте Accept при неожиданной смене ключа. Туннель использует удалённый127.0.0.1. +Если локальный порт занят, закройте прежнее окно туннеля этой панели либо выберите другой порт. +Система с корпоративным запретом PowerShell/WinForms может блокировать запуск: +общие ограничения безопасности установщик не отключает. + +ПРОВЕРКА И СБОРКА ДЛЯ ОПЕРАТОРА +Test-Catalog.ps1 проверяет каталог, выбор серверов, ограничения полей, привязку +к loopback, host-key pin и атомарное сохранение. Требуется PowerShell5.1+. +Проверка на Linux не заменяет запуск WinForms/реестра/ярлыков на настоящем Windows. +Сборка общего ZIP из исходников: + powershell -NoProfile -File Build-Package.ps1 -OutputPath C:\Private\panels.zip +Сборка ZIP со своим каталогом (не публиковать такой архив): + powershell -NoProfile -File Build-Package.ps1 -OutputPath C:\Private\my-panels.zip -CatalogPath C:\Private\connections.json +Сборщик копирует только разрешённые исходники и нормализованный каталог, +не собирает ключи, env-файлы и пароли из соседних директорий. Готовый ZIP не перезаписывается. +Контрольные суммы файлов — SHA256SUMS.txt. diff --git a/windows/panels/Test-Catalog.ps1 b/windows/panels/Test-Catalog.ps1 new file mode 100644 index 0000000..12b4934 --- /dev/null +++ b/windows/panels/Test-Catalog.ps1 @@ -0,0 +1,82 @@ +$ErrorActionPreference='Stop' +. (Join-Path $PSScriptRoot 'Catalog.ps1') +$script:Checks=0 +function Assert-Panel($Condition,[string]$Message) { if (-not $Condition) { throw $Message }; $script:Checks++ } +function Expect-Rejection($Action,[string]$Message) { + $rejected=$false + try { & $Action | Out-Null } catch { $rejected=$true } + Assert-Panel $rejected $Message +} +function New-Fixture([string]$Role,[int]$Port,[string]$Address='192.0.2.10') { + return [pscustomobject]@{Role=$Role; Label=('Server '+$Role); HostName=$Address; UserName='operator'; PortNumber=64646; LocalPort=$Port; RemotePort=2053; Fingerprint=('SHA256:'+('A'*43)); PanelPath='/private-panel/'} +} +$ru=New-Fixture 'RU' 18053 +$de=New-Fixture 'DE' 18054 'server.example.org' +$dk=New-Fixture 'DK' 18055 '2001:db8::10' +$catalog=ConvertTo-PanelCatalog ([pscustomobject]@{Sessions=@($ru,$de,$dk)}) +Assert-Panel ($catalog.Sessions.Count -eq 3) 'Three initial servers must load.' +$chosen=@(Select-PanelSessions $catalog @('KAgent-DK-panel')) +Assert-Panel ($chosen.Count -eq 1 -and $chosen[0].Role -ceq 'DK') 'Installing DK must select only DK.' +$chosen=@(Select-PanelSessions $catalog @('KAgent-DE-panel','KAgent-RU-panel')) +Assert-Panel (($chosen.Role -join ',') -ceq 'DE,RU') 'Multiple selected servers must be preserved in requested order.' +Expect-Rejection { Select-PanelSessions $catalog @() } 'Empty selection must not install anything.' +Expect-Rejection { Select-PanelSessions $catalog @('KAgent-FI-panel') } 'Unknown selection must be rejected.' +Expect-Rejection { Select-PanelSessions $catalog @('KAgent-DK-panel','KAgent-DK-panel') } 'Duplicate selection must be rejected.' +$fi=New-Fixture 'FI2' 18056 +$larger=ConvertTo-PanelCatalog ([pscustomobject]@{Sessions=@($catalog.Sessions)+@($fi)}) +Assert-Panel ($larger.Sessions.Count -eq 4 -and $larger.Sessions[3].Role -ceq 'FI2') 'A new country must work without changing installer code.' +$legacy=[pscustomobject]@{Sessions=@([pscustomobject]@{Role='DE';Name='KAgent-DE-panel';HostName='198.51.100.20';UserName='operator';PortNumber=22;LocalPort=18054;RemotePort=2053;Fingerprint=('SHA256:'+('A'*43))})} +$normalized=ConvertTo-PanelCatalog $legacy +Assert-Panel ($normalized.Sessions[0].PanelPath -ceq '/' -and $normalized.Sessions[0].Label -ceq 'DE') 'Legacy RU/DE catalog compatibility failed.' +$empty=ConvertTo-PanelCatalog ([pscustomobject]@{Sessions=@()}) +Assert-Panel ($empty.Sessions.Count -eq 0) 'Clean install must support an empty catalog.' +Expect-Rejection { ConvertTo-PanelCatalog ([pscustomobject]@{Sessions=@($ru,$ru)}) } 'Duplicate session must be rejected.' +$collision=New-Fixture 'OTHER' 18053 +Expect-Rejection { ConvertTo-PanelCatalog ([pscustomobject]@{Sessions=@($ru,$collision)}) } 'Loopback port collision must be rejected.' +$caseCollision=New-Fixture 'ru' 18056 +Expect-Rejection { ConvertTo-PanelCatalog ([pscustomobject]@{Sessions=@($ru,$caseCollision)}) } 'Case-insensitive Windows session collision must be rejected.' +Expect-Rejection { ConvertTo-PanelCatalog ([pscustomobject]@{SchemaVersion=2;Sessions=@($ru)}) } 'Unknown schema must be rejected.' +foreach ($pair in @( + @('Role','../../owned'), @('Role','DK; Start-Process cmd'), @('Name','Other-session'), + @('HostName','https://example.org/path'), @('HostName','server -proxycmd cmd'), @('HostName',''), + @('HostName',"server`r`nPort=1"), @('UserName','user;cmd'), @('PortNumber','0'), @('PortNumber','65536'), + @('LocalPort','12.5'), @('RemotePort','1;2'), @('Fingerprint',''), @('Fingerprint',('SHA256:'+('A'*43)+' -pw injected')), + @('PanelPath','//example.org/'), @('PanelPath','/../secret'), @('PanelPath',"/x`r`nInjected: yes"), + @('PanelPath','/panel?password=secret'), @('Label',"line`nnewline") +)) { + $copy=$ru | ConvertTo-Json | ConvertFrom-Json + $copy | Add-Member -NotePropertyName $pair[0] -NotePropertyValue $pair[1] -Force + Expect-Rejection { ConvertTo-PanelSession $copy } ('Unsafe input accepted: '+$pair[0]) +} +$key='C:\Keys & files\my owner.ppk' +$plan=Get-PanelRegistryPlan $catalog.Sessions[2] $key +Assert-Panel ($plan.Strings.PublicKeyFile -ceq $key) 'Key path with spaces must remain data.' +Assert-Panel ($plan.Strings.PortForwardings -ceq 'L127.0.0.1:18055=127.0.0.1:2053') 'Tunnel must bind loopback and target remote loopback.' +Assert-Panel ($plan.Numbers.LocalPortAcceptAll -eq 0 -and $plan.Numbers.AgentFwd -eq 0 -and $plan.Numbers.ProxyMethod -eq 0) 'Unsafe forwarding/proxy settings.' +Assert-Panel ($plan.Arguments -ceq ('-load KAgent-DK-panel -N -hostkey SHA256:'+('A'*43))) 'Shortcut must pin the exact host key.' +Assert-Panel ($plan.PanelUrl -ceq 'http://127.0.0.1:18055/private-panel/') 'Browser URL must stay loopback.' +Expect-Rejection { Get-PanelRegistryPlan $ru '' } 'Missing key path must be rejected.' +$temp=Join-Path ([IO.Path]::GetTempPath()) ('panel-catalog-test-'+[Guid]::NewGuid().ToString('N')) +[IO.Directory]::CreateDirectory($temp) | Out-Null +try { + $file=Join-Path $temp 'connections.json' + Write-PanelCatalog $file $larger + $read=Read-PanelCatalog $file + Assert-Panel (($read.Sessions.Role -join ',') -ceq 'RU,DE,DK,FI2') 'Export/import roundtrip lost a server.' + Write-PanelCatalog $file $catalog + Assert-Panel ((Read-PanelCatalog $file).Sessions.Count -eq 3) 'Atomic replacement failed.' + $before=[IO.File]::ReadAllText($file) + Expect-Rejection { Write-PanelCatalog $file ([pscustomobject]@{Sessions=@($collision,$ru)}) } 'Invalid replacement must fail.' + Assert-Panel ([IO.File]::ReadAllText($file) -ceq $before) 'Invalid replacement modified the existing catalog.' + Assert-Panel (@(Get-ChildItem -LiteralPath $temp -Filter '*.tmp').Count -eq 0) 'Temporary catalog leaked.' + $withSecret=$ru | ConvertTo-Json | ConvertFrom-Json + $withSecret | Add-Member -NotePropertyName Password -NotePropertyValue 'must-not-be-saved' + Write-PanelCatalog $file ([pscustomobject]@{Sessions=@($withSecret)}) + Assert-Panel (-not [IO.File]::ReadAllText($file).Contains('must-not-be-saved')) 'Unknown secret field must not be persisted.' +} finally { Remove-Item -LiteralPath $temp -Recurse -Force } +foreach ($file in Get-ChildItem -LiteralPath $PSScriptRoot -Filter '*.ps1') { + $tokens=$null; $parseErrors=$null + [System.Management.Automation.Language.Parser]::ParseFile($file.FullName,[ref]$tokens,[ref]$parseErrors) | Out-Null + Assert-Panel ($parseErrors.Count -eq 0) ('PowerShell syntax errors: '+$file.Name) +} +Write-Output ('PASS: '+$script:Checks+' catalog, selection, safe-forwarding, host-key and persistence checks.') diff --git a/windows/panels/connections.example.json b/windows/panels/connections.example.json new file mode 100644 index 0000000..3ff5ef4 --- /dev/null +++ b/windows/panels/connections.example.json @@ -0,0 +1,17 @@ +{ + "SchemaVersion": 1, + "Sessions": [ + { + "Role": "EXAMPLE", + "Name": "KAgent-EXAMPLE-panel", + "Label": "Example server — replace every value before import", + "HostName": "203.0.113.10", + "UserName": "operator", + "PortNumber": 64646, + "LocalPort": 18055, + "RemotePort": 2053, + "Fingerprint": "SHA256:REPLACE_WITH_VERIFIED_SERVER_HOST_KEY", + "PanelPath": "/replace-with-panel-path/" + } + ] +} -- 2.45.2 From d4e7e67905df9d9ca40eccf8473e7f84870ab404 Mon Sep 17 00:00:00 2001 From: ochenstarik-ui <267932263+ochenstarik-ui@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:14:12 +0700 Subject: [PATCH 2/2] fix: pin verified host key in saved PuTTY sessions --- windows/panels/Catalog.ps1 | 2 +- windows/panels/README.txt | 8 ++++---- windows/panels/Test-Catalog.ps1 | 1 + 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/windows/panels/Catalog.ps1 b/windows/panels/Catalog.ps1 index 32042ca..58d6c06 100644 --- a/windows/panels/Catalog.ps1 +++ b/windows/panels/Catalog.ps1 @@ -81,7 +81,7 @@ function Get-PanelRegistryPlan($Record, [string]$KeyPath) { $entry = ConvertTo-PanelSession $Record if ([string]::IsNullOrWhiteSpace($KeyPath) -or $KeyPath -match '[\x00-\x1f]') { throw 'Select your existing private key file.' } return [pscustomobject]@{ - Strings=@{HostName=$entry.HostName; Protocol='ssh'; UserName=$entry.UserName; PublicKeyFile=$KeyPath; PortForwardings=('L127.0.0.1:' + $entry.LocalPort + '=127.0.0.1:' + $entry.RemotePort)} + Strings=@{HostName=$entry.HostName; Protocol='ssh'; UserName=$entry.UserName; PublicKeyFile=$KeyPath; SSHManualHostKeys=$entry.Fingerprint; PortForwardings=('L127.0.0.1:' + $entry.LocalPort + '=127.0.0.1:' + $entry.RemotePort)} Numbers=@{PortNumber=$entry.PortNumber; LocalPortAcceptAll=0; RemotePortAcceptAll=0; SshNoShell=1; PingInterval=0; PingIntervalSecs=30; TCPKeepalives=1; AgentFwd=0; GssapiFwd=0; ProxyMethod=0; LogType=0} Arguments=('-load ' + $entry.Name + ' -N -hostkey ' + $entry.Fingerprint) PanelUrl=('http://127.0.0.1:' + $entry.LocalPort + $entry.PanelPath) diff --git a/windows/panels/README.txt b/windows/panels/README.txt index 56a5e84..080db85 100644 --- a/windows/panels/README.txt +++ b/windows/panels/README.txt @@ -1,4 +1,4 @@ -Панели 3x-ui: общий установщик сессий PuTTY для Windows +Панели 3x-ui: общий установщик сессий PuTTY для Windows БЫСТРОЕ НАЧАЛО 1. Распакуйте ZIP целиком в отдельную папку. Не запускайте файл внутри ZIP. @@ -51,9 +51,9 @@ connections.example.json — только шаблон. Его адрес и о Для восстановления сессии импортируйте соответствующий *-previous.reg; старый ярлык скопируйте из той же backup-папки на рабочий стол. Остальные серверы не переустанавливаются. Если установка остановится, окно покажет уже установленные записи и папку резервных копий. -Ярлык PuTTY передаёт -hostkey с точным отпечатком и открывает forwarding только на127.0.0.1. -При ручном запуске сохранённой сессии из самого PuTTY также сверяйте host key; -не нажимайте Accept при неожиданной смене ключа. Туннель использует удалённый127.0.0.1. +Проверенный отпечаток сохраняется в сессии PuTTY и передаётся ярлыком через -hostkey. +Предыдущий список разрешённых ручных ключей этой сессии заменяется точным отпечатком. +Forwarding слушает только 127.0.0.1. При смене ключа проверьте его в консоли сервера. Туннель использует удалённый127.0.0.1. Если локальный порт занят, закройте прежнее окно туннеля этой панели либо выберите другой порт. Система с корпоративным запретом PowerShell/WinForms может блокировать запуск: общие ограничения безопасности установщик не отключает. diff --git a/windows/panels/Test-Catalog.ps1 b/windows/panels/Test-Catalog.ps1 index 12b4934..702d817 100644 --- a/windows/panels/Test-Catalog.ps1 +++ b/windows/panels/Test-Catalog.ps1 @@ -53,6 +53,7 @@ $plan=Get-PanelRegistryPlan $catalog.Sessions[2] $key Assert-Panel ($plan.Strings.PublicKeyFile -ceq $key) 'Key path with spaces must remain data.' Assert-Panel ($plan.Strings.PortForwardings -ceq 'L127.0.0.1:18055=127.0.0.1:2053') 'Tunnel must bind loopback and target remote loopback.' Assert-Panel ($plan.Numbers.LocalPortAcceptAll -eq 0 -and $plan.Numbers.AgentFwd -eq 0 -and $plan.Numbers.ProxyMethod -eq 0) 'Unsafe forwarding/proxy settings.' +Assert-Panel ($plan.Strings.SSHManualHostKeys -ceq $catalog.Sessions[2].Fingerprint) 'Saved session must replace any previous manual host key allowlist.' Assert-Panel ($plan.Arguments -ceq ('-load KAgent-DK-panel -N -hostkey SHA256:'+('A'*43))) 'Shortcut must pin the exact host key.' Assert-Panel ($plan.PanelUrl -ceq 'http://127.0.0.1:18055/private-panel/') 'Browser URL must stay loopback.' Expect-Rejection { Get-PanelRegistryPlan $ru '' } 'Missing key path must be rejected.' -- 2.45.2