Files
NodeFlare/install.ps1
T
2026-09-07 23:41:50 +08:00

337 lines
13 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
[CmdletBinding(DefaultParameterSetName = "Menu")]
param(
[Parameter(ParameterSetName = "Menu")][switch]$Menu,
[Parameter(ParameterSetName = "Install", Mandatory = $true)][switch]$Install,
[Parameter(ParameterSetName = "Uninstall", Mandatory = $true)][switch]$Uninstall,
[Parameter(ParameterSetName = "Uninstall")][switch]$Purge,
[Parameter(ParameterSetName = "Status", Mandatory = $true)][switch]$Status,
[Parameter(ParameterSetName = "Restart", Mandatory = $true)][switch]$Restart
)
$ErrorActionPreference = "Stop"
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
$Repository = "imengying/NodeFlare"
$TaskName = "nodeflare"
$InstallDir = Join-Path $env:ProgramFiles "NodeFlare"
$ServerFile = Join-Path $InstallDir "nodeflare.exe"
$ShareDir = Join-Path $InstallDir "share"
$DataDir = Join-Path $env:ProgramData "NodeFlare\Server"
$ConfigFile = Join-Path $DataDir "config.toml"
$ThemeDir = Join-Path $DataDir "themes"
function Write-Step([string]$Message) {
Write-Host $Message
}
function Stop-Install([string]$Message) {
throw "错误:$Message"
}
function Show-InstallResult {
Write-Host ""
if (-not $NewConfig) {
Write-Host "更新完成(v$Version)"
return
}
Write-Host "安装完成(v$Version)"
Write-Host " 配置和数据:$DataDir"
Write-Host " 服务:Windows 计划任务 $TaskName"
Write-Host " 本机访问:http://127.0.0.1:$Port/admin/login"
Write-Host " 下一步:登录管理后台创建节点,并按弹窗命令安装 Agent"
}
function Assert-Administrator {
$Identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$Principal = [Security.Principal.WindowsPrincipal]$Identity
if (-not $Principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
Stop-Install "请使用管理员身份运行 PowerShell"
}
}
function Read-Password([string]$Prompt) {
$Secure = Read-Host $Prompt -AsSecureString
$Pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Secure)
try {
[Runtime.InteropServices.Marshal]::PtrToStringBSTR($Pointer)
} finally {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($Pointer)
}
}
function Escape-Toml([string]$Value) {
$Value.Replace('\', '\\').Replace('"', '\"')
}
function Show-Menu {
$CurrentVersion = $null
if (Test-Path -LiteralPath $ServerFile -PathType Leaf) {
try { $VersionOutput = (& $ServerFile --version 2>$null | Out-String).Trim() } catch { $VersionOutput = "" }
if ($VersionOutput -match '(\d+\.\d+\.\d+)\s*$') { $CurrentVersion = $Matches[1] }
}
$InstallAction = if ($CurrentVersion) { "更新(当前 v$CurrentVersion)" } else { "安装" }
Write-Host ""
Write-Host "NodeFlare 面板管理"
Write-Host " 1. $InstallAction"
Write-Host " 2. 查看服务状态"
Write-Host " 3. 重启服务"
Write-Host " 4. 卸载(保留配置和数据)"
Write-Host " 0. 退出"
while ($true) {
switch ((Read-Host "请选择 [0]").Trim()) {
"1" { return "Install" }
"2" { return "Status" }
"3" { return "Restart" }
"4" {
if ((Read-Host "确认卸载 NodeFlare 面板?[y/N]").Trim() -eq "y") { return "Uninstall" }
return "Exit"
}
"0" { return "Exit" }
"" { return "Exit" }
default { Write-Host "请输入 0-4。" }
}
}
}
function Read-Port {
while ($true) {
$Value = (Read-Host "监听端口 [2206]").Trim()
if (-not $Value) { return 2206 }
if ($Value -match '^[0-9]{1,5}$') {
$Port = [int]$Value
if ($Port -ge 1 -and $Port -le 65535) { return $Port }
}
Write-Host "端口必须是 1-65535 之间的整数。"
}
}
function Wait-Server {
$Task = $null
for ($Attempt = 0; $Attempt -lt 10; $Attempt++) {
Start-Sleep -Milliseconds 500
$Task = Get-ScheduledTask -TaskName $TaskName
if ($Task.State -eq "Running") { break }
}
if ($Task.State -ne "Running") {
Stop-Install "服务启动失败(状态:$($Task.State))"
}
for ($Attempt = 0; $Attempt -lt 10; $Attempt++) {
Start-Sleep -Seconds 1
$Task = Get-ScheduledTask -TaskName $TaskName
if ($Task.State -ne "Running") {
Stop-Install "服务启动后退出,请检查 bind_addr 端口占用及数据库连接"
}
}
}
function Write-Config([string]$Username, [string]$Password, [string]$DatabaseUrl, [int]$Port) {
$FrontendDir = (Join-Path $ShareDir "frontend").Replace('\', '/')
$AdminDir = (Join-Path $ShareDir "admin").Replace('\', '/')
$AgentDir = (Join-Path $ShareDir "agent").Replace('\', '/')
$ThemePath = $ThemeDir.Replace('\', '/')
$Lines = @(
"database_url = `"$(Escape-Toml $DatabaseUrl)`""
"bind_addr = `"127.0.0.1:$Port`""
'trusted_proxies = ["127.0.0.1/32", "::1/128"]'
"admin_username = `"$(Escape-Toml $Username)`""
"admin_password = `"$(Escape-Toml $Password)`""
'turnstile_site_key = ""'
'turnstile_secret_key = ""'
"frontend_dir = `"$FrontendDir`""
"admin_frontend_dir = `"$AdminDir`""
"agent_dir = `"$AgentDir`""
"theme_dir = `"$ThemePath`""
'session_ttl_hours = 168'
)
[IO.File]::WriteAllLines($ConfigFile, $Lines, [Text.UTF8Encoding]::new($false))
}
$Mode = $PSCmdlet.ParameterSetName
if ($Mode -eq "Menu") { $Mode = Show-Menu }
if ($Mode -eq "Exit") { exit 0 }
if ($Mode -eq "Status") {
$Task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if ($null -eq $Task) {
Write-Error "未检测到面板服务"
exit 1
}
$Task
exit 0
}
Assert-Administrator
if ($Mode -eq "Restart") {
$Task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if ($null -eq $Task -or -not (Test-Path -LiteralPath $ServerFile) -or -not (Test-Path -LiteralPath $ConfigFile)) {
Stop-Install "未检测到完整安装,请先选择安装 / 更新"
}
Stop-ScheduledTask -TaskName $TaskName
Start-ScheduledTask -TaskName $TaskName
Wait-Server
Write-Step "服务已重启"
exit 0
}
if ($Mode -eq "Uninstall") {
if ($Purge -and -not [Console]::IsInputRedirected) {
if ((Read-Host "即将删除全部配置和数据,确认继续?[y/N]").Trim() -ne "y") {
Stop-Install "已取消卸载"
}
}
Write-Step "停止并移除面板服务"
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $ServerFile -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $ShareDir -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $InstallDir -Force -ErrorAction SilentlyContinue
if ($Purge) {
Remove-Item -LiteralPath $DataDir -Recurse -Force -ErrorAction SilentlyContinue
Write-Step "已删除配置和全部服务端持久数据"
} else {
Write-Step "已保留配置和服务端持久数据:$DataDir"
}
Write-Step "卸载完成"
exit 0
}
$NativeArchitecture = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE }
if ($NativeArchitecture -ne "AMD64") {
Stop-Install "仅支持 Windows x64"
}
$Asset = "nodeflare-server-windows-x64.zip"
$ReleaseApi = "https://api.github.com/repos/$Repository/releases/latest"
$TemporaryDir = Join-Path $env:TEMP "nodeflare-install-$PID"
$Archive = Join-Path $TemporaryDir $Asset
$PackageDir = Join-Path $TemporaryDir "package"
$PreviousInstall = Join-Path $TemporaryDir "previous-install"
$InstallChanged = $false
$HadPreviousInstall = $false
$PreviousTaskXml = $null
try {
New-Item -ItemType Directory -Path $TemporaryDir -Force | Out-Null
Write-Step "获取 latest Release(Windows x64)"
$Release = Invoke-RestMethod -Uri $ReleaseApi -Headers @{ Accept = "application/vnd.github+json"; "User-Agent" = "nodeflare-installer" } -TimeoutSec 30
$ReleaseAsset = $Release.assets | Where-Object { $_.name -eq $Asset } | Select-Object -First 1
if ($Release.tag_name -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+$' -or $null -eq $ReleaseAsset) {
Stop-Install "GitHub 最新 Release 无效,或缺少 $Asset"
}
$DigestMatch = [regex]::Match([string]$ReleaseAsset.digest, '^sha256:([0-9a-fA-F]{64})$')
if (-not $DigestMatch.Success) { Stop-Install "Release 缺少 $Asset 的 SHA-256 摘要" }
$Expected = $DigestMatch.Groups[1].Value
Write-Step "下载 $($Release.tag_name)(Windows x64)"
Invoke-WebRequest -UseBasicParsing -Uri $ReleaseAsset.browser_download_url -OutFile $Archive -TimeoutSec 120
$Actual = (Get-FileHash -LiteralPath $Archive -Algorithm SHA256).Hash
if ($Actual -ne $Expected) {
Stop-Install "Release SHA-256 校验失败"
}
Expand-Archive -LiteralPath $Archive -DestinationPath $PackageDir -Force
$PackageServer = Join-Path $PackageDir "nodeflare.exe"
foreach ($Required in @(
$PackageServer,
(Join-Path $PackageDir "share\frontend\index.html"),
(Join-Path $PackageDir "share\admin\admin.html"),
(Join-Path $PackageDir "share\agent\agent.sh"),
(Join-Path $PackageDir "LICENSE")
)) {
if (-not (Test-Path -LiteralPath $Required -PathType Leaf)) {
Stop-Install "Release 文件不完整"
}
}
Unblock-File -LiteralPath $PackageServer
$VersionOutput = (& $PackageServer --version | Out-String).Trim()
if ($LASTEXITCODE -ne 0) {
Stop-Install "服务端文件无法运行"
}
$Version = ($VersionOutput -split '\s+')[-1]
if ($Version -notmatch '^\d+\.\d+\.\d+$') {
Stop-Install "服务端返回了无效版本号"
}
if ($Version -ne $Release.tag_name.Substring(1)) {
Stop-Install "Release $($Release.tag_name) 与服务端版本 $Version 不一致"
}
$NewConfig = -not (Test-Path -LiteralPath $ConfigFile)
if ($NewConfig) {
do {
$Username = (Read-Host "管理员用户名 [admin]").Trim()
if (-not $Username) { $Username = "admin" }
if ($Username -notmatch '^[A-Za-z0-9_.-]{1,64}$') { Write-Host "用户名只能包含字母、数字、点、下划线和连字符(1-64 个字符)。" }
} while ($Username -notmatch '^[A-Za-z0-9_.-]{1,64}$')
while ($true) {
$Password = Read-Password "管理员密码(8-128 个字符)"
if ($Password.Length -lt 8 -or $Password.Length -gt 128) {
Write-Host "密码长度必须在 8-128 个字符之间。"
continue
}
$Confirmed = Read-Password "再次输入密码"
if ($Password -ceq $Confirmed) { break }
Write-Host "两次输入的密码不一致。"
}
$Port = Read-Port
do {
$DatabaseUrl = (Read-Host "数据库 URL [sqlite://nodeflare.db]").Trim()
if (-not $DatabaseUrl) { $DatabaseUrl = "sqlite://nodeflare.db" }
} while ($DatabaseUrl -notmatch '^(sqlite|postgres|postgresql)://')
}
if ($NewConfig) {
Write-Step "正在安装 v$Version"
} else {
Write-Step "正在更新至 v$Version"
}
$HadPreviousInstall = Test-Path -LiteralPath $InstallDir -PathType Container
if ($HadPreviousInstall) {
Copy-Item -LiteralPath $InstallDir -Destination $PreviousInstall -Recurse -Force
}
$ExistingTask = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if ($null -ne $ExistingTask) {
$PreviousTaskXml = Export-ScheduledTask -TaskName $TaskName
}
$InstallChanged = $true
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path $InstallDir, $DataDir, $ThemeDir -Force | Out-Null
Remove-Item -LiteralPath $ShareDir -Recurse -Force -ErrorAction SilentlyContinue
Copy-Item -LiteralPath (Join-Path $PackageDir "share") -Destination $ShareDir -Recurse -Force
Copy-Item -LiteralPath $PackageServer -Destination $ServerFile -Force
if ($NewConfig) {
Write-Config $Username $Password $DatabaseUrl $Port
}
& icacls.exe $DataDir /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null
if ($LASTEXITCODE -ne 0) { Stop-Install "无法限制配置和数据目录权限" }
$Action = New-ScheduledTaskAction -Execute $ServerFile -Argument "--config `"$ConfigFile`"" -WorkingDirectory $DataDir
$Trigger = New-ScheduledTaskTrigger -AtStartup
$Settings = New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit (New-TimeSpan -Days 3650)
$Principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
Register-ScheduledTask -TaskName $TaskName -Action $Action -Trigger $Trigger -Settings $Settings -Principal $Principal -Force | Out-Null
Write-Step "正在启动服务"
Start-ScheduledTask -TaskName $TaskName
Wait-Server
$InstallChanged = $false
Show-InstallResult
} catch {
if ($InstallChanged) {
if ($HadPreviousInstall) { Write-Warning "安装未完成,正在恢复上一版本" }
else { Write-Warning "安装未完成,正在回滚本次更改" }
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $InstallDir -Recurse -Force -ErrorAction SilentlyContinue
if ($HadPreviousInstall) {
Copy-Item -LiteralPath $PreviousInstall -Destination $InstallDir -Recurse -Force
}
if ($null -ne $PreviousTaskXml) {
Register-ScheduledTask -TaskName $TaskName -Xml $PreviousTaskXml -Force | Out-Null
Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
}
}
throw
} finally {
Remove-Item -LiteralPath $TemporaryDir -Recurse -Force -ErrorAction SilentlyContinue
}