# BACKUP_NOT Server Agent - Hlavní skript # Běží jako Windows služba/scheduled task $ErrorActionPreference = "Continue" $InstallPath = "C:\Program Files\BACKUP_NOT\ServerAgent" $ConfigPath = "$InstallPath\config.json" $LogPath = "$InstallPath\logs" # Načtení konfigurace $config = Get-Content $ConfigPath | ConvertFrom-Json $ConsoleUrl = $config.ConsoleUrl $ApiKey = $config.ApiKey $BackupPath = $config.BackupPath $HeartbeatInterval = $config.HeartbeatInterval $Port = $config.Port function Write-Log { param([string]$Message, [string]$Level = "INFO") $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" $logEntry = "[$timestamp] [$Level] $Message" $logFile = "$LogPath\agent_$(Get-Date -Format 'yyyyMMdd').log" Add-Content -Path $logFile -Value $logEntry if ($Level -eq "ERROR") { Write-Host $logEntry -ForegroundColor Red } else { Write-Host $logEntry } } function Get-PerformanceMetrics { # CPU Usage - WMI perf trida (nezavisla na jazyku Windows, na rozdil od Get-Counter) $cpuPercent = 0 try { $cpuData = Get-CimInstance Win32_PerfFormattedData_PerfOS_Processor -Filter "Name='_Total'" -ErrorAction Stop $cpuPercent = [math]::Round([double]$cpuData.PercentProcessorTime, 2) } catch { try { $cpuPercent = [math]::Round((Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average, 2) } catch { $cpuPercent = 0 } } # RAM Usage $os = Get-CimInstance Win32_OperatingSystem $ramTotal = [math]::Round($os.TotalVisibleMemorySize / 1MB, 2) $ramFree = [math]::Round($os.FreePhysicalMemory / 1MB, 2) $ramUsed = [math]::Round($ramTotal - $ramFree, 2) $ramPercent = if ($ramTotal -gt 0) { [math]::Round(($ramUsed / $ramTotal) * 100, 2) } else { 0 } # Disk I/O - WMI perf trida (nezavisla na jazyku) $diskReadMbps = 0 $diskWriteMbps = 0 try { $diskData = Get-CimInstance Win32_PerfFormattedData_PerfDisk_PhysicalDisk -Filter "Name='_Total'" -ErrorAction Stop $diskReadMbps = [math]::Round([double]$diskData.DiskReadBytesPerSec / 1MB, 2) $diskWriteMbps = [math]::Round([double]$diskData.DiskWriteBytesPerSec / 1MB, 2) } catch { } # Network I/O - WMI perf trida (nezavisla na jazyku) $networkInMbps = 0 $networkOutMbps = 0 try { $netData = Get-CimInstance Win32_PerfFormattedData_Tcpip_NetworkInterface -ErrorAction Stop $networkInMbps = [math]::Round((($netData | Measure-Object -Property BytesReceivedPerSec -Sum).Sum) / 1MB, 2) $networkOutMbps = [math]::Round((($netData | Measure-Object -Property BytesSentPerSec -Sum).Sum) / 1MB, 2) } catch { } # Process count $processCount = (Get-Process).Count # Uptime $uptime = (Get-Date) - $os.LastBootUpTime $uptimeHours = [math]::Floor($uptime.TotalHours) @{ cpuPercent = $cpuPercent ramTotalGb = $ramTotal ramUsedGb = $ramUsed ramPercent = $ramPercent diskReadMbps = $diskReadMbps diskWriteMbps = $diskWriteMbps networkInMbps = $networkInMbps networkOutMbps = $networkOutMbps processCount = $processCount uptimeHours = $uptimeHours } } function Get-SystemInfo { $os = Get-CimInstance Win32_OperatingSystem $disk = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='C:'" $updates = (Get-HotFix | Sort-Object InstalledOn -Descending | Select-Object -First 1).InstalledOn $metrics = Get-PerformanceMetrics @{ hostname = $env:COMPUTERNAME ipAddress = (Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.InterfaceAlias -notlike "*Loopback*" } | Select-Object -First 1).IPAddress osVersion = $os.Caption osBuild = $os.BuildNumber totalDiskSpaceGb = [math]::Round($disk.Size / 1GB, 2) usedDiskSpaceGb = [math]::Round(($disk.Size - $disk.FreeSpace) / 1GB, 2) updatesPending = 0 agentVersion = $config.Version # Performance metrics cpuPercent = $metrics.cpuPercent ramTotalGb = $metrics.ramTotalGb ramUsedGb = $metrics.ramUsedGb ramPercent = $metrics.ramPercent diskReadMbps = $metrics.diskReadMbps diskWriteMbps = $metrics.diskWriteMbps networkInMbps = $metrics.networkInMbps networkOutMbps = $metrics.networkOutMbps processCount = $metrics.processCount uptimeHours = $metrics.uptimeHours # Cas startu OS (ISO 8601 UTC) pro detekci restartu serveru na konzoli. bootTime = $os.LastBootUpTime.ToUniversalTime().ToString("o") } } function Send-Heartbeat { $systemInfo = Get-SystemInfo $body = $systemInfo | ConvertTo-Json -Depth 10 try { $response = Invoke-RestMethod -Uri "$ConsoleUrl/api/agent/heartbeat" -Method POST -Body $body -ContentType "application/json" -Headers @{ "x-api-key" = $ApiKey } -TimeoutSec 20 Write-Log "Heartbeat odeslan: $($response.status)" # Kontrola dostupnosti novejsi verze agenta if ($response.latestVersion -and $response.latestVersion -ne $config.Version) { Write-Log "K dispozici nova verze agenta: $($response.latestVersion) (aktualni: $($config.Version))" Update-ServerAgent -NewVersion $response.latestVersion } } catch { Write-Log "Chyba pri odesilani heartbeatu: $_" "ERROR" } } # Automaticka aktualizace agenta z konzole function Update-ServerAgent { param([string]$NewVersion) try { Write-Log "Zahajuji aktualizaci na verzi $NewVersion..." $newScriptPath = "$InstallPath\server-agent.new.ps1" # Stazeni nove verze hlavniho skriptu Invoke-WebRequest -Uri "$ConsoleUrl/api/downloads/server-agent-main" -OutFile $newScriptPath -UseBasicParsing # Overeni, ze stazeny soubor neni prazdny if (-not (Test-Path $newScriptPath) -or (Get-Item $newScriptPath).Length -lt 100) { Write-Log "Stazeny skript je neplatny, aktualizace zrusena" "ERROR" Remove-Item $newScriptPath -Force -ErrorAction SilentlyContinue return } # Zaloha aktualniho skriptu Copy-Item -Path "$InstallPath\server-agent.ps1" -Destination "$InstallPath\server-agent.bak.ps1" -Force -ErrorAction SilentlyContinue # Nahrazeni skriptu Move-Item -Path $newScriptPath -Destination "$InstallPath\server-agent.ps1" -Force # Aktualizace verze v konfiguraci $config.Version = $NewVersion $config | ConvertTo-Json | Out-File $ConfigPath -Encoding UTF8 Write-Log "Aktualizace na verzi $NewVersion dokoncena. Restartuji agenta..." # POZOR: tento proces bezi JAKO instance naplanovane ulohy. Volani # Start-ScheduledTask na uz bezici ulohu Windows ignoruje (MultipleInstances # = IgnoreNew), takze po naslednem 'exit' by agenta nic nerestartovalo az do # hodinoveho triggeru. Proto spustime oddeleny pomocny proces, ktery pocka, # az tento proces skonci, a teprve pak ulohu (uz necinnou) spusti. $taskName = "BACKUP_NOT_ServerAgent" $relaunch = "Start-Sleep -Seconds 4; Start-ScheduledTask -TaskName '$taskName'" Start-Process powershell.exe -ArgumentList "-NoProfile","-WindowStyle","Hidden","-Command",$relaunch -WindowStyle Hidden exit 0 } catch { Write-Log "Chyba pri aktualizaci agenta: $_" "ERROR" } } # Zpracování jednoho HTTP požadavku (zálohy od stanic / status) function Process-HttpRequest { param($context) $request = $context.Request $response = $context.Response try { $path = $request.Url.LocalPath Write-Log "Pozadavek: $($request.HttpMethod) $path" if ($path -eq "/api/backup" -and $request.HttpMethod -eq "POST") { # Zpracování zálohy od workstation. Telo ctem explicitne v UTF-8, # jinak by se diakritika v ceste/nazvu souboru rozbila. $reader = New-Object System.IO.StreamReader($request.InputStream, [System.Text.Encoding]::UTF8) $body = $reader.ReadToEnd() | ConvertFrom-Json # Uložení souboru. Pokud stanice posila vlastni cilovou slozku (napr. # sitova UNC cesta), pouzije se jako zaklad; jinak vychozi slozka serveru # rozdelena podle ID stanice. $wsTarget = "$($body.target_path)".Trim() if ($wsTarget) { $baseDir = $wsTarget } else { $baseDir = "$BackupPath\$($body.workstation_id)" } # Zdrojova cesta prichazi jako plna cesta vcetne pismene disku (napr. # C:Testovaci datasoubor.txt). Odstranime pismeno disku uplne (vcetne # dvojtecky), aby se na cili nevytvarela korenova slozka "C" - vysledek je # napr. \server...ServerTestovaci datasoubor.txt. $relPath = "$($body.file_path)" $relPath = $relPath -replace '^[\\/]+', '' # odstranit uvodni nebo / $relPath = $relPath -replace '^[A-Za-z]:[\\/]*', '' # C: -> "" (pismeno disku pryc) $relPath = $relPath.TrimStart('\', '/') $targetPath = Join-Path $baseDir $relPath $targetDir = Split-Path $targetPath -Parent # Cely zapis chranime a pri chybe vracime konkretni zpravu (napr. pristup # odepren k sitove ceste), aby stanice/ikona mohla ukazat skutecnou pricinu. $saveOk = $false try { if (-not (Test-Path $targetDir)) { New-Item -ItemType Directory -Force -Path $targetDir -ErrorAction Stop | Out-Null } # Verzovani prepisem - soubor se ulozi primo na cilovou cestu a pripadny # existujici se prepise (zadne .v1/.v2/.v3). [System.IO.File]::WriteAllBytes($targetPath, [Convert]::FromBase64String($body.content)) Write-Log "Zaloha ulozena (prepis): $targetPath" $saveOk = $true } catch { $errMsg = "Nelze ulozit do '$targetDir': $_" Write-Log $errMsg "ERROR" $errData = @{ status = "error"; error = $errMsg; target = $targetDir } | ConvertTo-Json $buffer = [System.Text.Encoding]::UTF8.GetBytes($errData) $response.StatusCode = 500 $response.ContentType = "application/json; charset=utf-8" $response.OutputStream.Write($buffer, 0, $buffer.Length) } if ($saveOk) { # Ulozeni stavu posledni prijate zalohy pro stavovou ikonu (tray) try { @{ lastBackupTime = (Get-Date).ToString("o"); lastBackupFile = $body.file_name; lastBackupStatus = "ok" } | ConvertTo-Json | Out-File "$InstallPath\status.json" -Encoding UTF8 } catch { } $responseData = @{ status = "ok"; version = 1 } | ConvertTo-Json $buffer = [System.Text.Encoding]::UTF8.GetBytes($responseData) $response.ContentType = "application/json; charset=utf-8" $response.OutputStream.Write($buffer, 0, $buffer.Length) # Odeslání informace do konzole $backupInfo = @{ workstation_id = $body.workstation_id file_path = $body.file_path file_name = $body.file_name file_size = $body.file_size version = 1 } | ConvertTo-Json Invoke-RestMethod -Uri "$ConsoleUrl/api/agent/files" -Method POST -Body $backupInfo -ContentType "application/json; charset=utf-8" -Headers @{ "x-api-key" = $ApiKey } -ErrorAction SilentlyContinue } } elseif ($path -eq "/api/backup-batch" -and $request.HttpMethod -eq "POST") { # Binarni davka od stanice: [4B delka manifestu][manifest JSON UTF8][syrove bajty souboru]. # Zadny Base64 ani JSON pro obsah -> mensi objem prenosu a rychlejsi zpracovani. $msIn = New-Object System.IO.MemoryStream $request.InputStream.CopyTo($msIn) $all = $msIn.ToArray(); $msIn.Dispose() $okCount = 0; $errCount = 0; $lastErr = ""; $wsId = "" try { if ($all.Length -lt 4) { throw "Prazdne nebo poskozene telo davky" } $mlen = [System.BitConverter]::ToInt32($all, 0) if ($mlen -le 0 -or $mlen -gt ($all.Length - 4)) { throw "Neplatna delka manifestu" } $manifestJson = [System.Text.Encoding]::UTF8.GetString($all, 4, $mlen) $manifest = $manifestJson | ConvertFrom-Json $wsId = "$($manifest.workstation_id)" # Cilova slozka: vlastni cesta stanice, jinak vychozi slozka serveru dle ID stanice. $wsTarget = "$($manifest.target_path)".Trim() if ($wsTarget) { $baseDir = $wsTarget } else { $baseDir = "$BackupPath\$($manifest.workstation_id)" } $offset = 4 + $mlen foreach ($fileMeta in $manifest.files) { $size = [int]$fileMeta.file_size $relPath = "$($fileMeta.file_path)" $relPath = $relPath -replace '^[\\/]+', '' $relPath = $relPath -replace '^[A-Za-z]:[\\/]*', '' $relPath = $relPath.TrimStart('\', '/') $targetPath = Join-Path $baseDir $relPath $targetDir = Split-Path $targetPath -Parent try { if (-not (Test-Path $targetDir)) { New-Item -ItemType Directory -Force -Path $targetDir -ErrorAction Stop | Out-Null } # Verzovani prepisem - zapiseme prislusny usek bajtu primo na cilovou cestu. $fs = [System.IO.File]::Create($targetPath) try { $fs.Write($all, $offset, $size) } finally { $fs.Close() } $okCount++ } catch { $errCount++; $lastErr = "Nelze ulozit '$targetPath': $_" Write-Log $lastErr "ERROR" } $offset += $size } } catch { $lastErr = "Chyba zpracovani davky: $_" Write-Log $lastErr "ERROR" $errCount++ } if ($errCount -eq 0 -and $okCount -gt 0) { try { @{ lastBackupTime = (Get-Date).ToString("o"); lastBackupFile = "$okCount souboru (davka)"; lastBackupStatus = "ok" } | ConvertTo-Json | Out-File "$InstallPath\status.json" -Encoding UTF8 } catch { } $responseData = @{ status = "ok"; saved = $okCount } | ConvertTo-Json $buffer = [System.Text.Encoding]::UTF8.GetBytes($responseData) $response.ContentType = "application/json; charset=utf-8" $response.OutputStream.Write($buffer, 0, $buffer.Length) Write-Log "Davka ulozena: $okCount souboru (stanice $wsId)" } else { $errData = @{ status = "error"; error = $lastErr; saved = $okCount; failed = $errCount } | ConvertTo-Json $buffer = [System.Text.Encoding]::UTF8.GetBytes($errData) $response.StatusCode = 500 $response.ContentType = "application/json; charset=utf-8" $response.OutputStream.Write($buffer, 0, $buffer.Length) } } elseif ($path -eq "/api/workstation" -and $request.HttpMethod -eq "POST") { # Registrace/heartbeat stanice - server preposila do konzole s vlastnim API klicem # Telo requestu ctem s explicitnim UTF-8 (diakritika v ceste). $reader = New-Object System.IO.StreamReader($request.InputStream, [System.Text.Encoding]::UTF8) $wsBody = $reader.ReadToEnd() try { # Odpoved konzole ctem jako surove bajty a dekoduju explicitne UTF-8, # jinak by se diakritika v ceste rozbila jeste pred preposlanim stanici. $consoleBytes = [System.Text.Encoding]::UTF8.GetBytes($wsBody) $wr = Invoke-WebRequest -Uri "$ConsoleUrl/api/agent/workstation" -Method POST -Body $consoleBytes -ContentType "application/json; charset=utf-8" -Headers @{ "x-api-key" = $ApiKey } -TimeoutSec 20 -UseBasicParsing $consoleRaw = $wr.RawContentStream.ToArray() $consoleText = [System.Text.Encoding]::UTF8.GetString($consoleRaw) $consoleResp = $consoleText | ConvertFrom-Json $respJson = $consoleResp | ConvertTo-Json $buffer = [System.Text.Encoding]::UTF8.GetBytes($respJson) # Charset musi byt uveden, jinak stanice (PS 5.1) dekoduje odpoved # spatnym kodovanim a diakritika v cestach (napr. "Testovaci") se rozbije. $response.ContentType = "application/json; charset=utf-8" $response.OutputStream.Write($buffer, 0, $buffer.Length) Write-Log "Stanice zaregistrovana v konzoli" } catch { Write-Log "Nepodarilo se preposlat registraci stanice do konzole: $_" "ERROR" $response.StatusCode = 502 } } elseif ($path -eq "/api/status") { $status = @{ status = "online"; version = $config.Version } | ConvertTo-Json $buffer = [System.Text.Encoding]::UTF8.GetBytes($status) $response.ContentType = "application/json; charset=utf-8" $response.OutputStream.Write($buffer, 0, $buffer.Length) } elseif ($path -eq "/api/version") { # Workstation agent zjistuje aktualni verzi z konzole (server jako proxy) $wsVersion = $config.Version try { $verResp = Invoke-RestMethod -Uri "$ConsoleUrl/api/agent/version" -Method GET -TimeoutSec 10 $wsVersion = $verResp.version } catch { Write-Log "Nepodarilo se zjistit verzi z konzole: $_" "WARN" } $status = @{ version = $wsVersion } | ConvertTo-Json $buffer = [System.Text.Encoding]::UTF8.GetBytes($status) $response.ContentType = "application/json; charset=utf-8" $response.OutputStream.Write($buffer, 0, $buffer.Length) } elseif ($path -eq "/api/workstation-script") { # Server jako proxy: stahne aktualni workstation skript z konzole a vrati ho stanici try { $script = Invoke-RestMethod -Uri "$ConsoleUrl/api/downloads/workstation-agent-main" -Method GET -TimeoutSec 30 $buffer = [System.Text.Encoding]::UTF8.GetBytes($script) $response.ContentType = "text/plain; charset=utf-8" $response.OutputStream.Write($buffer, 0, $buffer.Length) } catch { Write-Log "Nepodarilo se stahnout workstation skript z konzole: $_" "ERROR" $response.StatusCode = 502 } } else { $response.StatusCode = 404 } } catch { Write-Log "Chyba zpracovani pozadavku: $_" "ERROR" $response.StatusCode = 500 } finally { $response.Close() } } # Hlavní smyčka Write-Log "BACKUP_NOT Server Agent spusten (verze skriptu 1.6.5, config verze $($config.Version))" Write-Log "Konzole: $ConsoleUrl" Write-Log "Backup: $BackupPath" # Spuštění HTTP listeneru (non-blocking) - pro příjem záloh od stanic. # Pokud se nepodaří (chybí URL ACL), agent i tak posílá heartbeat. $listener = $null try { $listener = New-Object System.Net.HttpListener $listener.Prefixes.Add("http://+:$Port/") $listener.Start() Write-Log "HTTP listener spusten na portu $Port" } catch { Write-Log "HTTP listener se nepodarilo spustit na portu $Port (prijem zaloh nebude fungovat, heartbeat ale bezi dal): $_" "WARN" $listener = $null } # Zapis "alive" souboru pro stavovou ikonu (tray cte tento soubor i bez opravneni na SYSTEM ulohu) function Write-AliveFile { try { @{ lastSeen = (Get-Date).ToString("o") } | ConvertTo-Json | Out-File "$InstallPath\alive.json" -Encoding UTF8 } catch { } } # Počáteční heartbeat ihned po startu (aby se server hned objevil jako online) Write-Log "Odesilam pocatecni heartbeat na $ConsoleUrl" Send-Heartbeat $lastHeartbeat = Get-Date Write-AliveFile $lastAliveWrite = Get-Date Write-Log "Agent bezi. Heartbeat interval: $HeartbeatInterval s" # Drzime jen JEDNU cekajici async operaci listeneru (nevolat BeginGetContext opakovane bez dokonceni) $pendingContext = $null while ($true) { # Cela smycka je chranena, aby jedina neosetrena vyjimka neukoncila server agenta. try { # Periodický heartbeat if (((Get-Date) - $lastHeartbeat).TotalSeconds -ge $HeartbeatInterval) { Send-Heartbeat $lastHeartbeat = Get-Date } # Casty zapis "alive" souboru (kazdych ~20s) pro stavovou ikonu if (((Get-Date) - $lastAliveWrite).TotalSeconds -ge 20) { Write-AliveFile $lastAliveWrite = Get-Date } # Non-blocking zpracování příchozích požadavků if ($null -ne $listener -and $listener.IsListening) { # Spustit novou async operaci jen pokud zadna necekat if ($null -eq $pendingContext) { $pendingContext = $listener.BeginGetContext($null, $null) } # Pockat max 2s na prichozi pozadavek if ($pendingContext.AsyncWaitHandle.WaitOne(2000)) { try { $context = $listener.EndGetContext($pendingContext) Process-HttpRequest -context $context } catch { Write-Log "Chyba pri prijmu pozadavku: $_" "ERROR" } $pendingContext = $null } } else { Start-Sleep -Seconds 2 } } catch { Write-Log "Neosetrena chyba v hlavni smycce (agent pokracuje): $_" "ERROR" Start-Sleep -Seconds 2 } }