netx/packaging/setup_first_run.ps1
oliver eb0eccdffe UME TLS off by default on Linux and Windows; truthful admin login hints.
- Deployments are on internal networks with self-signed UME certificates:
  installers write NETX_UME_VERIFY_TLS=false and
  NETX_ALLOW_INSECURE_DEFAULTS=1 when absent (Linux also on update), so
  binding 0.0.0.0 no longer stops NetX at the startup security check.
  Admin password and JWT secret stay unique per install.
- Linux: after install, check app_user to report whether the generated
  initial password applies or the admin already exists (reused external
  DB); new 'netxctl admin-password'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:50:38 +08:00

394 lines
19 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.

param(
[ValidateSet("bundled", "external", "")]
[string]$DbMode = "",
[string]$ProgramRoot = "",
[string]$DataRoot = "",
[string]$ExternalDatabaseUrl = "",
[string]$ExternalDatabaseUrlFile = "",
[string]$CredentialSecretKey = "",
[string]$CredentialSecretKeyFile = "",
[string]$BundledPassword = "",
[int]$BundledPort = 15432,
[switch]$NonInteractive = $false,
[switch]$SkipExternalProbe = $false
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot\_common.ps1"
Assert-NetxAdminOrRelaunch -ScriptPath $PSCommandPath -BoundParameters $PSBoundParameters -WindowStyle Normal
$prog = Get-NetxProgramRoot -Override $ProgramRoot
$data = Get-NetxDataRoot -ProgramRoot $prog -Override $DataRoot
$envPath = Join-Path $data ".env"
$zh = ([cultureinfo]::CurrentUICulture.Name -match '^(zh|zh-)')
function T([string]$En, [string]$Zh) {
if ($zh) { return $Zh } else { return $En }
}
try {
Write-Host ("==> " + (T "Program root:" "程序目录:") + " $prog")
Write-Host ("==> " + (T "Data root:" "数据目录:") + " $data")
Write-Host ("==> " + (T "Env file:" "环境文件:") + " $envPath")
Ensure-NetxDataDirectories -DataRoot $data
$existing = Read-DotEnv -Path $envPath
if (-not $DbMode) {
if ($NonInteractive) {
if ($existing.ContainsKey("NETX_DB_MODE")) {
$DbMode = $existing["NETX_DB_MODE"]
} elseif ($existing.ContainsKey("NETX_DATABASE_URL")) {
$DbMode = "external"
} else {
$DbMode = "bundled"
}
} else {
Write-Host ""
Write-Host (T "Choose database mode:" "选择数据库模式:") -ForegroundColor Cyan
Write-Host (T " 1) bundled — NetX portable PostgreSQL (offline / default)" " 1) bundled — NetX 内置便携 PostgreSQL(可离线,默认)")
Write-Host (T " 2) external — existing PostgreSQL (you provide connection URL)" " 2) external — 已有外置 PostgreSQL(需填写连接串)")
Write-Host ""
$choice = Read-Host (T "Enter 1 or 2" "请输入 1 或 2")
if ($choice -eq "2") { $DbMode = "external" } else { $DbMode = "bundled" }
}
}
$DbMode = $DbMode.Trim().ToLowerInvariant()
if ($DbMode -ne "bundled" -and $DbMode -ne "external") {
throw "invalid_db_mode: $DbMode"
}
$values = @{}
$values["NETX_DB_MODE"] = $DbMode
$values["NETX_HOST"] = "127.0.0.1"
$values["NETX_PORT"] = "8890"
# Internal networks: UME uses self-signed certificates. Keep TLS verification off and
# waive that startup check explicitly, so switching NETX_HOST to 0.0.0.0 later does
# not stop NetX (same defaults as the Linux installer). Existing values are kept.
if (-not $existing.ContainsKey("NETX_UME_VERIFY_TLS")) { $values["NETX_UME_VERIFY_TLS"] = "false" }
if (-not $existing.ContainsKey("NETX_ALLOW_INSECURE_DEFAULTS")) { $values["NETX_ALLOW_INSECURE_DEFAULTS"] = "1" }
# Absolute UI path when present; else relative for source trees.
$distAbs = Join-Path $prog "web\dist"
if (Test-Path (Join-Path $distAbs "index.html")) {
$values["NETX_UI_DIST_DIR"] = (ConvertTo-NetxFsPath $distAbs)
} else {
$values["NETX_UI_DIST_DIR"] = "web/dist"
}
# All runtime data under data root (never under Program Files).
$dataEnv = Get-NetxDataEnvMap -DataRoot $data
foreach ($k in $dataEnv.Keys) { $values[$k] = $dataEnv[$k] }
# Preload credential key from file/CLI only; interactive prompt comes AFTER DB settings
# so users are not left thinking the key alone finished setup.
if ($CredentialSecretKeyFile) {
if (-not (Test-Path -LiteralPath $CredentialSecretKeyFile)) {
throw "credential_secret_key_file_missing: $CredentialSecretKeyFile"
}
$CredentialSecretKey = [IO.File]::ReadAllText($CredentialSecretKeyFile).Trim()
}
if ($DbMode -eq "bundled") {
$pgsql = Join-Path $prog "postgres\pgsql"
if (-not (Test-Path (Join-Path $pgsql "bin\initdb.exe"))) {
$alt = Join-Path $PSScriptRoot "postgres\pgsql"
if (Test-Path (Join-Path $alt "bin\initdb.exe")) {
$pgsql = $alt
} else {
throw (T `
"bundled_postgres_missing: incomplete package (expected $pgsql). Offline Setup must include postgres; rebuild with build_release.ps1 on a machine that already ran download_postgres.ps1." `
"缺少内置 PostgreSQL(期望路径 $pgsql)。离线安装包必须自带数据库;请在已运行过 download_postgres.ps1 的机器上重新 build_release。")
}
}
if (-not $BundledPassword) {
if ($NonInteractive) {
$BundledPassword = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 24 | ForEach-Object { [char]$_ })
} else {
$sec = Read-Host -Prompt (T "Password for bundled role 'netx' (empty = auto-generate)" "内置数据库用户 netx 的密码(回车=自动生成)") -AsSecureString
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec)
try {
$BundledPassword = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
} finally {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
}
if (-not $BundledPassword) {
$BundledPassword = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 24 | ForEach-Object { [char]$_ })
Write-Host (T "Generated password (saved in .env only)." "已自动生成密码(仅保存在 .env)。")
}
}
}
# Keep the configured port on reconfigure unless the caller passes -BundledPort.
if (-not $PSBoundParameters.ContainsKey("BundledPort")) {
$keepPort = 0
if ($existing.ContainsKey("NETX_BUNDLED_PG_PORT")) {
try { $keepPort = [int]$existing["NETX_BUNDLED_PG_PORT"] } catch { $keepPort = 0 }
}
if ($keepPort -ge 1024 -and $keepPort -le 65535) { $BundledPort = $keepPort }
if (-not $NonInteractive) {
$entered = (Read-Host (T "Bundled PostgreSQL port [$BundledPort]" "内置 PostgreSQL 端口 [$BundledPort]")).Trim()
if ($entered) { $BundledPort = [int]$entered }
}
}
if ($BundledPort -lt 1024 -or $BundledPort -gt 65535) {
throw "invalid_bundled_port: $BundledPort (1024-65535)"
}
$pgData = Join-Path $data "pgdata"
$values["NETX_BUNDLED_PG_PORT"] = "$BundledPort"
$values["NETX_BUNDLED_PG_DATA_DIR"] = (ConvertTo-NetxFsPath $pgData)
$pwFile = Join-Path $data "pg_netx.pw"
Set-Content -Path $pwFile -Value $BundledPassword -Encoding ascii -NoNewline
$encPw = [uri]::EscapeDataString($BundledPassword)
$values["NETX_DATABASE_URL"] = "postgresql+psycopg://netx:${encPw}@127.0.0.1:${BundledPort}/netx"
$initdb = Join-Path $pgsql "bin\initdb.exe"
$pgCtl = Join-Path $pgsql "bin\pg_ctl.exe"
$psql = Join-Path $pgsql "bin\psql.exe"
$sqlPw = ConvertTo-NetxSqlLiteral $BundledPassword
if (-not (Test-Path (Join-Path $pgData "PG_VERSION"))) {
Write-Host "==> initdb $pgData"
$pwSuper = Join-Path $data "pg_super.pw"
Set-Content -Path $pwSuper -Value $BundledPassword -Encoding ascii -NoNewline
& $initdb -D $pgData -U postgres -A password --pwfile=$pwSuper -E UTF8 --locale=C
if ($LASTEXITCODE -ne 0) { throw "initdb_failed" }
}
$conf = Join-Path $pgData "postgresql.conf"
$hba = Join-Path $pgData "pg_hba.conf"
if (Test-Path $conf) {
$confText = Get-Content -Raw -Path $conf
if ($confText -notmatch "(?m)^\s*port\s*=") {
Add-Content -Path $conf -Value "`nport = $BundledPort`nlisten_addresses = '127.0.0.1'`n"
} else {
$confText = $confText -replace '(?m)^\s*port\s*=\s*\d+', "port = $BundledPort"
if ($confText -notmatch "(?m)^\s*listen_addresses\s*=") {
$confText += "`nlisten_addresses = '127.0.0.1'`n"
} else {
$confText = $confText -replace "(?m)^\s*listen_addresses\s*=\s*'[^']*'", "listen_addresses = '127.0.0.1'"
}
# No BOM: PowerShell 5.1 `Set-Content -Encoding utf8` adds one and postgres then
# rejects postgresql.conf (syntax error at line 1) and will not start.
[IO.File]::WriteAllText($conf, $confText, (New-Object System.Text.UTF8Encoding($false)))
}
}
if (Test-Path $hba) {
$hbaText = Get-Content -Raw -Path $hba
if ($hbaText -notmatch "127\.0\.0\.1/32") {
Add-Content -Path $hba -Value "`nhost all all 127.0.0.1/32 scram-sha-256`n"
}
}
$status = & $pgCtl -D $pgData status 2>&1 | Out-String
if ($status -notmatch "server is running") {
if (-not (Test-NetxTcpPortFree -HostName "127.0.0.1" -Port $BundledPort)) {
throw "port_in_use: 127.0.0.1:$BundledPort already occupied (bundled Postgres)"
}
Write-Host "==> Starting bundled PostgreSQL for bootstrap"
& $pgCtl -D $pgData -l (Join-Path $pgData "pg.log") start
if ($LASTEXITCODE -ne 0) { throw "pg_start_failed" }
Start-Sleep -Seconds 2
}
$env:PGPASSWORD = $BundledPassword
try {
function Invoke-NetxPsql {
param([string]$Sql, [string]$Database = "postgres")
$out = & $psql -h 127.0.0.1 -p $BundledPort -U postgres -d $Database -v ON_ERROR_STOP=1 -tAc $Sql 2>&1
if ($LASTEXITCODE -ne 0) {
throw "psql_failed ($LASTEXITCODE): $Sql`n$out"
}
return (($out | Out-String).Trim())
}
$role = Invoke-NetxPsql -Sql "SELECT 1 FROM pg_roles WHERE rolname='netx'"
if ($role -eq "1") {
Invoke-NetxPsql -Sql "ALTER ROLE netx WITH LOGIN PASSWORD '$sqlPw'" | Out-Null
} else {
Invoke-NetxPsql -Sql "CREATE ROLE netx LOGIN PASSWORD '$sqlPw'" | Out-Null
}
$db = Invoke-NetxPsql -Sql "SELECT 1 FROM pg_database WHERE datname='netx'"
if ($db -ne "1") {
Invoke-NetxPsql -Sql "CREATE DATABASE netx OWNER netx" | Out-Null
}
Invoke-NetxPsql -Sql "GRANT ALL PRIVILEGES ON DATABASE netx TO netx" | Out-Null
# Verify login as app role (catches auth/hba mismatches early).
$prev = $env:PGPASSWORD
$env:PGPASSWORD = $BundledPassword
try {
$ping = & $psql -h 127.0.0.1 -p $BundledPort -U netx -d netx -v ON_ERROR_STOP=1 -tAc "SELECT 1" 2>&1
if ($LASTEXITCODE -ne 0 -or (($ping | Out-String).Trim()) -ne "1") {
throw "netx_role_login_failed: $ping"
}
} finally {
$env:PGPASSWORD = $prev
}
} finally {
Remove-Item Env:PGPASSWORD -ErrorAction SilentlyContinue
}
Write-Host "==> Bundled Postgres ready on 127.0.0.1:$BundledPort" -ForegroundColor Green
} else {
if ($ExternalDatabaseUrlFile) {
if (-not (Test-Path -LiteralPath $ExternalDatabaseUrlFile)) {
throw "external_url_file_missing: $ExternalDatabaseUrlFile"
}
$ExternalDatabaseUrl = [IO.File]::ReadAllText($ExternalDatabaseUrlFile).Trim()
}
if (-not $ExternalDatabaseUrl) {
if ($NonInteractive) {
if ($existing.ContainsKey("NETX_DATABASE_URL") -and $existing["NETX_DATABASE_URL"]) {
$ExternalDatabaseUrl = $existing["NETX_DATABASE_URL"]
} else {
throw "external_url_required"
}
} else {
# Interactive: always ask. Empty = keep existing URL (never silent).
Write-Host ""
Write-Host (T `
"Enter PostgreSQL URL (database/role must already exist):" `
"请输入 PostgreSQL 连接串(库和用户需事先建好):") -ForegroundColor Cyan
Write-Host " postgresql+psycopg://USER:PASSWORD@HOST:5432/DBNAME"
Write-Host (T `
"Example: postgresql+psycopg://netx:secret@10.0.0.8:5432/netx" `
"示例: postgresql+psycopg://netx:secret@10.0.0.8:5432/netx")
if ($existing.ContainsKey("NETX_DATABASE_URL") -and $existing["NETX_DATABASE_URL"]) {
Write-Host (T `
"Current: $($existing['NETX_DATABASE_URL'])" `
"当前: $($existing['NETX_DATABASE_URL'])") -ForegroundColor DarkGray
Write-Host (T `
"Press Enter to keep the current URL, or paste a new one." `
"直接回车 = 保留当前连接串;或粘贴新的连接串。")
}
$entered = (Read-Host "NETX_DATABASE_URL").Trim()
if ($entered) {
$ExternalDatabaseUrl = $entered
} elseif ($existing.ContainsKey("NETX_DATABASE_URL") -and $existing["NETX_DATABASE_URL"]) {
$ExternalDatabaseUrl = $existing["NETX_DATABASE_URL"]
Write-Host (T "==> Keeping existing NETX_DATABASE_URL" "==> 保留已有 NETX_DATABASE_URL") -ForegroundColor Green
}
}
}
if (-not $ExternalDatabaseUrl) {
throw "external_url_required"
}
$ExternalDatabaseUrl = $ExternalDatabaseUrl.Trim()
$values["NETX_DATABASE_URL"] = $ExternalDatabaseUrl
Write-Host "==> External Postgres configured" -ForegroundColor Green
if (-not $SkipExternalProbe) {
# Probe with bundled psql when available (wizard already tested; this covers CLI reconfigure).
$psqlProbe = Join-Path $prog "postgres\pgsql\bin\psql.exe"
$testHelper = Join-Path $PSScriptRoot "installer\test_pg_conn.ps1"
if (-not (Test-Path $testHelper)) {
$testHelper = Join-Path $PSScriptRoot "test_pg_conn.ps1"
}
if ((Test-Path $psqlProbe) -and ($ExternalDatabaseUrl -match '^(?:postgresql(?:\+psycopg)?|postgres)://([^:]+):([^@]*)@([^:/]+):?(\d+)?/([^?\s]+)')) {
$u = [uri]::UnescapeDataString($Matches[1])
$pw = [uri]::UnescapeDataString($Matches[2])
$h = $Matches[3]
$po = if ($Matches[4]) { [int]$Matches[4] } else { 5432 }
$dbn = [uri]::UnescapeDataString($Matches[5])
Write-Host "==> Probing external PostgreSQL ${h}:${po}/${dbn} ..."
if (Test-Path $testHelper) {
$probeErr = Join-Path $env:TEMP ("netx-pg-probe-" + [Guid]::NewGuid().ToString("n") + ".txt")
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $testHelper `
-PgHost $h -Port $po -User $u -Password $pw -Database $dbn `
-PsqlPath $psqlProbe -OutErrFile $probeErr
if ($LASTEXITCODE -ne 0) {
$detail = if (Test-Path $probeErr) { (Get-Content -LiteralPath $probeErr -Raw) } else { "exit $LASTEXITCODE" }
Remove-Item -LiteralPath $probeErr -Force -ErrorAction SilentlyContinue
throw (T "external_db_probe_failed: $detail" "外置数据库连接失败: $detail")
}
Remove-Item -LiteralPath $probeErr -Force -ErrorAction SilentlyContinue
Write-Host "==> External PostgreSQL OK" -ForegroundColor Green
} else {
$env:PGPASSWORD = $pw
try {
$ping = & $psqlProbe -h $h -p $po -U $u -d $dbn -t -A -c "SELECT 1" 2>&1
if ($LASTEXITCODE -ne 0 -or (($ping | Out-String).Trim()) -notmatch '1') {
throw (T "external_db_probe_failed: $ping" "外置数据库连接失败: $ping")
}
} finally {
Remove-Item Env:PGPASSWORD -ErrorAction SilentlyContinue
}
}
} else {
Write-Host (T `
"[WARN] Skipped connection probe (no bundled psql or URL parse failed)." `
"[WARN] 已跳过连接探测(缺少捆绑 psql 或连接串无法解析)。") -ForegroundColor Yellow
}
}
}
# Fernet key AFTER database prompts (interactive UX).
# Priority: CLI/file > interactive prompt > existing .env > generate.
if (-not $CredentialSecretKey -and -not $NonInteractive) {
Write-Host ""
Write-Host (T `
"Optional: paste NETX_CREDENTIAL_SECRET_KEY from an existing install" `
"可选: 粘贴已有安装的 NETX_CREDENTIAL_SECRET_KEY(便于沿用已加密凭据)") -ForegroundColor Cyan
Write-Host (T `
"Leave empty to keep existing .env key, or auto-generate if none." `
"留空则保留已有 .env 中的密钥;若没有则自动生成。")
$CredentialSecretKey = (Read-Host "NETX_CREDENTIAL_SECRET_KEY").Trim()
}
if ($CredentialSecretKey) {
$values["NETX_CREDENTIAL_SECRET_KEY"] = $CredentialSecretKey.Trim()
Write-Host "==> Using provided NETX_CREDENTIAL_SECRET_KEY" -ForegroundColor Green
} elseif ($existing.ContainsKey("NETX_CREDENTIAL_SECRET_KEY") -and $existing["NETX_CREDENTIAL_SECRET_KEY"]) {
$values["NETX_CREDENTIAL_SECRET_KEY"] = $existing["NETX_CREDENTIAL_SECRET_KEY"]
Write-Host "==> Keeping existing NETX_CREDENTIAL_SECRET_KEY from .env" -ForegroundColor Green
} else {
$values["NETX_CREDENTIAL_SECRET_KEY"] = New-NetxFernetKey
Write-Host "==> Generated NETX_CREDENTIAL_SECRET_KEY (saved in .env)" -ForegroundColor Green
}
Write-DotEnvValue -Path $envPath -Values $values
Write-Host ""
Write-Host "Wrote $envPath" -ForegroundColor Green
# Official Setup ships python/runtime + .venv (build_release -CreateVenv).
$venvPy = Join-Path $prog ".venv\Scripts\python.exe"
try {
$null = Ensure-NetxVenv -ProgramRoot $prog
Write-Host "==> Bundled Python venv OK: $venvPy" -ForegroundColor Green
} catch {
if (Test-Path $venvPy) {
throw
}
Write-Host "==> Bundled .venv missing — creating one (Setup should normally ship it)." -ForegroundColor Yellow
Write-Host "[WARN] $($_.Exception.Message)" -ForegroundColor Yellow
Write-Host " Install a Setup built with: packaging\build_release.ps1 -CreateVenv" -ForegroundColor Yellow
}
Write-Host ""
Write-Host (T "Setup complete." "首次配置完成。") -ForegroundColor Green
if (-not $NonInteractive) {
Write-Host (T `
"Next: Start Menu → NetX Tray (or press Y below)." `
"下一步: 开始菜单 → NetX 托盘 (或在下方按 Y 立即启动)。")
$ans = Read-Host (T "Start NetX tray now? [Y/n]" "现在启动 NetX 托盘?[Y/n]")
if ($ans -notmatch '^[Nn]') {
try {
Start-NetxPowerShell -File (Join-Path $PSScriptRoot "netx_tray.ps1") `
-Arguments @("-ProgramRoot", $prog, "-DataRoot", $data, "-StartOnLaunch") `
-WorkingDirectory $prog -WindowStyle Hidden | Out-Null
Write-Host (T "Tray started." "托盘已启动。") -ForegroundColor Green
} catch {
Write-Host "[WARN] $($_.Exception.Message)" -ForegroundColor Yellow
}
}
} else {
Write-Host "Next: .\packaging\start_netx_app.ps1 or NetX-Tray.cmd"
}
} catch {
Write-Host ""
Write-Host ("[ERR] " + $_.Exception.Message) -ForegroundColor Red
if (-not $NonInteractive) {
try {
[void](Read-Host (T "Press Enter to close" "按回车关闭窗口"))
} catch {}
}
exit 1
}