#Requires -Version 5.1 <# JARWITS SA — autonomous station installer. Online: [Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; & ([scriptblock]::Create((irm https://api.jarwits.ru))) -StationToken JARW-XXXX-XXXX-XXXX (нельзя `irm | iex -StationToken` — iex не принимает параметры скрипта; всё по 443, порт не указывается) Offline: powershell -File install.ps1 -OfflineMode -BundlePath D:\jarwits-bundle -CustomerName ... -FieldName ... -PadName ... Everything is fetched ONLY from api.jarwits.ru (or a local bundle). No git/npm/pip, no nodejs.org/python.org. The archive is Ed25519-verified with the bundled node.exe. #> [CmdletBinding()] param( [string]$StationToken = "", [string]$ApiBase = "https://api.beta.jarwits.ru", # install-token /api/install + ingest /api/ingest -> JING (path-роут) [string]$GetBase = "https://api.beta.jarwits.ru", # bootstrap + update files/manifests /updates -> JGT (path-роут) [string]$SyncBase = "wss://api.beta.jarwits.ru", # persistent WSS /ws/sync,/ws/stream -> JSC (empty "" -> HTTP-only) [string]$Version = "2026.07.1", [string]$InstallDir = "C:\JARWITS", [switch]$OfflineMode, [string]$BundlePath = "", # «Уже установлено и работает»: по умолчанию установщик НЕ трогает живую настроенную станцию (повторный # irm|iex не затирает рабочую). -Reinstall форсирует переустановку поверх; -Uninstall запускает удаление. [switch]$Reinstall, [switch]$Uninstall, # -Status: показать состояние станции (версия/связь/объект + ОТПЕЧАТОК ключа усыновления для сверки) и выйти. # Для дормантной станции без веб-панели — прочитать отпечаток epk с машины оператором (out-of-band сверка). [switch]$Status, # Offline / manual object identity (online mode fills these from the token's pad): [string]$CustomerName = "", [string]$FieldName = "", [string]$PadName = "", [string]$WellName = "" ) $ErrorActionPreference = "Stop" $ProgressPreference = "SilentlyContinue" # faster Invoke-WebRequest [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 # 🔴 -ApiBase задаёт КОНТУР целиком (prod/beta/staging). Если оператор указал -ApiBase, но НЕ задал явно # -GetBase/-SyncBase — производим их из ApiBase: GetBase = тот же хост (обновления/манифесты), SyncBase = # тот же хост со схемой wss (WSS-стрим). Иначе установка через -ApiBase https://api.beta… слала бы self-update # (update_manifest_url) и WSS-стрим (sync_url) на ПРОД, а токен валидировался на бете (память # beta-staging-install-pitfalls-2026-08-04). Явно заданные -GetBase/-SyncBase имеют приоритет (не перетираем). if ($PSBoundParameters.ContainsKey('ApiBase')) { if (-not $PSBoundParameters.ContainsKey('GetBase')) { $GetBase = $ApiBase } if (-not $PSBoundParameters.ContainsKey('SyncBase')) { $SyncBase = $ApiBase -replace '^https://', 'wss://' -replace '^http://', 'ws://' } } $UPDATE_PUBKEY = "d98c7dd5a4e08a213ba586d5857bd353051b669dc7a1d84341df31db34f6e84f" # #аудит 2026-07-22: node.exe НИЖЕ выполняет Ed25519-проверку скачанных компонентов — но проверяет и САМ # СЕБЯ, поэтому подменённый node.exe способен молча пройти всю проверку. Get-Component сверяет node.exe с # sha256 из МАНИФЕСТА, но манифест доставляется сервером и на компрометированном сервере переписывается # вместе с node (PS 5.1 не умеет проверить Ed25519-подпись манифеста нативно). Анкор — ПИН sha256 node.exe # ЗДЕСЬ: install.ps1 приходит по TLS (irm|iex), т.е. эта константа не зависит от артефакт-сервера. Заполнить # на релизе: (Get-FileHash -Algorithm SHA256 node.exe).Hash.ToLower(). Пусто = анкор выключен (как раньше). $NODE_SHA256 = "9a4eb5f1c29c6a2e93852ead46b999e284a6a5ca8bab4d4e241d587d025a52de" # 🔴 КОНТУР УСТАНОВКИ — ЯВНО. Адрес, с которого СКАЧАН скрипт, на умолчания НЕ влияет (irm|iex отдаёт текст, # скрипт не знает свой источник) — поэтому «скачал с беты и запустил» без -ApiBase ставило станцию в ПРОД # МОЛЧА (с прод-токеном отработало бы без единой жалобы, и человек был бы уверен, что обкатывает бету). # Две меры: (1) publish-release.py ШТАМПУЕТ умолчания в served-копию под свой контур — скачанное с беты # по построению ставит бету; (2) контур печатается КРУПНО в шапке и в финале — оператор видит, куда ставит. function Get-Contour($apiBase) { if ($apiBase -match '(?i)(^|[./])beta\.') { return "beta" } if ($apiBase -match '(?i)(^|[./])(api|get|sync)\.jarwits\.ru') { return "prod" } return "custom" } $CONTOUR = Get-Contour $ApiBase function Show-Contour($when) { $color = if ($CONTOUR -eq 'prod') { 'Red' } elseif ($CONTOUR -eq 'beta') { 'Yellow' } else { 'Magenta' } Write-Host "" Write-Host " ############################################################" -ForegroundColor $color Write-Host (" ## СТАВЛЮ НА: {0,-42}##" -f $CONTOUR.ToUpper()) -ForegroundColor $color Write-Host (" ## {0,-56}##" -f $ApiBase) -ForegroundColor $color Write-Host " ############################################################" -ForegroundColor $color if ($when -eq 'start' -and $CONTOUR -eq 'prod') { Info "это ПРОД. Для беты: -ApiBase https://api.beta.jarwits.ru (или скачайте install.ps1 с беты)" } } $SERVICE = "JARWITS-SA" $SA_PORT = 8765 function Step($n, $msg) { Write-Host "`n[$n] $msg" -ForegroundColor Cyan } function Info($msg) { Write-Host " $msg" -ForegroundColor Gray } function Ok($msg) { Write-Host " OK: $msg" -ForegroundColor Green } function Warn($msg) { Write-Host " ВНИМАНИЕ: $msg" -ForegroundColor Yellow } function Die($msg) { Write-Host "`nОШИБКА: $msg" -ForegroundColor Red; exit 1 } Show-Contour 'start' # «Уже установлено и работает»: прочитать локальный статус живой станции. Установлено = служба есть; # здорово = служба Running + свежий status.json (его пишет sync-worker каждый heartbeat, mtime < 90с); # на связи = cloud_connected в статусе. Путь data-dir фиксирован установщиком (%ProgramData%\JARWITS\SA). function Get-SaStatus { $r = [ordered]@{ Installed=$false; Running=$false; Fresh=$false; CloudConnected=$false; Version=$null; Configured=$false; AdoptEpkFp=$null } $svc = Get-Service $SERVICE -ErrorAction SilentlyContinue if (-not $svc) { return $r } $r.Installed = $true $r.Running = ($svc.Status -eq 'Running') # Два кандидата пути статуса: НОВАЯ раскладка (SA_DATA_DIR=%ProgramData%\JARWITS\SA) и LEGACY (SA_DATA_DIR # не задан у старых станций, доехавших до нового кода самообновлением → saDataDir()=cwd=AppDirectory= # InstallDir\sa). Берём самый свежий из существующих — иначе живая legacy-станция не детектится и молча # переустанавливается поверх (расход токена + перезапись config.json). $candidates = @( (Join-Path (Join-Path $env:ProgramData "JARWITS\SA") "status.json"), (Join-Path (Join-Path $InstallDir "sa") "status.json") ) $statusFile = $candidates | Where-Object { Test-Path $_ } | Sort-Object { (Get-Item $_).LastWriteTime } -Descending | Select-Object -First 1 if ($statusFile) { try { $ageSec = ((Get-Date) - (Get-Item $statusFile).LastWriteTime).TotalSeconds $r.Fresh = ($ageSec -lt 90) $j = Get-Content $statusFile -Raw -ErrorAction Stop | ConvertFrom-Json $r.Version = $j.version $r.CloudConnected = [bool]$j.cloud_connected $r.Configured = [bool]$j.station_configured $r.AdoptEpkFp = $j.adopt_epk_fp # отпечаток ключа усыновления (публичный) — для out-of-band сверки оператором } catch { } } return $r } # Запустить деинсталлятор станции (создаётся при установке) и выйти. function Invoke-SaUninstall { $un = Join-Path $InstallDir "uninstall.ps1" if (Test-Path $un) { Step 1 "Удаление JARWITS SA"; & powershell -NoProfile -ExecutionPolicy Bypass -File $un; exit 0 } Die "Деинсталлятор не найден ($un). Возможно, SA установлен в другой каталог — укажите -InstallDir." } # Streaming download with a live one-line progress (МБ скачано / всего, %, скорость). We keep # $ProgressPreference=SilentlyContinue (Invoke-WebRequest's own bar is very slow on big files), # so render our own from a raw HttpWebRequest stream. TLS1.2 is already forced above. function Download-WithProgress([string]$url, [string]$outFile, [long]$expected = 0) { # Устойчивая загрузка для медленных/флаки каналов буровых: RESUME по HTTP Range (сервер отдаёт # Accept-Ranges: bytes) + повторы. Обрыв/таймаут/ранний EOF → докачиваем с места, а не с нуля. $attempts = 8 for ($attempt = 1; $attempt -le $attempts; $attempt++) { [long]$have = 0 if (Test-Path $outFile) { try { $have = (Get-Item $outFile).Length } catch { $have = 0 } } if ($expected -gt 0 -and $have -ge $expected) { return } # уже полностью скачан try { $req = [System.Net.HttpWebRequest]::Create($url) $req.UserAgent = "JARWITS-Installer" $req.Timeout = 60000 $req.ReadWriteTimeout = 120000 # 2 мин на чтение — на флаки-линке падать быстрее и докачивать if ($have -gt 0) { $req.AddRange($have) } # Range: bytes=$have- (докачка с байта $have) $resp = $req.GetResponse() $append = $false try { $partial = ($resp -is [System.Net.HttpWebResponse]) -and ($resp.StatusCode -eq [System.Net.HttpStatusCode]::PartialContent) if ($have -gt 0 -and $partial) { $append = $true } else { $have = 0 } # сервер проигнорил Range → с нуля $len = [long]$resp.ContentLength $total = if ($expected -gt 0) { $expected } elseif ($len -gt 0) { $have + $len } else { 0 } $in = $resp.GetResponseStream() $out = if ($append) { [System.IO.File]::Open($outFile, [System.IO.FileMode]::Append) } else { [System.IO.File]::Create($outFile) } try { $buf = New-Object byte[] 1048576 # 1 МБ [long]$read = $have; $last = -1000 $sw = [System.Diagnostics.Stopwatch]::StartNew() while (($n = $in.Read($buf, 0, $buf.Length)) -gt 0) { $out.Write($buf, 0, $n); $read += $n if (($sw.ElapsedMilliseconds - $last) -ge 250) { $last = $sw.ElapsedMilliseconds $spd = (($read - $have) / 1MB) / [math]::Max($sw.Elapsed.TotalSeconds, 0.001) if ($total -gt 0) { $pct = [int]($read * 100 / $total) Write-Host ("`r {0,7:N1} / {1,7:N1} МБ {2,3}% {3,5:N1} МБ/с " -f ($read/1MB), ($total/1MB), $pct, $spd) -NoNewline } else { Write-Host ("`r {0,7:N1} МБ {1,5:N1} МБ/с " -f ($read/1MB), $spd) -NoNewline } } } } finally { $out.Close() } } finally { $resp.Close() } [long]$got = (Get-Item $outFile).Length if ($expected -le 0 -or $got -ge $expected) { Write-Host ("`r {0:N1} МБ скачано " -f ($got/1MB)) return } Write-Host ("`r неполно ({0:N1}/{1:N1} МБ) — докачиваю (попытка {2}/{3})… " -f ($got/1MB), ($expected/1MB), ($attempt+1), $attempts) -ForegroundColor Yellow } catch { Write-Host ("`r обрыв — докачиваю (попытка {0}/{1})… {2} " -f ($attempt+1), $attempts, $_.Exception.Message) -ForegroundColor Yellow Start-Sleep -Seconds ([math]::Min(3 * $attempt, 20)) } } throw "Не удалось скачать после $attempts попыток (нестабильное соединение): $url" } Write-Host "==== JARWITS SA installer (v$Version) ====" -ForegroundColor White # ---------------------------------------------------------------- 1. preflight Step 1 "Проверка окружения" $admin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) if (-not $admin) { Die "Запустите PowerShell от имени администратора." } if ([Environment]::Is64BitOperatingSystem -ne $true) { Die "Требуется 64-битная Windows." } try { $drive = (Split-Path $InstallDir -Qualifier) if (-not $drive) { throw "нет буквы диска" } $free = (Get-PSDrive $drive.TrimEnd(':')).Free } catch { Die "Некорректный -InstallDir '$InstallDir' — укажите локальный путь с буквой диска (например C:\JARWITS)." } if ($free -lt 2GB) { Die "Недостаточно места на $drive (нужно >= 2 ГБ, свободно $([math]::Round($free/1GB,1)) ГБ)." } Ok "администратор, x64, свободно $([math]::Round($free/1GB,1)) ГБ" # ---------------------------------------------------------------- 1b. «уже установлено и работает?» # -Uninstall: удалить и выйти (ДО любых сетевых действий/расхода токена). if ($Uninstall) { Invoke-SaUninstall } # -Status: показать состояние станции (в т.ч. отпечаток ключа усыновления) и выйти. Дубль WSA-панели для # дормантной станции без веб-доступа: оператор читает отпечаток epk с экрана и сверяет с облаком. if ($Status) { $sa = Get-SaStatus Write-Host "" Write-Host "JARWITS SA — состояние станции:" -ForegroundColor Cyan Write-Host (" установлен: {0} · служба Running: {1} · статус свежий: {2}" -f $sa.Installed, $sa.Running, $sa.Fresh) Write-Host (" версия: {0}" -f (if ($sa.Version) { $sa.Version } else { "неизвестна" })) Write-Host (" объект задан: {0} · на связи с облаком: {1}" -f $sa.Configured, $sa.CloudConnected) if ($sa.AdoptEpkFp) { Write-Host (" Отпечаток ключа усыновления: {0}" -f $sa.AdoptEpkFp) -ForegroundColor Yellow Write-Host " ^ сверьте этот отпечаток с облаком ПЕРЕД усыновлением (защита от подмены ключа)." -ForegroundColor Gray } else { Write-Host " Отпечаток ключа усыновления: недоступен (служба не запускалась / нет status.json)" -ForegroundColor Gray } exit 0 } # Живая рабочая станция + НЕ форсим переустановку → не затирать молча: показать статус и дать выбор. # Это защищает настроенную станцию от случайного повторного `irm|iex` (перекликается с ca-template-exe-is-installer). if (-not $Reinstall) { $sa = Get-SaStatus if ($sa.Installed -and $sa.Running -and $sa.Fresh) { $verStr = if ($sa.Version) { "версия $($sa.Version)" } else { "версия неизвестна" } $cloudStr = if ($sa.CloudConnected) { "на связи с облаком" } else { "БЕЗ связи с облаком (проверьте интернет)" } Write-Host "" Write-Host "JARWITS SA уже установлен и работает ($verStr, $cloudStr)." -ForegroundColor Green Write-Host " Служба $SERVICE запущена. Панель: https://localhost:$SA_PORT/admin" -ForegroundColor Gray if ($sa.AdoptEpkFp) { Write-Host " Отпечаток ключа усыновления: $($sa.AdoptEpkFp)" -ForegroundColor Gray } $interactive = [Environment]::UserInteractive -and -not [Console]::IsInputRedirected if ($interactive) { Write-Host "" Write-Host " 1 - Выйти (ничего не менять)" Write-Host " 2 - Удалить JARWITS SA с этого ПК" Write-Host " 3 - Переустановить (заменить текущую установку)" $ch = (Read-Host "Выберите 1/2/3").Trim() switch ($ch) { '2' { Invoke-SaUninstall } '3' { Write-Host " Переустановка поверх…" -ForegroundColor Yellow } # провалиться в установку ниже default { Write-Host " Выход — рабочая установка не тронута." -ForegroundColor Gray; exit 0 } } } else { Write-Host "" Write-Host " Повторный запуск НЕ тронул рабочую установку (безопасный режим headless)." -ForegroundColor Gray Write-Host " Переустановить: добавьте -Reinstall. Удалить: добавьте -Uninstall." -ForegroundColor Gray exit 0 } } } $work = Join-Path $env:TEMP ("jarwits-install-" + [guid]::NewGuid().ToString("N").Substring(0,8)) New-Item -ItemType Directory -Path $work -Force | Out-Null # ---------------------------------------------------------------- 2. token (install authorisation only) # Токен установки НЕ привязан к объекту — он лишь разрешает установку ПО на новый ПК (защита от # несанкционированной установки, ограничен сроком жизни). Объект (заказчик/месторождение/куст/скважина) # указывается в WSA ПОСЛЕ установки; тогда же станция регистрируется в облаке (гейт v5.1.2). $station = @{ customer_name = $CustomerName; field_name = $FieldName; name = $PadName; well = $WellName } $ingestToken = "" if (-not $OfflineMode) { Step 2 "Проверка токена установки" if (-not $StationToken) { Die "Укажите -StationToken JARW-XXXX-XXXX-XXXX (получите в админке -> Токены установки)." } try { $null = Invoke-RestMethod -Method Post -Uri "$ApiBase/api/install/validate" -ContentType "application/json" -Body (@{ token = $StationToken } | ConvertTo-Json) } catch { Die "Токен недействителен, использован или истёк (validate)." } Ok "токен действителен — установка разрешена" } else { Step 2 "Офлайн-режим — объект из параметров (можно уточнить в WSA)" Ok "объект: $CustomerName / $PadName" } # ---------------------------------------------------------------- 3. компоненты (докачиваю только недостающее, #4) Step 3 "Получение компонентов" if ($OfflineMode) { if (-not (Test-Path $BundlePath)) { Die "-BundlePath не найден: $BundlePath" } $man = Get-Content (Join-Path $BundlePath "install-manifest.json") -Raw | ConvertFrom-Json } else { try { $man = Invoke-RestMethod -Uri "$GetBase/updates/files/install-manifest.json" } catch { Die "Сервер обновлений недоступен ($GetBase). Проверьте интернет-соединение и повторите." } } if (-not $man.components) { Die "манифест старого формата (без components) — обновите архив на сервере." } $comps = @{}; foreach ($c in $man.components) { $comps[$c.name] = $c } function Sha256File($p) { (Get-FileHash -Algorithm SHA256 $p).Hash.ToLower() } # Reuse a local copy whose sha256 matches the manifest; otherwise fetch it (offline: from the bundle). function Get-Component($name, $reuseFrom) { $c = $comps[$name]; if (-not $c) { Die "в манифесте нет компонента '$name'" } $dst = Join-Path $work $c.file # $reuseFrom may be one path or a list — reuse the FIRST candidate whose sha256 matches the manifest # (e.g. the prior InstallDir copy OR the persistent %ProgramData% cache that survives a full uninstall). foreach ($cand in @($reuseFrom)) { if ($cand -and (Test-Path $cand) -and ((Sha256File $cand) -eq $c.sha256.ToLower())) { Copy-Item $cand $dst -Force Info ("{0}: уже есть на ПК — не качаю ({1:N1} МБ сэкономлено)" -f $c.file, ($c.size / 1MB)) return $dst } } if ($OfflineMode) { Copy-Item (Join-Path $BundlePath $c.file) $dst -Force if ((Sha256File $dst) -ne $c.sha256.ToLower()) { Remove-Item $dst -Force -ErrorAction SilentlyContinue; Die "Контрольная сумма $($c.file) не сошлась (bundle повреждён)." } return $dst } # RESUME на слабых линках буровой: качаем в ПЕРСИСТЕНТНЫЙ .part в %ProgramData%\cache. Download-WithProgress # докачивает по HTTP Range при обрыве соединения (внутри одного прогона), а персистентный .part переживает и # ПОЛНЫЙ перезапуск установщика (потеря питания) — следующий запуск докачивает с offset, а не с нуля. $part = Join-Path $cacheDir ($c.file + ".part") $resumeFrom = if (Test-Path $part) { (Get-Item $part).Length } else { 0 } if ($resumeFrom -gt 0 -and $resumeFrom -lt $c.size) { Info ("скачивание {0} ({1:N1} МБ) — ДОКАЧКА с {2:N1} МБ:" -f $c.file, ($c.size / 1MB), ($resumeFrom / 1MB)) } else { Info ("скачивание {0} ({1:N1} МБ):" -f $c.file, ($c.size / 1MB)) } Download-WithProgress "$GetBase/updates/files/$($c.file)" $part ([long]$c.size) # sha ПОЛНОГО файла (для node.exe.gz — сжатого, как подписано на сервере). Несовпадение → удалить .part # (битые байты в середине — докачивать нечего), чистая перезагрузка при повторном запуске. if ((Sha256File $part) -ne $c.sha256.ToLower()) { Remove-Item $part -Force -ErrorAction SilentlyContinue Die "Контрольная сумма $($c.file) не сошлась (повреждение при передаче). Запустите установку снова." } Move-Item $part $dst -Force # завершено + sha-проверено → в рабочий каталог (следующий запуск не докачивает) return $dst } function Test-VCRedist { foreach ($k in @("HKLM:\SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64", "HKLM:\SOFTWARE\WOW6432Node\Microsoft\VisualStudio\14.0\VC\Runtimes\x64")) { try { if ((Get-ItemProperty $k -ErrorAction Stop).Installed -eq 1) { return $true } } catch { } } return $false } # Persistent cache for the big reusable binaries (node/nssm/vc) OUTSIDE InstallDir. A full uninstall wipes # InstallDir, so without this a reinstall re-downloads ~90 МБ node every time; the cache survives and is # reused across install/uninstall cycles on the same PC. $cacheDir = Join-Path $env:ProgramData "JARWITS\cache" New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null # STAGED core-first: качаем ТОЛЬКО CORE-компоненты (node/nssm/sa/vcredist). node/vc могут быть GZIP # (gzip=true в манифесте, экономия трафика: node 88→33М, vc 26→12М) — sha/подпись у СЖАТОГО файла (то, что на # сервере); после скачивания штатно распаковываем GZipStream'ом в .exe (node нужен для verify+старта службы). # piper/dotnet/pjsip/ca-assets/sa-deps (stage=deferred) НЕ трогаем — их докачает сама служба SA после старта. function Expand-Gzip($src, $dst) { $in = [IO.File]::OpenRead($src); $out = [IO.File]::Create($dst) $gz = New-Object IO.Compression.GZipStream($in, [IO.Compression.CompressionMode]::Decompress) try { $gz.CopyTo($out) } finally { $gz.Dispose(); $out.Dispose(); $in.Dispose() } } $obtained = @{} # node: reuse/скачать (кэш хранит .gz если gzip; иначе .exe — sha манифеста = sha того, что на сервере). $obtained["node"] = Get-Component "node" @((Join-Path $cacheDir $comps["node"].file), (Join-Path $InstallDir "node.exe")) $obtained["nssm"] = Get-Component "nssm" @((Join-Path $InstallDir "nssm.exe"), (Join-Path $cacheDir "nssm.exe")) $obtained["sa"] = Get-Component "sa" $null # node.exe для verify+старта: распаковать из gzip если помечен, иначе взять как есть. if ($comps["node"].gzip) { $nodeExe = Join-Path $work "node.exe"; Expand-Gzip $obtained["node"] $nodeExe; Info ("node.exe распакован из gzip ({0:N1}М -> {1:N1}М)" -f ($comps["node"].size / 1MB), ((Get-Item $nodeExe).Length / 1MB)) } else { $nodeExe = $obtained["node"] } # VC++ redist — пропустить если уже установлен (реестр); иначе скачать (+ распаковать из gzip если помечен). $vcInstalled = Test-VCRedist $vcExe = $null if ($vcInstalled) { Info ("Visual C++ Redistributable уже установлен — не качаю ({0:N1} МБ)" -f ($comps['vcredist'].size / 1MB)) } else { $obtained["vcredist"] = Get-Component "vcredist" (Join-Path $cacheDir $comps["vcredist"].file) if ($comps["vcredist"].gzip) { $vcExe = Join-Path $work "vc_redist.x64.exe"; Expand-Gzip $obtained["vcredist"] $vcExe } else { $vcExe = $obtained["vcredist"] } } # Кэш обновляем тем, что скачали/переиспользовали (СЖАТЫЙ .gz — как на сервере), чтобы реинстал пропустил докачку. try { Copy-Item $obtained["node"] (Join-Path $cacheDir $comps["node"].file) -Force Copy-Item $obtained["nssm"] (Join-Path $cacheDir "nssm.exe") -Force if ($obtained.ContainsKey("vcredist")) { Copy-Item $obtained["vcredist"] (Join-Path $cacheDir $comps["vcredist"].file) -Force } } catch { } Ok "компоненты получены (CORE)" # ---------------------------------------------------------------- 4. verify (sha256 + Ed25519 per component) Step 4 "Проверка целостности и подписи" $verifyJs = Join-Path $work "verify.js" @' const c = require("crypto"), fs = require("fs"); const items = JSON.parse(process.env.JW_ITEMS); const spki = Buffer.concat([Buffer.from("302a300506032b6570032100", "hex"), Buffer.from(process.env.JW_PUB, "hex")]); const pub = c.createPublicKey({ key: spki, format: "der", type: "spki" }); for (const it of items) { const b = fs.readFileSync(it.path); const s = c.createHash("sha256").update(b).digest("hex"); if (s !== it.sha256) { console.error("sha256 mismatch: " + it.file); process.exit(2); } const canon = "install|" + process.env.JW_VER + "|" + it.file + "|" + it.sha256; if (!c.verify(null, Buffer.from(canon, "utf8"), pub, Buffer.from(it.signature, "hex"))) { console.error("bad signature: " + it.file); process.exit(3); } } process.exit(0); '@ | Set-Content -Path $verifyJs -Encoding UTF8 $items = @() foreach ($n in $obtained.Keys) { $c = $comps[$n]; $items += @{ file = $c.file; sha256 = $c.sha256.ToLower(); signature = $c.signature; path = $obtained[$n] } } $env:JW_VER = $man.version; $env:JW_PUB = $UPDATE_PUBKEY $env:JW_ITEMS = (ConvertTo-Json @($items) -Compress -Depth 4) # Анкор верификатора (#аудит): node.exe должен совпасть с ПИНОМ установщика ДО того, как мы доверим ему # проверку подписей. Пусто → анкор выключен (совместимо с прежним поведением; активировать пином на релизе). if ($NODE_SHA256) { $nodeSha = Sha256File $nodeExe if ($nodeSha -ne $NODE_SHA256.ToLower()) { Die "node.exe sha256 ($nodeSha) не совпал с пином установщика — верификатор не доверенный." } Ok "node.exe совпал с пином установщика" } else { Info "node.exe НЕ запинен (NODE_SHA256 пуст) — Ed25519-проверку выполняет непроверенный node; задать пин на релизе (#аудит #6/канал обновлений)" } & $nodeExe $verifyJs if ($LASTEXITCODE -ne 0) { Die "Подпись Ed25519 недействительна (код $LASTEXITCODE) — компонент не доверенный." } Ok "SHA-256 + подписи Ed25519 верны" # ---------------------------------------------------------------- 5. VC++ redist (silent, если качали) Step 5 "Visual C++ Redistributable" if ($vcInstalled) { Ok "уже установлен — пропущено" } else { $p = Start-Process $vcExe -ArgumentList "/quiet","/norestart" -Wait -PassThru if ($p.ExitCode -in @(0,1638,3010)) { Ok "установлен (код $($p.ExitCode))" } else { Info "код $($p.ExitCode) — продолжаю" } } # ---------------------------------------------------------------- helpers: замена ЗАНЯТЫХ бинарей # 🔴 install.ps1 падал на шаге 6 с IOException на nssm.exe: файл держит НЕЗАВИСИМАЯ служба JarwitsWstunnel — # она хостится ЭТИМ ЖЕ nssm.exe (sa/src/vpn/wstunnel-service.js#nssmPath = каталог node.exe → nssm.exe рядом). # Шаг 6 останавливал только JARWITS-SA, поэтому на ЧИСТОЙ машине бага не видно, а на ПЕРЕУСТАНОВКЕ — стопор. $script:WSTUNNEL_SVC = "JarwitsWstunnel" # 🔴 ЛОВУШКА УДАЛЁННОЙ ПЕРЕУСТАНОВКИ: SA сужает SSH до VPN-подсети ("JARWITS SSH (VPN only)"), а wstunnel — # несущий транспорт этого самого VPN. Остановив туннель удалённо, оператор рубит сук, на котором сидит: # доступа к станции больше нет ВООБЩЕ. Поэтому ПЕРЕД остановкой туннеля открываем SSH из ЛОКАЛЬНОЙ подсети # и ПРОВЕРЯЕМ, что правило реально создано и включено. Решаем в коде, а не инструкцией в рунбуке. function Enable-LanSshFallback { $ruleName = "JARWITS SSH (LAN fallback)" try { $existing = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue if ($existing) { Enable-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue; Ok "запасной SSH-доступ из LAN уже есть"; return $true } # локальные IPv4-подсети (/24), кроме VPN 10.75.* и loopback/APIPA $nets = @(Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -notmatch '^(127\.|169\.254\.|10\.75\.)' } | ForEach-Object { ($_.IPAddress -replace '\.\d+$', '.0') + "/24" } | Sort-Object -Unique) if (-not $nets) { Info "локальных подсетей не найдено — запасное правило не создаю"; return $false } $port = 22 try { $r = Get-NetFirewallRule -DisplayName "JARWITS SSH (VPN only)" -ErrorAction SilentlyContinue if ($r) { $port = ($r | Get-NetFirewallPortFilter).LocalPort } } catch {} New-NetFirewallRule -DisplayName $ruleName -Direction Inbound -Action Allow -Protocol TCP ` -LocalPort $port -RemoteAddress $nets -Profile Any -ErrorAction Stop | Out-Null # ПРОВЕРЯЕМ факт (создать мало — надо убедиться, что правило есть и включено), как это делал оператор вручную $chk = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue if ($chk -and $chk.Enabled -eq 'True') { Ok "запасной SSH-доступ из LAN открыт ($($nets -join ', '), порт $port)"; return $true } Info "ВНИМАНИЕ: запасное правило SSH не подтвердилось — удалённый доступ может пропасть вместе с туннелем" return $false } catch { Info "ВНИМАНИЕ: не удалось открыть запасной SSH из LAN ($($_.Exception.Message))"; return $false } } # Заменить бинарь, который может быть ЗАНЯТ службой. Ничего не трогаем, если содержимое уже совпадает — # это снимает проблему без остановки чего-либо в самом частом случае (переустановка той же версии). function Copy-HostedBinary($src, $dst, $label) { if (Test-Path $dst) { try { if ((Get-FileHash $src -Algorithm SHA256).Hash -eq (Get-FileHash $dst -Algorithm SHA256).Hash) { Ok "$label идентичен — не трогаю (файл может быть занят службой)"; return } } catch {} } $stopped = $false $svc = Get-Service $script:WSTUNNEL_SVC -ErrorAction SilentlyContinue if ($svc -and $svc.Status -eq 'Running') { Info "$label занят службой $($script:WSTUNNEL_SVC) — останавливаю на время замены" Enable-LanSshFallback | Out-Null # СНАЧАЛА запасной доступ, ПОТОМ рубим туннель Stop-Service $script:WSTUNNEL_SVC -Force -ErrorAction SilentlyContinue Start-Sleep 2; $stopped = $true } try { Copy-Item $src $dst -Force -ErrorAction Stop Ok "$label обновлён" } catch { Die "не удалось заменить $label ($dst): $($_.Exception.Message). Файл держит другой процесс — остановите службы JARWITS и повторите." } finally { if ($stopped) { Start-Service $script:WSTUNNEL_SVC -ErrorAction SilentlyContinue; Info "служба $($script:WSTUNNEL_SVC) поднята обратно" } } } # ---------------------------------------------------------------- 6. stop old service + extract Step 6 "Распаковка в $InstallDir" if (Get-Service $SERVICE -ErrorAction SilentlyContinue) { Info "остановка существующей службы"; Stop-Service $SERVICE -Force -ErrorAction SilentlyContinue; Start-Sleep 2 } New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null # node.exe/nssm.exe могут быть ЗАНЯТЫ (nssm хостит независимую службу wstunnel; node — переживший процесс). Copy-HostedBinary $nodeExe (Join-Path $InstallDir "node.exe") "node.exe" Copy-HostedBinary $obtained["nssm"] (Join-Path $InstallDir "nssm.exe") "nssm.exe" # the SA tree under \sa (config/keys/state are NOT in the archive) & tar.exe -xzf $obtained["sa"] -C $InstallDir "./sa" if (-not (Test-Path (Join-Path $InstallDir "sa\src\master.js"))) { Die "SA не распакован (нет sa\src\master.js)." } # STAGED: piper/pjsip/assets-ca/dotnet/sa-deps НЕ распаковываем здесь — их положит фоновый докачиватель # (hot-drop) после старта службы. Каталог assets\ca создаём заранее (config/иконки в CORE-архиве уже есть). New-Item -ItemType Directory -Path (Join-Path $InstallDir "sa\assets\ca") -Force | Out-Null Ok "распаковано (CORE — движок готов к старту)" # ---------------------------------------------------------------- 7. config + credential files Step 7 "Настройка станции" $saDir = Join-Path $InstallDir "sa" $wsaPass = -join ((48..57) + (97..122) + (65..90) | Get-Random -Count 14 | ForEach-Object { [char]$_ }) $config = [ordered]@{ superadmin = @{ login = "admin"; password = $wsaPass } web = @{ port = $SA_PORT } wits = @{ mode = "client"; host = ""; port = 0; reconnect_sec = 5 } # Стандартные WITS-коды по умолчанию (оператор при необходимости поменяет в WSA -> Привязка WITS-кодов). wits_codes = [ordered]@{ bit_depth = "0110"; rop = "0113"; flow_in = "0114"; flow_out = "0115"; rpm = "0116" gas_total = "0120"; co2 = "0121"; pit_volume = "0130"; hook_load = "0140" standpipe_pressure = "0141"; torque = "0142"; mud_density_in = "0150"; mud_density_out = "0151" } station = @{ customer_name = $station.customer_name; field_name = $station.field_name; name = $station.name; well = $station.well } central = [ordered]@{ enabled = $true; url = $ApiBase; sync_url = $SyncBase; device_uid = ""; ingest_token = $ingestToken update_manifest_url = "$GetBase/updates/v5-manifest.json"; update_pubkey = $UPDATE_PUBKEY auto_update_sa = $true; heartbeat_sec = 15; command_poll_sec = 5; config_push_sec = 5; update_poll_sec = 60 } } # fill ingest_token from consume (online) — see step 8; write config after that. $script:configObj = $config Ok "учётные данные подготовлены (объект укажете в WSA)" # ---------------------------------------------------------------- 8. consume token (mark used) + write config # 🔴 CONSUME ИДЕМПОТЕНТЕН. Токен установки ОДНОРАЗОВЫЙ (сервер: UPDATE ... WHERE used_at IS NULL, повтор -> # 409 invalid_or_used_token). Раньше он тратился ЗДЕСЬ, а служба регистрировалась ШАГОМ НИЖЕ: любой отказ # между ними (nssm, брандмауэр, обрыв RDP, Ctrl+C) оставлял станцию с распакованным деревом, записанным # конфигом и НЕЗАРЕГИСТРИРОВАННОЙ службой, причём старая уже остановлена. Повторный запуск с тем же токеном # умирал здесь же — выхода на месте не было, нужен был новый токен из облака, до которого станции ещё нет # связи. Теперь ранее полученный ingest_token переиспользуется, а 409 отличается от прочих отказов. $ingestFile = Join-Path $saDir "ingest_token.txt" $existingIngest = "" if (Test-Path $ingestFile) { try { $existingIngest = (Get-Content $ingestFile -Raw -ErrorAction Stop).Trim() } catch {} } if (-not $OfflineMode) { if ($existingIngest) { Step 8 "Регистрация установки (ingest_token уже получен ранее)" $ingestToken = $existingIngest Ok "переиспользую ingest_token с прошлой попытки — токен установки повторно не тратится" } else { Step 8 "Регистрация установки (расход токена)" try { $hostname = $env:COMPUTERNAME $cons = Invoke-RestMethod -Method Post -Uri "$ApiBase/api/install/consume" -ContentType "application/json" -Body (@{ token = $StationToken; host = $hostname } | ConvertTo-Json) $ingestToken = $cons.ingest_token # Пишем НЕМЕДЛЕННО: между «сервер пометил токен использованным» и «мы сохранили ответ» не должно быть # ни одного шага, иначе отказ в этом окне снова оставит станцию без пути повтора. New-Item -ItemType Directory -Path $saDir -Force | Out-Null Set-Content -Path $ingestFile -Value $ingestToken -Encoding ASCII Ok "токен помечен использованным" } catch { $code = $null try { $code = [int]$_.Exception.Response.StatusCode } catch {} if ($code -eq 409) { # Скобки ОБЯЗАТЕЛЬНЫ: `Die "a" + "b"` — это ПЯТЬ аргументов команды, до $msg доходит только "a" # (проверено исполнением). Без них подсказки оператору молча теряются. Die ("Токен установки уже использован (сервер ответил 409), а локального ingest_token на этой станции нет.`n" + " Значит токен потрачен на ДРУГОЙ машине либо каталог станции удалён вместе с ingest_token.txt.`n" + " Исправление: выпустите НОВЫЙ токен установки в облаке и повторите.") } Die "Не удалось зарегистрировать установку (consume): $($_.Exception.Message)" } } } $script:configObj.central.ingest_token = $ingestToken $cfgJson = $script:configObj | ConvertTo-Json -Depth 6 # UTF-8 БЕЗ BOM: `Set-Content -Encoding UTF8` в PS 5.1 добавляет BOM, из-за чего JSON.parse в SA # падает -> loadConfig откатывается на config.example.json (пароль-заглушка) и вход в WSA отклоняется # (superadmin_not_configured). WriteAllText c UTF8Encoding($false) — как пишется .service.json ниже. [IO.File]::WriteAllText((Join-Path $saDir "config.json"), $cfgJson, (New-Object Text.UTF8Encoding $false)) Set-Content -Path $ingestFile -Value $ingestToken -Encoding ASCII # идемпотентно: при первом получении файл записан выше # ---------------------------------------------------------------- 9. service (nssm) + firewall Step 9 "Регистрация службы $SERVICE (nssm) и firewall" $nssm = Join-Path $InstallDir "nssm.exe" $node = Join-Path $InstallDir "node.exe" # Ф1a: persistent data dir (TLS identity server_auto.*, per-station command key keys/, and sa.sqlite3) # OUTSIDE InstallDir, so a reinstall (which removes InstallDir) does NOT change the SA's TLS fingerprint / # command key and therefore does NOT orphan installed CAs. Locked to SYSTEM+Administrators — it holds # private keys. The service is told about it via SA_DATA_DIR; master.js migrates any identity that still # lives in the old code dir (over-install upgrade) into here on first start. $dataDir = Join-Path $env:ProgramData "JARWITS\SA" New-Item -ItemType Directory -Path $dataDir -Force | Out-Null try { & icacls "$dataDir" /inheritance:r /grant:r "SYSTEM:(OI)(CI)F" "*S-1-5-32-544:(OI)(CI)F" 2>$null | Out-Null } catch {} # Reinstall-safe: strict errors OFF for the nssm calls below. On a REINSTALL the service already exists, # so `nssm install` prints "service already exists" to stderr — which under -EA Stop aborts the whole # install (field bug). These calls are best-effort; the real service status is read via Get-Service below. # The already-installed service keeps its Application (from `nssm install`), so we FORCE the entry point # below via explicit set calls — needed to migrate an old station from src\master.js to the eternal launcher. $eapSvc = $ErrorActionPreference; $ErrorActionPreference = 'SilentlyContinue' & $nssm install $SERVICE $node "updater\launcher.mjs" 2>$null | Out-Null & $nssm set $SERVICE AppDirectory $saDir | Out-Null # Точка входа = ВЕЧНЫЙ лаунчер (updater\launcher.mjs), он супервизирует src\master.js и владеет свапом при # обновлении. Явные set — потому что `nssm install` на существующей службе не меняет Application: так # переустановка мигрирует старую станцию (nssm->master) на новую схему (nssm->launcher->master). & $nssm set $SERVICE Application $node | Out-Null & $nssm set $SERVICE AppParameters "updater\launcher.mjs" | Out-Null & $nssm set $SERVICE AppEnvironmentExtra "SA_DATA_DIR=$dataDir" | Out-Null & $nssm set $SERVICE DisplayName "JARWITS SA" | Out-Null & $nssm set $SERVICE Start SERVICE_AUTO_START | Out-Null & $nssm set $SERVICE AppStdout (Join-Path $saDir "logs\service.out.log") | Out-Null & $nssm set $SERVICE AppStderr (Join-Path $saDir "logs\service.err.log") | Out-Null & $nssm set $SERVICE AppRotateFiles 1 | Out-Null # Self-update survival + sane restart policy — works on ANY nssm version (even those lacking # AppKillProcessTree). The self-update helper is spawned DETACHED by master; the OLD approach called # `nssm stop`, which on such nssm builds tree-kills that helper mid-swap (station stuck stopped). Instead: # AppExit 0 Exit — a CLEAN master exit(0) (a stop / graceful give-up / the self-update swap) means # "stay stopped": nssm does NOT auto-restart the old code and issues no tree-kill. # AppExit Default Restart + AppRestartDelay — a real CRASH (non-zero) still auto-restarts, but throttled # so a persistently-crashing build can't hot-loop the station. # AppKillProcessTree 0 — belt-and-suspenders for newer nssm (older builds reject it → swallowed). & $nssm set $SERVICE AppExit Default Restart | Out-Null & $nssm set $SERVICE AppExit 0 Exit | Out-Null & $nssm set $SERVICE AppRestartDelay 15000 | Out-Null try { & $nssm set $SERVICE AppKillProcessTree 0 2>$null | Out-Null } catch {} # Marker read by master.js on self-update: it sets `AppExit 0 Exit` then exits, and the detached helper # swaps files + `nssm start`s the new code (no `nssm stop`, so no tree-kill on any nssm build). $svcMarker = @{ manager = "nssm"; service = $SERVICE; nssm = $nssm } | ConvertTo-Json -Compress [IO.File]::WriteAllText((Join-Path $saDir ".service.json"), $svcMarker, (New-Object Text.UTF8Encoding $false)) if (-not (Get-NetFirewallRule -DisplayName "JARWITS SA $SA_PORT" -ErrorAction SilentlyContinue)) { New-NetFirewallRule -DisplayName "JARWITS SA $SA_PORT" -Direction Inbound -LocalPort $SA_PORT -Protocol TCP -Action Allow | Out-Null } # Best-effort start: nssm may print a benign "SERVICE_START_PENDING" to stderr — the real status # is read via Get-Service below, so swallow nssm's chatter instead of scaring the field install. try { & $nssm start $SERVICE 2>$null | Out-Null } catch {} $ErrorActionPreference = $eapSvc # restore strict mode after the best-effort nssm/service block # 🔴 СТАТУС ПРОВЕРЯЕТСЯ, А НЕ ПЕЧАТАЕТСЯ. Раньше здесь стояло Ok "служба: $($svc.Status) ..." — зелёная # строка с префиксом OK: печаталась ДАЖЕ при Stopped (а при $svc = $null вырождалась в «служба: — движок # работает»), и следом шло зелёное «Установка завершена». Монтажник уходил с буровой, станция мёртвая. # Тот же приём, что у брата install-cca.ps1: подождать, перечитать, назвать причины, упасть. $svc = $null for ($i = 0; $i -lt 10; $i++) { # служба может быть в StartPending — даём ~10с Start-Sleep 1 $svc = Get-Service $SERVICE -ErrorAction SilentlyContinue if ($svc -and $svc.Status -eq 'Running') { break } } if (-not $svc) { Die "Служба $SERVICE НЕ зарегистрирована (nssm install не отработал). Повторите установку с -Reinstall." } if ($svc.Status -ne 'Running') { Warn "Служба $SERVICE зарегистрирована, но статус «$($svc.Status)», а не Running." Warn " Причины: занят порт $SA_PORT, антивирус блокирует node.exe, падение master.js на старте." Warn " Логи: $InstallDir\sa\logs ; ручной старт: nssm start $SERVICE" Die "Установка НЕ подтверждена (служба не запустилась). Повтор безопасен: ingest_token сохранён локально и повторно не тратится." } Ok "служба: Running — движок работает на CORE" # ---------------------------------------------------------------- 9b. SSH-доступ администратора ЧЕРЕЗ VPN (key-only) # Идемпотентный setup: скрытая admin-учётка gti + OpenSSH server + НАШ публичный ключ в # administrators_authorized_keys + порт 22 ТОЛЬКО из 10.75.0.0/24 (VPN). Скрипт едет в SA-архиве # (sa\Setup-JarwitsSsh.ps1); ТОТ ЖЕ скрипт SA-агент гоняет идемпотентно на старте — путь для существующих станций. $sshSetup = Join-Path $saDir "Setup-JarwitsSsh.ps1" if (Test-Path $sshSetup) { Step 9 "SSH-доступ администратора через VPN (gti, только по ключу)" try { & powershell -NoProfile -ExecutionPolicy Bypass -File $sshSetup; Ok "SSH настроен (порт 22 только из VPN)" } catch { Info "SSH-setup не завершился: $($_.Exception.Message) (не критично для установки SA)" } } # STAGED core-first: на этом install.ps1 ЗАВЕРШАЕТ работу с компонентами. Тяжёлые программы раскатки # (piper/pjsip/assets-ca/dotnet/sa-deps, stage=deferred в манифесте) докачивает САМА СЛУЖБА SA на работающей # станции (переживает закрытие меш-сессии, ретраит, hot-drop БЕЗ рестарта). Манифест schema 3 (file/url/sha256/ # signature/dest по каждому deferred) — самодостаточный источник для SA-фетчера. # ---------------------------------------------------------------- 10. ярлыки (Пуск + рабочий стол) + деинсталлятор Step 10 "Ярлыки и деинсталлятор" $panelUrl = "https://localhost:$SA_PORT/admin" # Иконка ярлыков SA — полный логотип JARWITS с бейджем «SA» (векторный, sa.ico едет в архиве). Отличается # от ярлыков LCA/CCA (у тех бейдж LCA/CCA), чтобы на одной машине станцию и клиентов не путать. Фолбэк — # общий jarwits.ico. Ромб/GDI-генерация убраны (2026-07-14, единый векторный набор иконок). $saIco = Join-Path $saDir "public\admin\icons\sa.ico" $icoPath = Join-Path $saDir "public\admin\icons\jarwits.ico" $brandIco = if (Test-Path $saIco) { $saIco } else { $icoPath } # self-elevating .cmd helpers the shortcuts point at (nssm start/stop + удаление требуют прав админа) $startCmd = Join-Path $InstallDir "service-start.cmd" $stopCmd = Join-Path $InstallDir "service-stop.cmd" $unCmd = Join-Path $InstallDir "uninstall.cmd" $unPs1 = Join-Path $InstallDir "uninstall.ps1" $elevate = 'net session >nul 2>&1 || (powershell -NoProfile -Command "Start-Process -FilePath ''%~f0'' -Verb RunAs" & exit /b)' Set-Content -Path $startCmd -Encoding OEM -Value @('@echo off', $elevate, "`"$nssm`" start $SERVICE", 'timeout /t 2 >nul') Set-Content -Path $stopCmd -Encoding OEM -Value @('@echo off', $elevate, "`"$nssm`" stop $SERVICE", 'timeout /t 2 >nul') Set-Content -Path $unCmd -Encoding OEM -Value @('@echo off', $elevate, "powershell -NoProfile -ExecutionPolicy Bypass -File `"$unPs1`"") # uninstall.ps1 — self-elevating; stops+removes the service, firewall rule, shortcuts, каталог. # Single-quoted here-string (no interpolation) + placeholder substitution keeps it robust. Written # WITH a BOM so PS 5.1 `-File` reads its Cyrillic correctly. Cyrillic-named desktop shortcut is # removed by matching the ASCII substring "JARWITS". $unBody = @' #Requires -Version 5.1 $ErrorActionPreference = 'SilentlyContinue' $admin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) if (-not $admin) { Start-Process powershell -Verb RunAs -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File',"$PSCommandPath"; exit } Write-Host '==== JARWITS SA uninstall ====' -ForegroundColor White # Tell the cloud this station is being UNINSTALLED (it preserves the station's history + settings so a # reinstall on the same hardware restores them). Best-effort, short-bounded — must never block uninstall. # Run from the SA dir (InstallDir\sa) — that's where config.json + src/ live (nssm AppDirectory=$saDir), # so the beacon reads ./config.json and derives the same device_uid the running SA used. try { Push-Location '__INSTALLDIR__\sa'; & '__NODE__' 'src\uninstall-beacon.js' 2>$null | Out-Null; Pop-Location } catch {} # 🔴 ЛОВУШКА УДАЛЁННОГО УДАЛЕНИЯ: ниже мы гасим wstunnel — несущий транспорт VPN, а SSH сужен до VPN-подсети. # Удалённый оператор без запасного пути отрежет себя от станции НАВСЕГДА. Открываем SSH из локальной подсети # и ПРОВЕРЯЕМ факт ДО остановки туннеля (правило переживает удаление намеренно — иначе машина недостижима). try { $lanRule = 'JARWITS SSH (LAN fallback)' if (-not (Get-NetFirewallRule -DisplayName $lanRule -ErrorAction SilentlyContinue)) { $nets = @(Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -notmatch '^(127\.|169\.254\.|10\.75\.)' } | ForEach-Object { ($_.IPAddress -replace '\.\d+$', '.0') + '/24' } | Sort-Object -Unique) $sshPort = 22 try { $vpnRule = Get-NetFirewallRule -DisplayName 'JARWITS SSH (VPN only)' -ErrorAction SilentlyContinue if ($vpnRule) { $sshPort = ($vpnRule | Get-NetFirewallPortFilter).LocalPort } } catch {} if ($nets) { New-NetFirewallRule -DisplayName $lanRule -Direction Inbound -Action Allow -Protocol TCP -LocalPort $sshPort -RemoteAddress $nets -Profile Any -ErrorAction SilentlyContinue | Out-Null } } $chk = Get-NetFirewallRule -DisplayName $lanRule -ErrorAction SilentlyContinue if ($chk) { Write-Host ' запасной SSH-доступ из LAN открыт (VPN сейчас будет снят)' -ForegroundColor Gray } else { Write-Host ' ВНИМАНИЕ: запасной SSH из LAN не создан — при удалённой работе доступ пропадёт вместе с туннелем' -ForegroundColor Yellow } } catch {} & '__NSSM__' stop __SERVICE__ 2>$null | Out-Null Start-Sleep 2 & '__NSSM__' remove __SERVICE__ confirm 2>$null | Out-Null # 🔴 08.233: снять НЕЗАВИСИМЫЕ VPN-службы (wstunnel-carrier + WG-туннель). Они НАМЕРЕННО переживают жизненный цикл # SA, поэтому удаление SA само их не трогает — иначе останутся службами-сиротами, указывающими на стёртый exe. & '__NSSM__' stop JarwitsWstunnel 2>$null | Out-Null & '__NSSM__' remove JarwitsWstunnel confirm 2>$null | Out-Null $wgExe = '__INSTALLDIR__\sa\tools\wireguard\wireguard.exe' if (Test-Path $wgExe) { & $wgExe /uninstalltunnelservice wg-jw 2>$null | Out-Null } & sc.exe stop 'WireGuardTunnel$wg-jw' 2>$null | Out-Null & sc.exe delete 'WireGuardTunnel$wg-jw' 2>$null | Out-Null Start-Sleep 2 Remove-NetFirewallRule -Name 'JARWITS-VPN-*' -ErrorAction SilentlyContinue Get-NetFirewallRule -DisplayName 'JARWITS SA __PORT__' -ErrorAction SilentlyContinue | Remove-NetFirewallRule -ErrorAction SilentlyContinue Remove-Item '__MENUDIR__' -Recurse -Force -ErrorAction SilentlyContinue Get-ChildItem ([Environment]::GetFolderPath('CommonDesktopDirectory')) -Filter '*JARWITS*.url' -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue Set-Location $env:TEMP # 🔴 Снятие службы НЕ гарантирует смерть процесса: переживший node.exe держит файлы, и каталог не удаляется # (симптом «C:\JARWITS остался»). Бьём ТОЛЬКО свои процессы — по пути образа внутри InstallDir, чтобы не # задеть посторонний Node на этой машине. try { $mine = @(Get-CimInstance Win32_Process -Filter "Name='node.exe'" -ErrorAction SilentlyContinue | Where-Object { $_.ExecutablePath -and $_.ExecutablePath.StartsWith('__INSTALLDIR__', [StringComparison]::OrdinalIgnoreCase) }) foreach ($pr in $mine) { Write-Host (" добиваю процесс node.exe PID {0}" -f $pr.ProcessId) -ForegroundColor Gray Stop-Process -Id $pr.ProcessId -Force -ErrorAction SilentlyContinue } if ($mine) { Start-Sleep 2 } } catch {} Remove-Item '__INSTALLDIR__' -Recurse -Force -ErrorAction SilentlyContinue # Проверяем ФАКТ удаления, а не надеемся: остаток = кто-то ещё держит файлы, оператор должен это увидеть. if (Test-Path '__INSTALLDIR__') { Start-Sleep 3 Remove-Item '__INSTALLDIR__' -Recurse -Force -ErrorAction SilentlyContinue } if (Test-Path '__INSTALLDIR__') { Write-Host '' Write-Host 'ВНИМАНИЕ: каталог __INSTALLDIR__ удалить не удалось — его держит процесс.' -ForegroundColor Yellow Write-Host 'Кто держит:' -ForegroundColor Yellow try { Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { $_.ExecutablePath -and $_.ExecutablePath.StartsWith('__INSTALLDIR__', [StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { Write-Host (" {0} (PID {1})" -f $_.Name, $_.ProcessId) -ForegroundColor Yellow } } catch {} Write-Host 'Службы сняты; перезагрузите ПК и удалите каталог вручную.' -ForegroundColor Yellow } else { Write-Host 'Готово. JARWITS удалён с этого ПК.' -ForegroundColor Green } Start-Sleep 4 '@ $menuRoot = [Environment]::GetFolderPath("CommonPrograms") $menuDir = Join-Path $menuRoot "JARWITS" $unBody = $unBody.Replace('__NSSM__', $nssm).Replace('__SERVICE__', $SERVICE).Replace('__PORT__', "$SA_PORT").Replace('__MENUDIR__', $menuDir).Replace('__NODE__', $node).Replace('__INSTALLDIR__', $InstallDir) [IO.File]::WriteAllText($unPs1, $unBody, (New-Object Text.UTF8Encoding $true)) # BOM: PS 5.1 -File reads Cyrillic # shortcuts: Start-Menu folder "JARWITS" (панель + старт/стоп + удаление) + desktop panel shortcut New-Item -ItemType Directory -Path $menuDir -Force | Out-Null $ws = New-Object -ComObject WScript.Shell $icoLine = if (Test-Path $brandIco) { @("IconFile=$brandIco", "IconIndex=0") } else { @() } function New-UrlShortcut($path, $url) { Set-Content -Path $path -Encoding ASCII -Value (@("[InternetShortcut]", "URL=$url") + $icoLine) } function New-CmdShortcut($path, $target, $desc) { $s = $ws.CreateShortcut($path); $s.TargetPath = $target; $s.WorkingDirectory = $InstallDir $s.IconLocation = $(if (Test-Path $brandIco) { "$brandIco,0" } else { "$node,0" }); $s.Description = $desc; $s.Save() } New-UrlShortcut (Join-Path $menuDir "Панель JARWITS.url") $panelUrl New-CmdShortcut (Join-Path $menuDir "Запустить службу.lnk") $startCmd "Запустить службу JARWITS" New-CmdShortcut (Join-Path $menuDir "Остановить службу.lnk") $stopCmd "Остановить службу JARWITS" New-CmdShortcut (Join-Path $menuDir "Удалить JARWITS.lnk") $unCmd "Полностью удалить JARWITS с этого ПК" New-UrlShortcut (Join-Path ([Environment]::GetFolderPath("CommonDesktopDirectory")) "Панель JARWITS.url") $panelUrl Ok "меню Пуск -> JARWITS (панель, старт/стоп, удаление) + ярлык на рабочем столе" # ---------------------------------------------------------------- done Remove-Item $work -Recurse -Force -ErrorAction SilentlyContinue # device_id станции: SA пишет $dataDir\device_uid на старте службы (в течение 1-3с). Короткий ретрай (~8с), # чтобы показать оператору сразу; фолбэк БЕЗ падения, если файл ещё не появился (медленный ПК / edge). $deviceIdFile = Join-Path $dataDir "device_uid" $deviceId = $null; $adoptFp = $null for ($i = 0; $i -lt 16; $i++) { if (Test-Path $deviceIdFile) { try { $deviceId = (Get-Content $deviceIdFile -Raw -ErrorAction Stop).Trim() } catch {} ; if ($deviceId) { break } } Start-Sleep -Milliseconds 500 } # adopt-отпечаток (публичный, для out-of-band сверки) из status.json — тривиально, показываем рядом если есть. try { $statusFile = Join-Path $dataDir "status.json"; if (Test-Path $statusFile) { $adoptFp = ((Get-Content $statusFile -Raw | ConvertFrom-Json).adopt_epk_fp) } } catch {} Write-Host "`n==== Установка завершена ====" -ForegroundColor Green Show-Contour 'end' Write-Host " Каталог: $InstallDir" if ($deviceId) { Write-Host " Device ID: $deviceId" -ForegroundColor Cyan } else { Write-Host " Device ID: (появится в панели WSA / status.json через несколько секунд)" -ForegroundColor Gray } if ($adoptFp) { Write-Host " Отпечаток ключа: $adoptFp" } Write-Host " Панель WSA: $panelUrl (логин admin, пароль ниже)" Write-Host " Пароль WSA: $wsaPass <-- СОХРАНИТЕ" -ForegroundColor Yellow Write-Host " Меню Пуск: папка JARWITS — Панель, Запустить/Остановить службу, Удалить JARWITS" Write-Host " Рабочий стол: ярлык «Панель JARWITS»" Write-Host "`n Дальше: откройте панель -> Настройки станции -> укажите ЗАКАЗЧИКА/МЕСТОРОЖДЕНИЕ/КУСТ/СКВАЖИНУ" Write-Host " и источник WITS -> Сохранить. Регистрация в облаке произойдёт после Сохранить (гейт v5.1.2)."