mint-frp.ps1


<#PSScriptInfo
 
.VERSION 1.0.1
 
.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"
$LogDirectoryPath = Join-Path $InstallationPath "logs"
$VLESSStdoutLog = Join-Path $LogDirectoryPath "vless_stdout.log"
$VLESSStderrLog = Join-Path $LogDirectoryPath "vless_stderr.log"

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