mint-frp.ps1
<#PSScriptInfo .VERSION 1.0.0 .GUID b34e6689-a005-4ff6-9347-ad7d5a79a22c .AUTHOR Doremy .COMPANYNAME .COPYRIGHT .TAGS .LICENSEURI .PROJECTURI .ICONURI .EXTERNALMODULEDEPENDENCIES .REQUIREDSCRIPTS .EXTERNALSCRIPTDEPENDENCIES .RELEASENOTES .PRIVATEDATA #> <# .DESCRIPTION Fast reverse proxy deployment #> Param() # --- Configuration --- $FRPServiceName = "Fast Reverse Proxy" $InstallationPath = Join-Path $env:ProgramFiles "mint" "proxy" $InstallationPathExe = Join-Path $InstallationPath "mint-frp.ps1" $FRPPanelPath = Join-Path $InstallationPath "frpp.exe" $ConfigPath = Join-Path $InstallationPath "config.yaml" $VLESSConfigPath = Join-Path $InstallationPath "server.json" $VLESSServiceName = "VLESS" $VLESSStdoutLog = Join-Path $LogDirectoryPath "vless_stdout.log" $VLESSStderrLog = Join-Path $LogDirectoryPath "vless_stderr.log" $LogDirectoryPath = Join-Path $InstallationPath "logs" $CertificatesPath = Join-Path $InstallationPath "certs" # Logs $FRPPStdoutLog = Join-Path $LogDirectoryPath "frpp_stdout.log" $FRPPStderrLog = Join-Path $LogDirectoryPath "frpp_stderr.log" #(Join-Path -Path $env:ProgramFiles -ChildPath "mint" "proxy") ###################################### # Installation utility functions # ###################################### <# .SYNOPSIS Checks if the current session has administrator rights. .DESCRIPTION Returns $true if the script is running under an elevated (Admin) session, otherwise $false. #> function Test-IsAdministrator { [OutputType([bool])] param () # Retrieve the current Windows identity and principal $currentIdentity = [System.Security.Principal.WindowsIdentity]::GetCurrent() $principal = New-Object System.Security.Principal.WindowsPrincipal($currentIdentity) # Check for the Administrator role return $principal.IsInRole([System.Security.Principal.WindowsBuiltinRole]::Administrator) } function Copy-Self { [CmdletBinding()] param( [Parameter(Mandatory = $true, Position = 0, HelpMessage = "Specify the destination file or folder path")] [string]$DestinationPath ) $sourcePath = $PSCommandPath if (-not $sourcePath) { $sourcePath = $MyInvocation.MyCommand.Path } if (-not $sourcePath) { Throw "Unable to determine script path. This function must be called from a script file." } if (Test-Path -Path $DestinationPath -PathType Container) { $fileName = [IO.Path]::GetFileName($sourcePath) $DestinationPath = Join-Path -Path $DestinationPath -ChildPath $fileName } else { $destDir = Split-Path -Path $DestinationPath -Parent if ($destDir -and -not (Test-Path $destDir)) { New-Item -Path $destDir -ItemType Directory -Force | Out-Null } } try { Copy-Item -Path $sourcePath ` -Destination $DestinationPath ` -Force ` -ErrorAction Stop Write-Verbose "Copied: '$sourcePath' → '$DestinationPath'" } catch { Throw "Copy error: $($_.Exception.Message)" } } ###################################### # Winget related functions # ###################################### function Install-Winget { [CmdletBinding(SupportsShouldProcess = $true)] param() if ($PSCmdlet.ShouldProcess("Installing winget from PSGallery", "Install-Winget")) { Set-PSRepository -Name PSGallery -InstallationPolicy Trusted -ErrorAction Stop Set-ExecutionPolicy -Scope Process -ExecutionPolicy Unrestricted -Force Install-Script -Name winget-install -Force -ErrorAction Stop Write-Verbose 'Winget installer script deployed.' } } function Get-WingetVersion { [CmdletBinding()] param() try { # Получаем версию winget $version = winget --version 2>$null if ($LASTEXITCODE -eq 0 -and $version) { return $version.Trim() } else { return $null } } catch { return $null } } ###################################### # Github release download functions # ###################################### <# .SYNOPSIS Получает URL скачивания артефакта из последнего релиза GitHub. .PARAMETER Owner Владелец репозитория (например: SagerNet). .PARAMETER Repo Название репозитория (например: sing-box). .PARAMETER AssetNamePattern Шаблон имени артефакта (Wildcard, например: '*windows-amd64.zip'). .OUTPUTS Строка с URL для скачивания. #> function Get-LatestGitHubReleaseAssetUrl { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Owner, [Parameter(Mandatory)] [string]$Repo, [Parameter(Mandatory)] [string]$AssetNamePattern ) # Формируем URL API для последнего релиза $apiUrl = "https://api.github.com/repos/$Owner/$Repo/releases/latest" try { $release = Invoke-RestMethod -Uri $apiUrl -UseBasicParsing -ErrorAction Stop } catch { throw "Не удалось получить данные релиза: $_" } # Ищем первый артефакт по шаблону $asset = $release.assets | Where-Object { $_.name -like $AssetNamePattern } | Select-Object -First 1 if (-not $asset) { throw "Артефакт с именем по шаблону '$AssetNamePattern' в последнем релизе не найден." } return $asset.browser_download_url } <# .SYNOPSIS Получает URL скачивания артефакта из последнего релиза GitHub. .PARAMETER Owner Владелец репозитория (например: SagerNet). .PARAMETER Repo Название репозитория (например: sing-box). .PARAMETER AssetNamePattern Шаблон имени артефакта (Wildcard, например: '*windows-amd64.zip'). .OUTPUTS Строка с URL для скачивания. #> <# .SYNOPSIS Скачивает последний релиз frp-panel, переименовывает исполняемый файл в frpp.exe. .PARAMETER DestinationPath Папка, в которую будет сохранён frpp.exe. #> function Install-FrpPanel { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$DestinationPath ) $owner = 'VaalaCat' $repo = 'frp-panel' $pattern = '*.exe' Write-Verbose "Получаем URL последнего exe..." $downloadUrl = Get-LatestGitHubReleaseAssetUrl -Owner $owner -Repo $repo -AssetNamePattern $pattern # Создаём папку назначения if (-not (Test-Path $DestinationPath)) { Write-Verbose "Создаём папку $DestinationPath..." New-Item -ItemType Directory -Path $DestinationPath -Force | Out-Null } $targetExe = Join-Path $DestinationPath 'frpp.exe' Write-Verbose "Скачиваем и сохраняем как frpp.exe..." Invoke-WebRequest -Uri $downloadUrl -OutFile $targetExe -UseBasicParsing -ErrorAction Stop if (-not (Test-Path $targetExe)) { Write-Host "Antivirus could remove frpp.exe. Permission to use the file is required." Pause } } ###################################### # Core installation functions # ###################################### function Get-OpenSSLStartScript { [CmdletBinding()] param() # Поиск каталогов OpenSSL в Program Files $results = @() $opensslDirs = Get-ChildItem -Path "$env:ProgramFiles\OpenSSL*" -Directory -ErrorAction SilentlyContinue foreach ($dir in $opensslDirs) { $batPath = Join-Path -Path $dir.FullName -ChildPath 'start.bat' if (Test-Path $batPath) { $results += $batPath } } if ($results.Count -gt 0) { return $results } else { Write-Verbose 'Start script OpenSSL (start.bat) не найден.' return @() } } function Approve-Win32Program { [CmdletBinding(DefaultParameterSetName = 'ByName', SupportsShouldProcess = $true)] param( [Parameter(Mandatory, Position = 0)] [string] $Name ) if (-Not (Get-Command -Name $Name -ErrorAction SilentlyContinue)) { Write-Host "Installing " -NoNewline Write-Host $Name -ForegroundColor Cyan -NoNewline Write-Host " via winget" if ($PSCmdlet.ShouldProcess($MyInvocation.MyCommand.Name, "winget install $Name --silent --accept-package-agreements")) { winget install $Name --silent --accept-package-agreements } } } function Write-InstallationDirectory { [CmdletBinding(SupportsShouldProcess = $true)] param() if (-not ($MyInvocation.MyCommand.Path -like "*$InstallationPath*")) { if (Test-Path $InstallationPath) { if ((Read-Host 'Path exists. Remove all? [y/N]') -eq 'y') { if ($PSCmdlet.ShouldProcess($MyInvocation.MyCommand.Name, "Remove-Item $InstallationPath -Recurse -Force")) { Remove-Item $InstallationPath -Recurse -Force } } else { Write-Error 'Aborted by user'; exit 1 } } if ($PSCmdlet.ShouldProcess($InstallationPath, "Recursively create directories and copy script")) { New-Item -Path $InstallationPath, $LogDirectoryPath, $CertificatesPath -ItemType Directory -Force Copy-Self -DestinationPath $InstallationPath New-Item -Path $(Join-Path -Path $InstallationPath -ChildPath "frp.lock") -ItemType File | Out-Null } } } function Update-Dependecies { [CmdletBinding(SupportsShouldProcess = $true)] param() if (-not (Get-WingetVersion)) { Write-Host 'Installing winget...' -ForegroundColor Cyan Install-Winget } $opensslPaths = Get-OpenSSLStartScript if (-not $opensslPaths) { Approve-Win32Program ShiningLight.OpenSSL.Light $opensslPaths = Get-OpenSSLStartScript } Write-Host "OpenSSL found at: $($opensslPaths -join ', ')" foreach ($pkg in 'NSSM','Sing-box') { Approve-Win32Program $pkg } if (-not (Test-Path $FRPPanelPath)) { if ($PSCmdlet.ShouldProcess($MyInvocation.MyCommand.Name, "Install-FrpPanel -Destination $InstallationPath")) { Install-FrpPanel -Destination $InstallationPath } } if (-not (Get-Module -ListAvailable -Name powershell-yaml)) { if ($PSCmdlet.ShouldProcess($MyInvocation.MyCommand.Name, "Install-Module -Name powershell-yaml")) { Write-Host "Installing module " -NoNewline Write-Host "powershell-yaml" -ForegroundColor Cyan Install-Module -Name powershell-yaml } } return $opensslPaths } ###################################### # System service management # ###################################### function New-NssmService { [CmdletBinding()] param( [Parameter(Mandatory=$true)] [string] $ServiceName, [Parameter(Mandatory=$true)] [string] $ExecutablePath, [Parameter(Mandatory=$false)] [string] $Arguments = "", [Parameter(Mandatory=$false)] [string] $WorkingDirectory = "", [Parameter(Mandatory=$false)] [int] $RestartDelayMs = 5000, [Parameter(Mandatory=$false)] [string] $StdOutLog = "", [Parameter(Mandatory=$false)] [string] $StdErrLog = "" ) # 1) Установка службы & nssm install $ServiceName $ExecutablePath # 2) Параметры запуска if ($Arguments) { & nssm set $ServiceName AppParameters $Arguments } # 3) Рабочая директория if ($WorkingDirectory) { & nssm set $ServiceName AppDirectory $WorkingDirectory } # 4) Автоматический запуск & nssm set $ServiceName Start SERVICE_AUTO_START # 5) Перезапуск при сбое if ($RestartDelayMs -gt 0) { & nssm set $ServiceName AppRestartDelay $RestartDelayMs } # 6) Логи stdout/stderr if ($StdOutLog) { & nssm set $ServiceName AppStdout $StdOutLog } if ($StdErrLog) { & nssm set $ServiceName AppStderr $StdErrLog } & nssm set $ServiceName AppRotateFiles 1 & nssm set $ServiceName AppRotateBytes 10000000 # 7) Запуск службы try { Start-Service $ServiceName -ErrorAction Stop Write-Host "Service '$ServiceName was created and running." } catch { Write-Warning "Service '$ServiceName' created, but not started automatically: $_" } } function Save-Config { [CmdletBinding()] param( [Alias('s')] [Parameter(Mandatory)] [string]$Secret, [Alias('i')] [Parameter(Mandatory)] [string]$Identity, [Alias('r')] [Parameter(Mandatory)] [string]$Remote, [Alias('p')] [string]$Path = (Join-Path -Path $InstallationPath -ChildPath "config.yaml") ) # Импортируем модуль powershell-yaml try { Import-Module powershell-yaml -ErrorAction Stop } catch { Write-Error "Error loading module powershell-yaml: $_" return } # Формируем данные и конвертируем в YAML $data = @{ secret = $Secret identity = $Identity remote = $Remote } try { $yaml = $data | ConvertTo-Yaml $yaml | Out-File -FilePath $Path -Encoding utf8 Write-Verbose "Config saved to '$Path'" } catch { Write-Error "Error converting to YAML: $_" } } function Get-Config { [CmdletBinding()] param( [Alias('p')] [Parameter(Mandatory)] [string]$Path ) # Проверяем, что файл существует if (-not (Test-Path -Path $Path -PathType Leaf)) { Throw "Файл не найден: $Path" } # Импортируем модуль powershell-yaml try { Import-Module powershell-yaml -ErrorAction Stop } catch { Write-Error "Error loading module powershell-yaml: $_" return } try { # Читаем содержимое файла и конвертируем из YAML $yamlContent = Get-Content -Path $Path -Raw -Encoding utf8 $config = $yamlContent | ConvertFrom-Yaml return $config } catch { Write-Error "Error converting to YAML: $_" } } # --- Script Execution --- #if (-not (Test-IsAdministrator)) { # throw 'Script requires Administrator rights.' #} function New-SelfSignedCertWithOpenSSL { <# .SYNOPSIS Создаёт самоподписанный сертификат и приватный ключ с помощью OpenSSL в Windows. .PARAMETER Subject DN-субъект сертификата, например "/C=RU/ST=Moscow/L=Moscow/O=MyCompany/CN=www.example.com" .PARAMETER OutputDir Папка, куда будут сохранены файлы сертификата и ключа. По умолчанию — текущая директория. .PARAMETER Days Срок действия сертификата в днях. По умолчанию 365. .PARAMETER OpenSSLPath Полный путь до файла openssl.exe. #> [CmdletBinding(SupportsShouldProcess = $true)] param( [Parameter(Mandatory=$true)] [string] $Subject, [Parameter(Mandatory=$false)] [string] $OutputDir = (Get-Location).Path, [Parameter(Mandatory=$false)] [int] $Days = 365, [Parameter(Mandatory=$true)] [string] $OpenSSLPath ) # Проверка наличия openssl.exe if (-not (Test-Path $OpenSSLPath)) { Throw "Не найден openssl.exe по пути: $OpenSSLPath" } # Создание выходной директории, если не существует if (-not (Test-Path $OutputDir)) { if ($PSCmdlet.ShouldProcess($OutputDir, "Create directory for certificates")) { New-Item -Path $OutputDir -ItemType Directory | Out-Null } } # Определяем пути к файлам $keyFile = Join-Path $OutputDir 'private.key' $certFile = Join-Path $OutputDir 'certificate.crt' # Аргументы для openssl $opensslArgs = @('req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', "`"$keyFile`"", '-out', "`"$certFile`"", '-days', $Days.ToString(), '-subj', "`"$Subject`"") # Запускаем openssl.exe, ждём завершения if ($PSCmdlet.ShouldProcess($OpenSSLPath, $opensslArgs)) { $proc = Start-Process -FilePath $OpenSSLPath -ArgumentList $opensslArgs -NoNewWindow -Wait -PassThru if ($proc.ExitCode -ne 0) { Throw "OpenSSL вернул ошибку (код выхода: $($proc.ExitCode))" } Write-Output "Самоподписанный сертификат создан успешно:`n • Приватный ключ: $keyFile`n • Сертификат: $certFile`n • Срок действия: $Days дней" } } function Deploy-FRPC { [CmdletBinding(SupportsShouldProcess = $true)] param( [Alias('s')] [Parameter(Mandatory)] [string]$Secret, [Alias('i')] [Parameter(Mandatory)] [string]$Identity, [Alias('r')] [Parameter(Mandatory)] [string]$Remote ) Write-InstallationDirectory if ($PSCmdlet.ShouldProcess($ConfigPath, "Saving configuration")) { Save-Config -Secret $Secret -Identity $Identity -Remote $Remote } if ($PSCmdlet.ShouldProcess($InstallationPathExe, "Spawn new pwsh process")) { # TODO } Get-SanityCheck } function New-SingBoxVlessConfig { [CmdletBinding()] param ( [Parameter(Mandatory = $true, HelpMessage = "Имя пользователя для VLESS (users.name)")] [string]$Username, [Parameter(Mandatory = $false, HelpMessage = "UUID клиента. Если не указан, будет сгенерирован")] [string]$UUID, [Parameter(Mandatory = $false, HelpMessage = "Порт для listen_port (по умолчанию 1984)")] [int]$Port = 1984, [Parameter(Mandatory = $false, HelpMessage = "Путь для сохранения конфига")] [string]$OutputPath = ".\config.json" ) # Генерируем UUID, если не задан if ([string]::IsNullOrWhiteSpace($UUID)) { $UUID = [guid]::NewGuid().Guid } # Собираем объект конфига (без секции settings и без streamSettings) $config = [PSCustomObject]@{ log = [PSCustomObject]@{ level = "info" } inbounds = @( [PSCustomObject]@{ type = "vless" tag = "vless-in" listen = "::" listen_port = $Port users = @( [PSCustomObject]@{ name = $Username uuid = $UUID flow = "" } ) } ) outbounds = @( [PSCustomObject]@{ type = "direct"; tag = "direct" }, [PSCustomObject]@{ type = "block"; tag = "block" } ) } # Сериализуем в JSON и сохраняем $config | ConvertTo-Json -Depth 6 | Set-Content -Path $OutputPath -Encoding UTF8 Write-Host "Sing-box VLESS config written to $OutputPath" Write-Host " Username: $Username" Write-Host " UUID: $UUID" Write-Host " ListenPort: $Port" } function Get-SanityCheck { [CmdletBinding(SupportsShouldProcess = $true)] param() $opensslPath = Update-Dependecies $certificates = @{ pubkey = $(Join-Path $CertificatesPath "certificate.crt"); privkey = $(Join-Path $CertificatesPath "privkey.key"); } if (-not (Test-Path $certificates["pubkey"])) { if ($PSCmdlet.ShouldProcess("OpenSSL", "Creating new certificates")) { New-SelfSignedCertWithOpenSSL ` -Subject "/C=RU/ST=Moscow/L=Moscow/O=MyCompany/CN=www.example.com" ` -OutputDir $CertificatesPath ` -OpenSSLPath $(Join-Path -Path $(Split-Path -Path $opensslPath -Parent) -ChildPath "bin" "openssl.exe") } } if (-not (Test-Path $VLESSConfigPath)) { New-SingBoxVlessConfig -OutputPath $VLESSConfigPath -Username "mint" } ($res = nssm status "$FRPServiceName") *> out-null if ($LASTEXITCODE -eq 3) { $config = Get-Config -Path $ConfigPath if ($PSCmdlet.ShouldProcess("Get-SanityCheck", "Creating FRP service")) { Write-Host "Creating FRP service" New-NssmService ` -ServiceName "Fast Reverse Proxy" ` -ExecutablePath $FRPPanelPath ` -Arguments "client -s $($config.secret) -i $($config.identity) --rpc-url $($config.remote)" ` -StdOutLog $FRPPStdoutLog ` -StdErrLog $FRPPStderrLog } } elseif ($res -eq "SERVICE_RUNNING") { <# Action when this condition is true #> } elseif ($res -eq "SERVICE_STOPPED") { } ($res = nssm status "$VLESSServiceName") *> out-null if ($LASTEXITCODE -eq 3) { $config = Get-Config -Path $ConfigPath if ($PSCmdlet.ShouldProcess("Get-SanityCheck", "Creating VLESS service")) { Write-Host "Creating VLESS service" New-NssmService ` -ServiceName "VLESS" ` -ExecutablePath $FRPPanelPath ` -Arguments "run -c $VLESSConfigPath" ` -StdOutLog $VLESSStdoutLog ` -StdErrLog $VLESSStderrLog } } } |