Compare commits
No commits in common. "codex/windows-panel-installer-20261024" and "main" have entirely different histories.
codex/wind
...
main
11 changed files with 0 additions and 494 deletions
13
.github/workflows/bash-syntax.yml
vendored
13
.github/workflows/bash-syntax.yml
vendored
|
|
@ -65,16 +65,3 @@ 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
|
||||
|
|
|
|||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -17,10 +17,3 @@ 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
|
||||
|
|
|
|||
|
|
@ -6,10 +6,6 @@ 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.
|
||||
|
|
|
|||
|
|
@ -6,10 +6,6 @@
|
|||
|
||||
Набор независимых Bash-скриптов для первоначальной настройки Ubuntu Server. Можно запустить только нужные модули или выполнить их по порядку для полной настройки.
|
||||
|
||||
## Ярлыки панелей на Windows
|
||||
|
||||
[Общий установщик PuTTY](../../windows/panels/README.txt) позволяет выбрать серверы, создать приватные туннели к 3x-ui и ярлыки браузера. Новые серверы добавляются в интерфейсе без пересборки. Каталог можно экспортировать и импортировать на другом ПК; рабочие каталоги и ключи в репозиторий не помещаются.
|
||||
|
||||
## Быстрая установка
|
||||
|
||||
Получите полный SHA одобренного коммита и SHA-256 архива из доверенного канала релиза или ревью. Контрольная сумма зависит от источника: архивы Forgejo и GitHub могут различаться. Не используйте имя ветки и не вычисляйте «ожидаемую» сумму из самого скачанного архива. Пока проверенный релиз не опубликовал эти значения, оператор должен указать их сам.
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
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 } }
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
# 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
@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
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
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()
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
Панели 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.
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
$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.')
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
{
|
||||
"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/"
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Reference in a new issue