Add selectable Windows PuTTY panel installer with editable server catalog #28

Open
ochenstarik wants to merge 2 commits from codex/windows-panel-installer-20261024 into main
11 changed files with 494 additions and 0 deletions

View file

@ -65,3 +65,16 @@ jobs:
- name: Test step 2 UFW boot readiness - name: Test step 2 UFW boot readiness
run: bash tests/test-step2-ufw-boot.sh 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

7
.gitignore vendored
View file

@ -17,3 +17,10 @@ id_ed25519*
.vscode/ .vscode/
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Private Windows panel catalogs and generated packages
/windows/panels/connections.json
/windows/panels/catalog.json*
/windows/panels/*.zip
/windows/panels/backup-*/
*.ppk

View file

@ -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. > **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 ## 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. 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.

View file

@ -6,6 +6,10 @@
Набор независимых Bash-скриптов для первоначальной настройки Ubuntu Server. Можно запустить только нужные модули или выполнить их по порядку для полной настройки. Набор независимых Bash-скриптов для первоначальной настройки Ubuntu Server. Можно запустить только нужные модули или выполнить их по порядку для полной настройки.
## Ярлыки панелей на Windows
[Общий установщик PuTTY](../../windows/panels/README.txt) позволяет выбрать серверы, создать приватные туннели к 3x-ui и ярлыки браузера. Новые серверы добавляются в интерфейсе без пересборки. Каталог можно экспортировать и импортировать на другом ПК; рабочие каталоги и ключи в репозиторий не помещаются.
## Быстрая установка ## Быстрая установка
Получите полный SHA одобренного коммита и SHA-256 архива из доверенного канала релиза или ревью. Контрольная сумма зависит от источника: архивы Forgejo и GitHub могут различаться. Не используйте имя ветки и не вычисляйте «ожидаемую» сумму из самого скачанного архива. Пока проверенный релиз не опубликовал эти значения, оператор должен указать их сам. Получите полный SHA одобренного коммита и SHA-256 архива из доверенного канала релиза или ревью. Контрольная сумма зависит от источника: архивы Forgejo и GitHub могут различаться. Не используйте имя ветки и не вычисляйте «ожидаемую» сумму из самого скачанного архива. Пока проверенный релиз не опубликовал эти значения, оператор должен указать их сам.

View file

@ -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 } }

View file

@ -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; 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)
}
}

View file

@ -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

169
windows/panels/Install.ps1 Normal file
View file

@ -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()

71
windows/panels/README.txt Normal file
View file

@ -0,0 +1,71 @@
Панели 3x-ui: общий установщик сессий PuTTY для Windows
БЫСТРОЕ НАЧАЛО
1. Распакуйте ZIP целиком в отдельную папку. Не запускайте файл внутри ZIP.
2. Запустите Install.cmd обычным пользователем. Права администратора не нужны.
3. Отметьте нужные серверы. Нажмите «Установить выбранные».
4. Выберите установленный putty.exe, если он не найден, и свой существующий .ppk.
5. На рабочем столе появятся «3x-ui <ID>» и «Панель <ID>».
Первый ярлык поднимает 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. При смене ключа проверьте его в консоли сервера. Туннель использует удалённый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.

View file

@ -0,0 +1,83 @@
$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.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.'
$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.')

View file

@ -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/"
}
]
}