param( [string]$Engine = '', [string]$Mode = '', [string]$HostName = '', [string]$ServerIP = '', [string]$AdminIP = '', [string]$HttpsPort = '', [string]$AdminPort = '', [string]$Project = '', [switch]$Yes, [switch]$NonInteractive, [switch]$Resume, [ValidateRange(10, 86400)][int]$WaitTimeout = 300 ) # Windows PowerShell 5.1+; also executable in PowerShell 7 on Linux/macOS. $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest $ProgressPreference = 'SilentlyContinue' [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 $TargetVersion = '0.1.19' $ArtifactBaseUrl = 'https://updates.putex.org/ristu/core/releases/0.1.19' $UpdateManifestUrl = 'https://updates.putex.org/ristu/core/stable.json' $ExpectedComposeSha256 = 'e00b4e62896c40c5b52dc678c2d498ec53ebb0aa9f348d6554f53ee067411a9c' $ExpectedEnvSha256 = '0143cee544b4fbdc11e8abb64f241c9627d694bc80089397066dddf982c52c61' $OnWindows = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT $TargetDir = (Get-Location).ProviderPath $EnvFile = Join-Path $TargetDir '.env'; $ComposeFile = Join-Path $TargetDir 'docker-compose.yml' $StateFile = Join-Path $TargetDir '.ristu-installation'; $RecoveryFile = Join-Path $TargetDir '.ristu-recovery' $LockDir = Join-Path $TargetDir '.ristu-installer.lock'; $TempDir = ''; $BackupDir = ''; $Phase = 'preflight' $Locked = $false; $Success = $false; $WriterIds = @(); $ContainerIds = @(); $VolumeId = 'pending' $Services = @('ristu-db', 'orthanc-db', 'orthanc-auth-service', 'orthanc', 'ristu-viewer', 'ristu-api', 'ristu-web') $SecretKeys = @('RISTU_DB_PASSWORD', 'ORTHANC_DB_PASSWORD', 'ORTHANC_AUTH_SERVICE_PASSWORD', 'ORTHANC_AUTH_SECRET', 'RISTU_PROXY_SESSION_SECRET', 'RISTU_SETTINGS_SECRET') $Volumes = @('ristu_db', 'orthanc_db', 'orthanc_dicom', 'ristu_api_runtime', 'ristu_web_tls', 'ristu_dicomweb_cache') $Utf8 = New-Object Text.UTF8Encoding($false) function Write-RistuLog([string]$Message) { Write-Host "Ristu: $Message" } function Write-Text([string]$Path, [string]$Value) { [IO.File]::WriteAllText($Path, $Value, $script:Utf8) } function Write-Lines([string]$Path, [string[]]$Lines) { [IO.File]::WriteAllLines($Path, $Lines, $script:Utf8) } function Assert-Plain([string]$Path, [switch]$Directory) { $Item = Get-Item -LiteralPath $Path -Force if (($Item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or $Item.PSIsContainer -ne [bool]$Directory) { throw "Wymagany zwykły plik/katalog bez dowiązań/reparse: $Path" } } function Set-Private([string]$Path, [switch]$Directory) { if ($script:OnWindows) { $Sid = [Security.Principal.WindowsIdentity]::GetCurrent().User $Acl = Get-Acl -LiteralPath $Path $Acl.SetAccessRuleProtection($true, $false) foreach ($Rule in @($Acl.Access)) { [void]$Acl.RemoveAccessRuleSpecific($Rule) } $Inheritance = [Security.AccessControl.InheritanceFlags]::None if ($Directory) { $Inheritance = [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit' } $Rule = New-Object Security.AccessControl.FileSystemAccessRule($Sid, 'FullControl', $Inheritance, 'None', 'Allow') $Acl.SetAccessRule($Rule); $Acl.SetOwner($Sid) Set-Acl -LiteralPath $Path -AclObject $Acl } else { $Permissions = '600'; if ($Directory) { $Permissions = '700' } & chmod $Permissions -- $Path if ($LASTEXITCODE -ne 0) { throw 'Nie można ograniczyć praw do danych instalatora.' } } } function Read-Values([string]$Path) { $Values = @{} foreach ($Line in [IO.File]::ReadAllLines($Path)) { if ($Line -match '^\s*(#|$)') { continue } if ($Line -notmatch '^([A-Za-z_][A-Za-z0-9_]*)=(.*)$') { throw 'Niejednoznaczny .env; wymagane KEY=VALUE bez kodu/interpolacji.' } $Key = $Matches[1]; $Value = $Matches[2] if ($Values.ContainsKey($Key)) { throw "Powtórzone pole $Key." } if ($Value -match '[\\$`]') { throw "Interpolacja/escape w $Key nie jest obsługiwana; odzyskaj literalny plik konfiguracji." } if (($Value.StartsWith("'") -and $Value.EndsWith("'")) -or ($Value.StartsWith('"') -and $Value.EndsWith('"'))) { if ($Value.Length -lt 2) { throw "Nieprawidłowa wartość $Key." } $Value = $Value.Substring(1, $Value.Length - 2) } if ($Value -match '[\s"'']' -or ($Key.StartsWith('COMPOSE_') -and $Key -ne 'COMPOSE_PROJECT_NAME')) { throw "Nieobsługiwana wartość $Key." } $Values[$Key] = $Value } return $Values } function Value-Or([hashtable]$Values, [string]$Key, [string]$Default = '') { if ($Values.ContainsKey($Key)) { return [string]$Values[$Key] }; return $Default } function Set-Value([string]$Path, [string]$Key, [string]$Value) { $Lines = [Collections.Generic.List[string]]::new(); $Found = $false foreach ($Line in [IO.File]::ReadAllLines($Path)) { if ($Line.StartsWith("$Key=")) { $Lines.Add("$Key=$Value"); $Found = $true } else { $Lines.Add($Line) } } if (-not $Found) { $Lines.Add("$Key=$Value") }; Write-Lines $Path $Lines.ToArray() } function Ask([string]$Prompt, [string]$Default) { if ($script:NonInteractive) { throw "Brak parametru: $Prompt. Użyj jawnych parametrów -Engine -Mode -HostName -Yes -NonInteractive." } $Answer = Read-Host "Ristu: $Prompt [$Default]" if ([string]::IsNullOrEmpty($Answer)) { return $Default }; return $Answer } function New-RistuSecret { [byte[]]$Bytes = New-Object byte[] 32 $Generator = [Security.Cryptography.RandomNumberGenerator]::Create() try { $Generator.GetBytes($Bytes) } finally { $Generator.Dispose() } return (($Bytes | ForEach-Object { $_.ToString('x2') }) -join '') } function Get-Sha([string]$Path) { return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() } function Get-TextSha([string]$Value) { $Hasher = [Security.Cryptography.SHA256]::Create() try { return (($Hasher.ComputeHash($script:Utf8.GetBytes($Value)) | ForEach-Object { $_.ToString('x2') }) -join '') } finally { $Hasher.Dispose() } } function Download([string]$Url, [string]$Path) { # Do not permit HTTPS -> HTTP redirects. A bounded, explicit redirect loop # works identically in Windows PowerShell 5.1 and PowerShell 7. for ($Hop = 0; $Hop -lt 6; $Hop++) { $Uri = [Uri]$Url if ($Uri.Scheme -ne 'https' -or -not [string]::IsNullOrEmpty($Uri.UserInfo)) { throw 'Artefakty wymagają HTTPS bez danych logowania.' } $Request = [Net.HttpWebRequest]::Create($Uri) $Request.AllowAutoRedirect = $false; $Request.Timeout = 120000 $Request.UserAgent = 'curl/8.0 Ristu-installer' $Response = $Request.GetResponse() try { $Code = [int]$Response.StatusCode if ($Code -ge 300 -and $Code -lt 400) { $Url = ([Uri]::new($Uri, $Response.Headers['Location'])).AbsoluteUri; continue } if ($Code -ne 200) { throw 'Nie udało się pobrać artefaktu wydania.' } $Output = [IO.File]::Create($Path) try { $Response.GetResponseStream().CopyTo($Output) } finally { $Output.Dispose() } return } finally { $Response.Dispose() } } throw 'Zbyt wiele przekierowań pobierania.' } function Invoke-Engine([string[]]$EngineArgs) { $PreviousPreference = $ErrorActionPreference try { $ErrorActionPreference = 'Continue' $ErrorFile = Join-Path $script:TempDir 'engine.stderr' $NativeArgs = $EngineArgs # Windows PowerShell 5.1 constructs a legacy command line; protect # literal quotes in Go templates from CommandLineToArgvW stripping. $Passing = Get-Variable PSNativeCommandArgumentPassing -ValueOnly -ErrorAction SilentlyContinue if ($script:OnWindows -and ($PSVersionTable.PSVersion -lt [Version]'7.3' -or $Passing -eq 'Legacy')) { $NativeArgs = @($EngineArgs | ForEach-Object { $_.Replace('"', '\"') }) } $Output = @(& $script:Engine @NativeArgs 2> $ErrorFile) $Code = $LASTEXITCODE } finally { $ErrorActionPreference = $PreviousPreference } if ($Code -ne 0) { throw "Polecenie $script:Engine ($($EngineArgs[0])) nie powiodło się. Nie wyświetlono konfiguracji ani sekretów." } return @($Output | ForEach-Object { $_.ToString() }) } function Invoke-Compose([string]$EnvPath, [string]$ComposePath, [string[]]$ComposeArgs) { $Saved = @{} $Keys = @([Environment]::GetEnvironmentVariables().Keys | Where-Object { $_ -match '^(COMPOSE_|RISTU_|ORTHANC_|ENABLED_MODULES$)' }) $Keys += @((Read-Values $EnvPath).Keys) foreach ($Key in $Keys | Select-Object -Unique) { $Saved[$Key] = [Environment]::GetEnvironmentVariable($Key); Remove-Item -LiteralPath "Env:$Key" -ErrorAction SilentlyContinue } try { return Invoke-Engine (@('compose', '--project-name', $script:Project, '--project-directory', $script:TargetDir, '--env-file', $EnvPath, '-f', $ComposePath) + $ComposeArgs) } finally { foreach ($Key in $Saved.Keys) { if ($null -eq $Saved[$Key]) { Remove-Item -LiteralPath "Env:$Key" -ErrorAction SilentlyContinue } else { [Environment]::SetEnvironmentVariable($Key, $Saved[$Key]) } } } } function Save-ContainerArchive([string]$ContainerPath, [string]$Archive) { # Raw engine tar retains Linux UID/mode even on Windows PowerShell 5.1. # This fixed, validated argument grammar also works without ArgumentList (.NET 4). if ($ContainerPath -notmatch '^[a-zA-Z0-9_-]+:/(runtime|etc/nginx/tls)/\.$') { throw 'Nieprawidłowa ścieżka kopii kontenera.' } $Process = New-Object Diagnostics.Process $Process.StartInfo.FileName = (Get-Command $script:Engine -CommandType Application | Select-Object -First 1).Source $Process.StartInfo.Arguments = "cp $ContainerPath -" $Process.StartInfo.UseShellExecute = $false $Process.StartInfo.RedirectStandardOutput = $true $Process.StartInfo.RedirectStandardError = $true $Output = [IO.File]::Create($Archive) $Started = $false try { [void]$Process.Start() $Started = $true $Errors = $Process.StandardError.ReadToEndAsync() $Process.StandardOutput.BaseStream.CopyTo($Output) $Process.WaitForExit() [void]$Errors.GetAwaiter().GetResult() if ($Process.ExitCode -ne 0) { throw 'Nie udało się zachować archiwum runtime/TLS; aktualizacja zatrzymana.' } } finally { if ($Started -and -not $Process.HasExited) { $Process.Kill(); $Process.WaitForExit() } $Output.Dispose() $Process.Dispose() } } function Get-Container([string]$Service) { return ((Invoke-Compose $script:EnvFile $script:ComposeFile @('ps', '-a', '-q', $Service)) -join "`n").Trim() } function Inspect([string]$Id, [string]$Format) { return ((Invoke-Engine @('inspect', $Id, '--format', $Format)) -join "`n").Trim() } function Assert-NotDowngrade([string]$Installed) { if ($Installed -match '^\d+\.\d+\.\d+$' -and [Version]$script:TargetVersion -lt [Version]$Installed) { throw "Odmowa cofnięcia wersji $Installed do $script:TargetVersion; starszy kod wymaga osobnej procedury odtworzenia danych." } } function Test-IPv4([string]$Value) { if ($Value -notmatch '^\d+\.\d+\.\d+\.\d+$') { return $false } foreach ($Part in $Value.Split('.')) { if ($Part.Length -gt 3 -or [int]$Part -gt 255 -or [string][int]$Part -cne $Part) { return $false } } return ([int]$Value.Split('.')[0] -gt 0 -and [int]$Value.Split('.')[0] -lt 224) } function Test-HostName([string]$Value) { if ($Value -match '^[0-9.]+$') { return Test-IPv4 $Value } if ($Value.Length -gt 253 -or $Value -notmatch '^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$' -or $Value.Contains('..')) { return $false } foreach ($Label in $Value.Split('.')) { if ($Label.Length -gt 63 -or $Label.StartsWith('-') -or $Label.EndsWith('-')) { return $false } } return $true } function Test-Port([string]$Value) { return ($Value -match '^[1-9][0-9]*$' -and $Value.Length -le 5 -and [int]$Value -le 65535) } function Get-VolumeIdentity { $Lines = @(); foreach ($Volume in $script:Volumes) { $Lines += Invoke-Engine @('volume', 'inspect', "$($script:Project)_$Volume", '--format', '{{.Name}}|{{.CreatedAt}}') } return ($Lines -join "`n") + "`n" } function Write-State { Write-Text (Join-Path $script:TempDir 'state') ("schema=1`nengine=$script:Engine`nengine_id=$script:EngineId`ndirectory_id=$script:DirectoryId`nproject=$script:Project`nmode=$script:Mode`nhost=$script:HostName`nversion=$script:TargetVersion`ncompose_sha256=$script:ExpectedComposeSha256`nvolumes_sha256=$script:VolumeId`n") Move-Item -LiteralPath (Join-Path $script:TempDir 'state') -Destination $script:StateFile -Force Set-Private $script:StateFile } function Write-Recovery([string]$Stage) { Write-Text (Join-Path $script:TempDir 'recovery') ("phase=$Stage`nversion=$script:TargetVersion`nbackup=$([IO.Path]::GetFileName($script:BackupDir))`n") Move-Item -LiteralPath (Join-Path $script:TempDir 'recovery') -Destination $script:RecoveryFile -Force Set-Private $script:RecoveryFile } try { $Ancestor = [IO.DirectoryInfo]::new($TargetDir) while ($null -ne $Ancestor) { if (($Ancestor.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Katalog instalacji nie może prowadzić przez dowiązanie/reparse.' } $Ancestor = $Ancestor.Parent } foreach ($Name in @('.env', 'docker-compose.yml', '.env.example', '.ristu-installation', '.ristu-recovery', 'ristu-certificate.crt', 'TRUST-CERTIFICATE.txt')) { $Path = Join-Path $TargetDir $Name if (Test-Path -LiteralPath $Path) { Assert-Plain $Path } } foreach ($Name in @('.site-overlay', 'quadlet-site', 'docker-compose.override.yml', 'docker-compose.override.yaml', 'compose.override.yml', 'compose.override.yaml')) { if (Test-Path -LiteralPath (Join-Path $TargetDir $Name)) { throw "Wykryto $Name; niestandardowa instalacja/Quadlet wymaga własnej procedury." } } if (Test-Path -LiteralPath (Join-Path $TargetDir '.ristu-backups')) { Assert-Plain (Join-Path $TargetDir '.ristu-backups') -Directory } if ((Test-Path -LiteralPath $RecoveryFile) -and (Value-Or (Read-Values $RecoveryFile) 'phase') -eq 'before-promotion') { throw 'Przerwano podmianę konfiguracji przed migracją; odzyskaj pliki z kopii wskazanej w .ristu-recovery według instrukcji odzyskiwania.' } if (Test-Path -LiteralPath $EnvFile) { $Action = 'update'; Assert-Plain $ComposeFile } else { $Action = 'install' if (@(Get-ChildItem -LiteralPath $TargetDir -Force).Count -ne 0) { throw 'Nową instalację uruchom w całkowicie pustym katalogu; istniejącą w katalogu z .env.' } } # CreateDirectory alone is not an exclusive lock; CreateNew is atomic. if (Test-Path -LiteralPath $LockDir) { throw 'Instalator już działa lub pozostała blokada .ristu-installer.lock; sprawdź poprzedni proces.' } [void][IO.Directory]::CreateDirectory($LockDir) $LockStream = [IO.File]::Open((Join-Path $LockDir 'lock'), [IO.FileMode]::CreateNew, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) $Locked = $true; Set-Private $LockDir -Directory Write-Text (Join-Path $LockDir 'pid') ([string]$PID) $TempDir = Join-Path $TargetDir ('.ristu-bootstrap.' + [Guid]::NewGuid().ToString('N')) [void][IO.Directory]::CreateDirectory($TempDir); Set-Private $TempDir -Directory $CandidateEnv = Join-Path $TempDir '.env'; $CandidateCompose = Join-Path $TempDir 'docker-compose.yml'; $CandidateExample = Join-Path $TempDir '.env.example' if (-not (Get-Command tar -ErrorAction SilentlyContinue)) { throw 'Brak tar; wymagane narzędzie systemowe (wbudowane w aktualny Windows/macOS/Linux).' } $CurrentValues = @{}; $State = @{} if ($Action -eq 'update') { $CurrentValues = Read-Values $EnvFile if ((Value-Or $CurrentValues 'ENABLED_MODULES') -ne '') { throw 'Moduły instalacji wymagają własnej procedury aktualizacji.' } foreach ($Key in $SecretKeys) { $Value = Value-Or $CurrentValues $Key if ($Value.Length -lt 16 -or $Value.Contains('HASLO_ZMIEN_MNIE')) { throw "Brak/pusty lub przykładowy sekret $Key; odzyskaj pierwotny .env, nie generuj nowego." } } if (Test-Path -LiteralPath $StateFile) { $State = Read-Values $StateFile if ($Engine -and $Engine -ne (Value-Or $State 'engine')) { throw 'Zmiana silnika oznacza inny magazyn danych.' } $Engine = Value-Or $State 'engine' if ($Project -and $Project -ne (Value-Or $State 'project')) { throw 'Projekt różni się od zapisanej instalacji.' } $Project = Value-Or $State 'project' } } if (-not $Engine) { $Available = @() foreach ($Candidate in @('docker', 'podman')) { if (Get-Command $Candidate -ErrorAction SilentlyContinue) { $Engine = $Candidate try { Invoke-Engine @('info') | Out-Null; $Available += $Candidate } catch { } } } $Engine = '' if ($Available.Count -eq 1) { $Engine = $Available[0] } elseif ($Available.Count -eq 0) { throw 'Zainstaluj i uruchom Docker Desktop/Engine lub Podman i provider Compose, potem ponów instalator.' } else { $Engine = Ask 'Silnik (docker/podman)' 'docker' } } if ($Engine -notin @('docker', 'podman')) { throw 'Silnik musi mieć wartość docker lub podman.' } if (-not (Get-Command $Engine -ErrorAction SilentlyContinue)) { throw "Brak $Engine; zainstaluj i uruchom silnik i provider Compose." } Invoke-Engine @('info') | Out-Null; Invoke-Engine @('compose', 'version') | Out-Null if ($Engine -eq 'docker') { $Endpoint = (Invoke-Engine @('context', 'inspect', '--format', '{{.Endpoints.docker.Host}}')) -join '' if ($env:DOCKER_HOST -and $env:DOCKER_HOST -ne $Endpoint) { throw 'DOCKER_HOST zmienia endpoint; wybierz lokalny kontekst.' } if ($Endpoint -notmatch '^(unix://|npipe://)') { throw 'Wymagany lokalny silnik; zdalny kontekst Docker nie jest obsługiwany.' } $EngineInfo = (Invoke-Engine @('info', '--format', '{{.ID}}|{{.OSType}}|{{.Architecture}}|{{.OperatingSystem}}|{{json .SecurityOptions}}')) -join '' $NativeId = (Invoke-Engine @('info', '--format', '{{.ID}}')) -join '' $EngineId = Get-TextSha "$Engine`n$NativeId|$Endpoint`n" } else { if ($env:CONTAINER_HOST -or $env:CONTAINER_CONNECTION) { throw 'Zdalny endpoint Podmana w środowisku; wybierz lokalny silnik/machine.' } $EngineInfo = (Invoke-Engine @('info', '--format', '{{.Store.GraphRoot}}|{{.Host.Hostname}}|{{.Host.Arch}}|{{.Host.Security.Rootless}}')) -join '' $Storage = (Invoke-Engine @('info', '--format', '{{.Store.GraphRoot}}|{{.Host.Security.Rootless}}')) -join '' $EngineId = Get-TextSha "$Engine`n$Storage`n" } $DirectoryId = Get-TextSha $TargetDir if ($State.Count -and ((Value-Or $State 'engine_id') -ne $EngineId -or (Value-Or $State 'directory_id') -ne $DirectoryId)) { throw 'Inny silnik, magazyn danych lub katalog niż zapisany w .ristu-installation.' } if (-not $Project) { $Project = Value-Or $CurrentValues 'COMPOSE_PROJECT_NAME' 'ristu-fresh' } if ($Project -cnotmatch '^[a-z0-9][a-z0-9_-]{0,62}$') { throw 'Nieprawidłowa nazwa projektu.' } if ($Action -eq 'install') { foreach ($Name in @(Invoke-Engine @('ps', '-a', '--format', '{{.Names}}'))) { if ($Name -in ($Services + @('ohif'))) { throw 'Silnik zawiera już kontenery Ristu; odzyskaj katalog .env zamiast nowej instalacji.' } } foreach ($Name in @(Invoke-Engine @('volume', 'ls', '--format', '{{.Name}}'))) { if ($Name -match '_(ristu_db|orthanc_db|orthanc_dicom|ristu_api_runtime|ristu_web_tls|ristu_dicomweb_cache)$') { throw 'Silnik zawiera już wolumeny Ristu; odzyskaj .env zamiast tworzyć nowe sekrety.' } } if (-not $Mode) { $Mode = Ask 'Tryb (workstation/server)' 'workstation' } if (-not $HostName) { $HostName = Ask 'Adres aplikacji IPv4/DNS (bez https://)' 'localhost' } if (-not $HttpsPort) { if ($NonInteractive) { $HttpsPort = '8443' } else { $HttpsPort = Ask 'Port HTTPS aplikacji' '8443' } } if (-not $AdminPort) { if ($NonInteractive) { $AdminPort = '8445' } else { $AdminPort = Ask 'Port HTTPS administratora' '8445' } } if ($Mode -eq 'server') { if (-not $ServerIP) { $ServerIP = Ask 'Lokalny IPv4 serwera' '' } if (-not $AdminIP) { $AdminIP = Ask 'IPv4 jednej stacji administratora' '' } } else { $ServerIP = '127.0.0.1'; $AdminIP = '127.0.0.1' } } else { $CurrentBind = Value-Or $CurrentValues 'RISTU_LOCAL_ADMIN_BIND_IP' '127.0.0.1' $CurrentMode = 'server'; if ($CurrentBind -eq '127.0.0.1') { $CurrentMode = 'workstation' } if ($Mode -and $Mode -ne $CurrentMode) { throw 'Aktualizacja nie zmienia trybu dostępu/certyfikatu.' }; $Mode = $CurrentMode $CurrentPort = Value-Or $CurrentValues 'RISTU_HTTPS_PORT' if ($HttpsPort -and $HttpsPort -ne $CurrentPort) { throw 'Aktualizacja nie zmienia portów.' }; $HttpsPort = $CurrentPort $CurrentPort = Value-Or $CurrentValues 'RISTU_LOCAL_ADMIN_HTTPS_PORT' if ($AdminPort -and $AdminPort -ne $CurrentPort) { throw 'Aktualizacja nie zmienia portów.' }; $AdminPort = $CurrentPort if ($ServerIP -and $ServerIP -ne $CurrentBind) { throw 'Aktualizacja nie zmienia adresów.' }; $ServerIP = $CurrentBind $CurrentAdmin = Value-Or $CurrentValues 'RISTU_LOCAL_ADMIN_ALLOWED_IP' if ($AdminIP -and $AdminIP -ne $CurrentAdmin) { throw 'Aktualizacja nie zmienia adresów.' }; $AdminIP = $CurrentAdmin if ($State.Count) { $CurrentHost = Value-Or $State 'host' } elseif ($Mode -eq 'workstation') { $CurrentHost = 'localhost' } else { $CurrentHost = (Value-Or $CurrentValues 'RISTU_TLS_SAN_DNS').Split(',')[0]; if (-not $CurrentHost -or $CurrentHost -eq 'localhost') { $CurrentHost = $ServerIP } } if ($HostName -and $HostName -ne $CurrentHost) { throw 'Aktualizacja zachowuje adres i zaufany certyfikat.' }; $HostName = $CurrentHost } if ($Mode -notin @('workstation', 'server')) { throw 'Tryb musi mieć wartość workstation lub server.' } if (-not (Test-HostName $HostName)) { throw 'Nieprawidłowy adres aplikacji (IPv4 lub DNS).' } if (-not (Test-Port $HttpsPort) -or -not (Test-Port $AdminPort) -or $HttpsPort -eq $AdminPort) { throw 'Porty muszą być różne i należeć do 1–65535.' } if (-not (Test-IPv4 $ServerIP) -or -not (Test-IPv4 $AdminIP)) { throw 'Nieprawidłowy IPv4 serwera/stacji.' } if ($Mode -eq 'server') { if ($OnWindows -or ((& uname -s) -ne 'Linux') -or $EngineInfo -match 'Desktop|rootless' -or ($Engine -eq 'podman' -and $EngineInfo -notmatch '\|false$')) { throw 'Serwer wymaga natywnego rootful Linuxa zachowującego IP klienta; Desktop/rootless obsługuje workstation.' } if ($ServerIP -match '^(0|127)\.' -or $AdminIP -match '^(0|127)\.' -or $ServerIP -eq $AdminIP -or $HostName -eq 'localhost' -or $HostName -match '^127\.') { throw 'Serwer i stacja wymagają różnych routowalnych adresów IPv4.' } } if ($EngineInfo -match 'aarch64|arm64') { Write-RistuLog 'Obrazy Ristu są linux/amd64; wymagają działającej emulacji amd64 w silniku.' } Write-RistuLog "tryb $Action ($Mode), $Engine, projekt $Project; pobieranie wydania $TargetVersion" Download "$ArtifactBaseUrl/docker-compose.yml" $CandidateCompose Download "$ArtifactBaseUrl/env.example" $CandidateExample if ((Get-Sha $CandidateCompose) -ne $ExpectedComposeSha256 -or (Get-Sha $CandidateExample) -ne $ExpectedEnvSha256) { throw 'Pobrany plik ma nieprawidłową sumę SHA-256.' } $ExampleValues = Read-Values $CandidateExample if ($Action -eq 'install') { Copy-Item -LiteralPath $CandidateExample -Destination $CandidateEnv foreach ($Key in $SecretKeys) { Set-Value $CandidateEnv $Key (New-RistuSecret) } Set-Value $CandidateEnv 'RISTU_LOCAL_ADMIN_BIND_IP' $ServerIP; Set-Value $CandidateEnv 'RISTU_LOCAL_ADMIN_ALLOWED_IP' $AdminIP Set-Value $CandidateEnv 'RISTU_HTTPS_PORT' $HttpsPort; Set-Value $CandidateEnv 'RISTU_LOCAL_ADMIN_HTTPS_PORT' $AdminPort if (Test-IPv4 $HostName) { Set-Value $CandidateEnv 'RISTU_TLS_SAN_IPS' "127.0.0.1,$HostName" } else { Set-Value $CandidateEnv 'RISTU_TLS_SAN_DNS' "localhost,$HostName" } } else { Copy-Item -LiteralPath $EnvFile -Destination $CandidateEnv foreach ($Key in $ExampleValues.Keys) { if (-not $CurrentValues.ContainsKey($Key)) { Set-Value $CandidateEnv $Key $ExampleValues[$Key] } } } Set-Value $CandidateEnv 'RISTU_IMAGE_TAG' $TargetVersion if (-not (Value-Or (Read-Values $CandidateEnv) 'RISTU_UPDATE_MANIFEST_URL')) { Set-Value $CandidateEnv 'RISTU_UPDATE_MANIFEST_URL' $UpdateManifestUrl } Set-Private $CandidateEnv Invoke-Compose $CandidateEnv $CandidateCompose @('config', '--quiet') | Out-Null $Recovery = @{} if (Test-Path -LiteralPath $RecoveryFile) { $Recovery = Read-Values $RecoveryFile if ($Resume -and (Value-Or $Recovery 'backup') -eq 'initial' -and (Value-Or $State 'volumes_sha256') -eq 'pending') { $Action = 'install-resume' } } if ($Action -eq 'update') { $InstalledVersion = Value-Or $State 'version' (Value-Or $CurrentValues 'RISTU_IMAGE_TAG') Assert-NotDowngrade $InstalledVersion if ($State.Count) { if ((Get-Sha $ComposeFile) -ne (Value-Or $State 'compose_sha256')) { throw 'Lokalny Compose zmieniono; wymagana własna procedura.' } } else { $OldVersion = Value-Or $CurrentValues 'RISTU_IMAGE_TAG' if ($OldVersion -eq 'latest') { $ApiId = Get-Container 'ristu-api'; if (-not $ApiId) { throw 'Brak API do identyfikacji wersji latest.' } $ImageId = Inspect $ApiId '{{.Image}}' $OldVersion = (Invoke-Engine @('image', 'inspect', $ImageId, '--format', '{{index .Config.Labels "org.opencontainers.image.version"}}')) -join '' } Assert-NotDowngrade $OldVersion if ($OldVersion -notmatch '^\d+\.\d+\.\d+$') { throw 'Nie można ustalić opublikowanej wersji starej instalacji.' } $OldOfficial = Join-Path $TempDir 'old-official.yml' Download "$($ArtifactBaseUrl -replace '/releases/.*$', '')/releases/$OldVersion/docker-compose.yml" $OldOfficial if ((Get-Sha $ComposeFile) -ne (Get-Sha $OldOfficial)) { throw 'Istniejący Compose różni się od neutralnego wydania; potrzebna własna procedura.' } } foreach ($Service in $Services) { $Cid = Get-Container $Service if (-not $Cid -or $Cid.Contains("`n")) { throw "Brak/jednoznaczność kontenera $Service; odzyskaj istniejącą instalację." } if ((Inspect $Cid '{{index .Config.Labels "com.docker.compose.project"}}|{{index .Config.Labels "com.docker.compose.service"}}') -ne "$Project|$Service") { throw "Kontener $Service należy do innego projektu/usługi." } $ContainerIds += $Cid if ($Service -in @('ristu-web', 'ristu-api', 'orthanc') -and (Inspect $Cid '{{.State.Running}}') -eq 'true') { $WriterIds += $Cid } } foreach ($Record in @('ristu-db POSTGRES_PASSWORD RISTU_DB_PASSWORD', 'orthanc-db POSTGRES_PASSWORD ORTHANC_DB_PASSWORD', 'orthanc-auth-service SECRET_KEY ORTHANC_AUTH_SECRET', 'ristu-api PROXY_SESSION_SECRET RISTU_PROXY_SESSION_SECRET', 'ristu-api AI_SETTINGS_SECRET RISTU_SETTINGS_SECRET', 'ristu-api ORTHANC_AUTH_SERVICE_PASSWORD ORTHANC_AUTH_SERVICE_PASSWORD')) { $Service, $ContainerKey, $EnvKey = $Record.Split(' ') $ContainerEnv = Inspect (Get-Container $Service) '{{range .Config.Env}}{{println .}}{{end}}' $Match = @($ContainerEnv.Split("`n") | Where-Object { $_.StartsWith("$ContainerKey=") }) if ($Match.Count -ne 1 -or $Match[0].Substring($ContainerKey.Length + 1) -cne (Value-Or $CurrentValues $EnvKey)) { throw "Sekret $EnvKey nie odpowiada kontenerowi; odzyskaj właściwy .env." } } foreach ($Record in @('ristu-db /var/lib/postgresql/data ristu_db', 'orthanc-db /var/lib/postgresql/data orthanc_db', 'orthanc /var/lib/orthanc/db orthanc_dicom', 'ristu-api /runtime ristu_api_runtime', 'ristu-web /etc/nginx/tls ristu_web_tls', 'ristu-api /var/cache/ristu/dicomweb ristu_dicomweb_cache')) { $Service, $Destination, $Volume = $Record.Split(' ') $Format = '{{range .Mounts}}{{if eq .Destination "' + $Destination + '"}}{{.Name}}{{end}}{{end}}' if ((Inspect (Get-Container $Service) $Format) -ne "${Project}_$Volume") { throw "Nieprawidłowy wolumen $Volume; nie wolno podłączyć pustego magazynu." } } $OldImages = @((Invoke-Compose $EnvFile $ComposeFile @('config', '--images')) | Where-Object { $_ -match '/(postgres|orthanc(-pre-release)?)[:@]' } | Sort-Object) $NewImages = @((Invoke-Compose $CandidateEnv $CandidateCompose @('config', '--images')) | Where-Object { $_ -match '/(postgres|orthanc(-pre-release)?)[:@]' } | Sort-Object) if (($OldImages -join "`n") -cne ($NewImages -join "`n")) { throw 'Wydanie zmienia PostgreSQL/Orthanc; potrzebna osobna procedura migracji i kopii DICOM.' } $VolumesBefore = Get-VolumeIdentity; $VolumeId = Get-TextSha $VolumesBefore if ($State.Count -and (Value-Or $State 'volumes_sha256') -ne $VolumeId) { throw 'Zmieniono tożsamość wolumenów od poprzedniego uruchomienia.' } } if ($Recovery.Count) { if (-not $Resume -or (Value-Or $Recovery 'version') -ne $TargetVersion -or (Value-Or $Recovery 'phase') -ne 'migration') { throw 'Niedokończona operacja: przeczytaj .ristu-recovery; po naprawie ponów to samo wydanie z -Resume.' } $BackupName = Value-Or $Recovery 'backup' if ($BackupName -notmatch '^[A-Za-z0-9_-]+$') { throw 'Nieprawidłowy katalog kopii w stanie odzyskiwania.' } $BackupDir = Join-Path (Join-Path $TargetDir '.ristu-backups') $BackupName Assert-Plain $BackupDir -Directory if (-not (Test-Path -LiteralPath (Join-Path $BackupDir 'COMPLETE'))) { throw 'Brak potwierdzonej kopii do wznowienia.' } } elseif ($Resume) { throw 'Brak operacji do wznowienia.' } Write-RistuLog "Pobieranie obrazów przypiętych do $TargetVersion przed przerwą serwisową." Invoke-Compose $CandidateEnv $CandidateCompose @('pull') | Out-Null if (-not $Yes) { $Answer = Ask 'Uruchomić instalację/aktualizację? Aktualizacja zatrzyma zapis na czas kopii baz i runtime (tak/nie)' 'nie' if ($Answer -notin @('tak', 'yes')) { throw 'Anulowano bez zmiany usług.' } } if ($Action -eq 'update' -and -not $Resume) { $BackupRoot = Join-Path $TargetDir '.ristu-backups' [void][IO.Directory]::CreateDirectory($BackupRoot); Set-Private $BackupRoot -Directory $BackupDir = Join-Path $BackupRoot ([DateTime]::UtcNow.ToString('yyyyMMddTHHmmssZ') + '-' + $PID) [void][IO.Directory]::CreateDirectory($BackupDir); Set-Private $BackupDir -Directory foreach ($Name in @('.env', 'docker-compose.yml', '.env.example', '.ristu-installation')) { if (Test-Path -LiteralPath (Join-Path $TargetDir $Name)) { Copy-Item -LiteralPath (Join-Path $TargetDir $Name) -Destination (Join-Path $BackupDir $Name) } } Write-Text (Join-Path $BackupDir 'volumes.txt') $VolumesBefore $PreviousContainers = @(); foreach ($Cid in $ContainerIds) { $PreviousContainers += Inspect $Cid '{{.Id}} {{.Image}} {{.State.Running}}' } Write-Lines (Join-Path $BackupDir 'containers.txt') $PreviousContainers Write-Recovery 'before-migration' $Phase = 'stopped' Invoke-Compose $EnvFile $ComposeFile @('stop', 'ristu-web', 'ristu-api', 'orthanc') | Out-Null Write-RistuLog 'Kopia obu baz, runtime i TLS; DICOM pozostaje w tym samym wolumenie (nie jest kopiowany).' foreach ($Service in @('ristu-db', 'orthanc-db')) { $Database = 'orthanc_ris'; if ($Service -eq 'orthanc-db') { $Database = 'orthanc' } $Cid = Get-Container $Service; $DumpPath = "/tmp/ristu-installer-$PID.dump" try { Invoke-Engine @('exec', $Cid, 'pg_dump', '-U', $Database, '-d', $Database, '--format=custom', "--file=$DumpPath") | Out-Null Invoke-Engine @('exec', $Cid, 'pg_restore', '--list', $DumpPath) | Out-Null $DumpHash = ((Invoke-Engine @('exec', $Cid, 'sha256sum', $DumpPath)) -join '').Split(' ')[0] # Never pipe binary output through > or Out-File in PowerShell 5.1. Invoke-Engine @('cp', "${Cid}:$DumpPath", (Join-Path $BackupDir "$Service.dump")) | Out-Null if ((Get-Item -LiteralPath (Join-Path $BackupDir "$Service.dump")).Length -eq 0 -or $DumpHash -notmatch '^[a-f0-9]{64}$' -or (Get-Sha (Join-Path $BackupDir "$Service.dump")) -ne $DumpHash) { throw "Pusta/uszkodzona kopia $Service po przeniesieniu na hosta." } } finally { Invoke-Engine @('exec', $Cid, 'rm', '-f', $DumpPath) | Out-Null } } foreach ($Record in @('ristu-api /runtime runtime', 'ristu-web /etc/nginx/tls tls')) { $Service, $Source, $Name = $Record.Split(' ') $Archive = Join-Path $BackupDir "$Name.tar" Save-ContainerArchive "$(Get-Container $Service):$Source/." $Archive & tar -tf $Archive | Out-Null; if ($LASTEXITCODE -ne 0) { throw "Nie można odczytać archiwum $Name." } } $Checksums = @() foreach ($Name in @('.env', 'docker-compose.yml', 'ristu-db.dump', 'orthanc-db.dump', 'runtime.tar', 'tls.tar', 'volumes.txt', 'containers.txt')) { $Checksums += "$(Get-Sha (Join-Path $BackupDir $Name)) $Name" } Write-Lines (Join-Path $BackupDir 'SHA256SUMS') $Checksums Write-Text (Join-Path $BackupDir 'README.txt') "Backup baz PostgreSQL, runtime, TLS i konfiguracji. DICOM pozostaje w oryginalnym wolumenie; to nie jest pełna kopia DICOM.`n" Write-Text (Join-Path $BackupDir 'COMPLETE') "verified`n" } if ($Action -eq 'install') { $BackupRoot = Join-Path $TargetDir '.ristu-backups'; [void][IO.Directory]::CreateDirectory($BackupRoot); Set-Private $BackupRoot -Directory $BackupDir = Join-Path $BackupRoot 'initial'; [void][IO.Directory]::CreateDirectory($BackupDir); Set-Private $BackupDir -Directory Write-Text (Join-Path $BackupDir 'COMPLETE') "initial installation; no previous data`n" } Write-Recovery 'before-promotion' $Phase = 'files' Move-Item -LiteralPath $CandidateEnv -Destination $EnvFile -Force Move-Item -LiteralPath $CandidateCompose -Destination $ComposeFile -Force Move-Item -LiteralPath $CandidateExample -Destination (Join-Path $TargetDir '.env.example') -Force Set-Private $EnvFile; Write-State Write-Recovery 'migration'; $Phase = 'migration' Write-RistuLog "Uruchamianie $TargetVersion; oczekiwanie na siedem zdrowych usług." Invoke-Compose $EnvFile $ComposeFile @('up', '-d') | Out-Null $Timer = [Diagnostics.Stopwatch]::StartNew() do { $Healthy = 0 foreach ($Service in $Services) { $Cid = Get-Container $Service if ($Cid -and (Inspect $Cid '{{.State.Status}}|{{if .State.Health}}{{.State.Health.Status}}{{end}}') -eq 'running|healthy') { $Healthy++ } } if ($Healthy -eq 7) { break } if ($Timer.Elapsed.TotalSeconds -ge $WaitTimeout) { throw "Przekroczono czas oczekiwania na zdrowie usług ($Healthy/7)." } Start-Sleep -Seconds 2 } while ($true) $VolumesAfter = Get-VolumeIdentity if ($Action -eq 'update' -and $VolumesBefore -cne $VolumesAfter) { throw 'Zmieniła się tożsamość wolumenów.' } $VolumeId = Get-TextSha $VolumesAfter; Write-State $WebId = Get-Container 'ristu-web'; $Certificate = Join-Path $TempDir 'certificate.crt' Invoke-Engine @('cp', "${WebId}:/etc/nginx/tls/fresh.crt", $Certificate) | Out-Null $Fingerprint = (Invoke-Engine @('exec', $WebId, 'openssl', 'x509', '-in', '/etc/nginx/tls/fresh.crt', '-noout', '-sha256', '-fingerprint')) -join "`n" $Check = '-checkhost'; if (Test-IPv4 $HostName) { $Check = '-checkip' } $CertificateCheck = (Invoke-Engine @('exec', $WebId, 'openssl', 'x509', '-in', '/etc/nginx/tls/fresh.crt', '-noout', $Check, $HostName)) -join "`n" if ($CertificateCheck -notmatch 'does match certificate') { throw 'Certyfikat nie odpowiada adresowi aplikacji; zachowano istniejący klucz.' } $ExportedCertificate = Join-Path $TargetDir 'ristu-certificate.crt' if ((Test-Path -LiteralPath $ExportedCertificate) -and (Get-Sha $ExportedCertificate) -ne (Get-Sha $Certificate)) { throw 'Zmienił się istniejący certyfikat; nie zmieniono eksportowanego zaufania.' } Move-Item -LiteralPath $Certificate -Destination $ExportedCertificate -Force $AdminHost = $HostName; if ($Mode -eq 'workstation') { $AdminHost = 'localhost' } Write-Text (Join-Path $TargetDir 'TRUST-CERTIFICATE.txt') @" Publiczny certyfikat: ristu-certificate.crt (nigdy nie udostępniaj fresh.key). $Fingerprint Przed importem porównaj fingerprint przekazany bezpiecznym kanałem. Windows: certmgr.msc > Bieżący użytkownik > Zaufane główne urzędy certyfikacji > Certyfikaty > Importuj. macOS: Dostęp do pęku kluczy > pęk logowania/System > import certyfikatu > Zaufanie > SSL: Zawsze ufaj. Linux: dodaj certyfikat do zaufanych w docelowej przeglądarce lub systemowym magazynie dystrybucji (np. /usr/local/share/ca-certificates i update-ca-certificates na Debian/Ubuntu). Firefox/Linux może używać osobnego NSS: zamknij Firefox, znajdź Katalog główny profilu w about:profiles i użyj narzędzia Mozilla NSS certutil (pakiet libnss3-tools, NIE Windows certutil.exe): certutil -A -d "sql:/sciezka/profilu" -n Ristu -t "C,," -i "/sciezka/ristu-certificate.crt" Flaga C,, ustanawia zaufanie; samo P,, nie wystarcza. Uruchom ponownie przeglądarkę. Adres ma działać bez ostrzeżenia TLS; dopiero wtedy instaluj PWA. Pominięcie ostrzeżenia nie ustanawia zaufania. Aplikacja: https://${HostName}:$HttpsPort/ Administrator i pierwsze konto: https://${AdminHost}:$AdminPort/ "@ Remove-Item -LiteralPath $RecoveryFile -Force; $Phase = 'complete'; $Success = $true Write-RistuLog "Instalacja/aktualizacja zakończona: https://${HostName}:$HttpsPort/" Write-RistuLog 'Certyfikat publiczny: ristu-certificate.crt; instrukcja: TRUST-CERTIFICATE.txt' Write-Host $Fingerprint Write-RistuLog "Pierwsze konto/administrator: https://${AdminHost}:$AdminPort/" if ($Action -eq 'update') { Write-RistuLog "Kopia baz/runtime/TLS/konfiguracji: $BackupDir; DICOM zachowano w tym samym wolumenie." } } finally { if (-not $Success) { if ($Phase -in @('stopped', 'files')) { try { if ($Phase -eq 'files' -and $BackupDir) { if ($Action -eq 'install') { throw 'Przerwano zapis pierwszej konfiguracji; wymagane odzyskanie według markera.' } foreach ($Name in @('.env', 'docker-compose.yml', '.env.example', '.ristu-installation')) { $Source = Join-Path $BackupDir $Name if (Test-Path -LiteralPath $Source) { Copy-Item -LiteralPath $Source -Destination (Join-Path $TargetDir $Name) -Force } elseif ($Name -in @('.ristu-installation', '.env.example')) { if (Test-Path -LiteralPath (Join-Path $TargetDir $Name)) { Remove-Item -LiteralPath (Join-Path $TargetDir $Name) -Force } } else { throw 'Brak pliku kopii konfiguracji.' } } } if ($WriterIds.Count) { Invoke-Engine (@('start') + $WriterIds) | Out-Null; Write-RistuLog 'Przywrócono poprzednie kontenery; migracja nie została uruchomiona.' } if ((Test-Path -LiteralPath $RecoveryFile) -and $Action -eq 'update') { Remove-Item -LiteralPath $RecoveryFile -Force } } catch { Write-RistuLog 'Nie ukończono przywracania konfiguracji/kontenerów; zachowano .ristu-recovery i kopię. Odzyskaj pliki według instrukcji odzyskiwania.' } } elseif ($Phase -eq 'migration') { try { Invoke-Compose $EnvFile $ComposeFile @('stop', 'ristu-web', 'ristu-api', 'orthanc') | Out-Null; Write-RistuLog 'Usługi zapisujące dane zatrzymano.' } catch { Write-RistuLog 'UWAGA: zatrzymanie usług zapisujących dane nie powiodło się; wymagane ręczne zatrzymanie przed odzyskiwaniem.' } Write-RistuLog "Start/migracja nie powiodły się. Backup: $BackupDir. Nie uruchamiaj starego kodu na zmienionej bazie. Po naprawie ponów to samo wydanie z -Resume; stan .ristu-recovery." } } if ($TempDir -and (Test-Path -LiteralPath $TempDir)) { Remove-Item -LiteralPath $TempDir -Recurse -Force } if ($Locked) { $LockStream.Dispose(); Remove-Item -LiteralPath $LockDir -Recurse -Force } }