<# Senkronis Yedekleme Ajani v1.0.0 Zamanlanmis gorev tarafindan her dakika calistirilir. - Heartbeat gonderir, yapilandirmayi ceker - Zamani gelen veya "simdi calistir" istenen isleri yurutur - MSSQL: BACKUP DATABASE -> .bak -> (zip) - FOLDER / USER_PROFILE: ZIP (7-Zip varsa AES-256 sifreli, yoksa Compress-Archive) - Cok parcali yukleme (50 MB), sonuc raporu, log, temp temizligi #> $ErrorActionPreference = 'Stop' [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $AgentVersion = '1.0.0' $BaseDir = Split-Path -Parent $MyInvocation.MyCommand.Path $ConfigPath = Join-Path $BaseDir 'config.json' $LogDir = Join-Path $BaseDir 'logs' $TempDir = Join-Path $BaseDir 'temp' $StatePath = Join-Path $BaseDir 'state.json' $LockPath = Join-Path $BaseDir 'agent.lock' New-Item -ItemType Directory -Force -Path $LogDir, $TempDir | Out-Null $script:RunLog = New-Object System.Text.StringBuilder function Log($msg, $level = 'INFO') { $line = "{0} [{1}] {2}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $level, $msg Add-Content -Path (Join-Path $LogDir ("agent-" + (Get-Date -Format 'yyyyMMdd') + '.log')) -Value $line -Encoding UTF8 [void]$script:RunLog.AppendLine($line) } # ---- Kilit: ayni anda iki ornek calismasin ---- if (Test-Path $LockPath) { $age = (Get-Date) - (Get-Item $LockPath).LastWriteTime if ($age.TotalHours -lt 12) { exit 0 } else { Remove-Item $LockPath -Force } } Set-Content -Path $LockPath -Value $PID try { # ---- Yapilandirma ---- if (-not (Test-Path $ConfigPath)) { Log 'config.json bulunamadi' 'ERROR'; exit 1 } $cfg = Get-Content $ConfigPath -Raw | ConvertFrom-Json $Server = $cfg.serverUrl.TrimEnd('/') $Headers = @{ 'X-Agent-Token' = $cfg.token } function Api($method, $path, $body) { $params = @{ Method = $method; Uri = "$Server$path"; Headers = $Headers; TimeoutSec = 120 } if ($body -ne $null) { $params.ContentType = 'application/json'; $params.Body = ($body | ConvertTo-Json -Depth 8 -Compress) } return Invoke-RestMethod @params } # Eski loglari temizle (30 gun) Get-ChildItem $LogDir -Filter 'agent-*.log' | Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-30) } | Remove-Item -Force -ErrorAction SilentlyContinue # ---- Durum (son calisma zamanlari) ---- $state = @{} if (Test-Path $StatePath) { try { (Get-Content $StatePath -Raw | ConvertFrom-Json).PSObject.Properties | ForEach-Object { $state[$_.Name] = $_.Value } } catch {} } function SaveState { $state | ConvertTo-Json | Set-Content $StatePath -Encoding UTF8 } # ---- Yapilandirmayi cek (heartbeat yerine gecer) ---- try { $conf = Api 'GET' '/api/agent/config' $null } catch { Log "Sunucuya ulasilamadi: $($_.Exception.Message)" 'WARN'; exit 0 } $now = [DateTime]::UtcNow $partSize = [int64]$conf.settings.partSizeBytes if (-not $partSize) { $partSize = 50MB } # ---- 7-Zip bul / indir (sifreli ZIP icin) ---- function Get-SevenZip { $cands = @("$env:ProgramFiles\7-Zip\7z.exe", "${env:ProgramFiles(x86)}\7-Zip\7z.exe", (Join-Path $BaseDir '7za.exe')) foreach ($c in $cands) { if (Test-Path $c) { return $c } } try { Log '7-Zip bulunamadi, tasinabilir surum indiriliyor...' $zipUrl = 'https://www.7-zip.org/a/7zr.exe' $dst = Join-Path $BaseDir '7za.exe' Invoke-WebRequest -Uri $zipUrl -OutFile $dst -UseBasicParsing -TimeoutSec 60 if (Test-Path $dst) { return $dst } } catch { Log "7-Zip indirilemedi: $($_.Exception.Message)" 'WARN' } return $null } # ---- VSS golge kopya (en iyi caba) ---- function New-Shadow($driveLetter) { try { $r = (Get-WmiObject -List Win32_ShadowCopy).Create("$driveLetter\", 'ClientAccessible') if ($r.ReturnValue -ne 0) { return $null } $sc = Get-WmiObject Win32_ShadowCopy | Where-Object { $_.ID -eq $r.ShadowID } return $sc } catch { Log "VSS olusturulamadi ($driveLetter): $($_.Exception.Message)" 'WARN'; return $null } } # ---- Arsiv olustur ---- function New-Archive($sources, $dest, $password, $excludeExt) { $seven = $null if ($password) { $seven = Get-SevenZip } if ($seven) { # 7zr.exe (tasinabilir) yalnizca .7z formatini destekler; tam 7z.exe ile sifreli ZIP uretilir $is7zr = (Split-Path $seven -Leaf) -ieq '7za.exe' if ($is7zr) { $dest = [IO.Path]::ChangeExtension($dest, '.7z'); $args = @('a', '-t7z', '-mx=5', '-ssw', '-y', "`"$dest`"") } else { $args = @('a', '-tzip', '-mx=5', '-ssw', '-y', "`"$dest`"") } if ($password) { $args += "-p$password"; if (-not $is7zr) { $args += '-mem=AES256' } else { $args += '-mhe=on' } } foreach ($e in $excludeExt) { if ($e) { $args += "-xr!*.$e" } } foreach ($s in $sources) { $args += "`"$s`"" } $p = Start-Process -FilePath $seven -ArgumentList $args -Wait -PassThru -NoNewWindow -RedirectStandardOutput (Join-Path $TempDir '7z-out.txt') -RedirectStandardError (Join-Path $TempDir '7z-err.txt') if ($p.ExitCode -gt 1) { throw "7-Zip hata kodu $($p.ExitCode): $(Get-Content (Join-Path $TempDir '7z-err.txt') -Raw)" } if ($p.ExitCode -eq 1) { $script:Warning = '7-Zip uyarisi: bazi dosyalar atlandi (kilitli/erisilemez).' } } else { if ($password) { $script:Warning = '7-Zip bulunamadigi icin ZIP sifrelenemedi.' } # Compress-Archive dislama desteklemez: haric tutulan uzantilar icin gecici kopya yapmak yerine uyari ver if ($excludeExt -and $excludeExt.Count -gt 0) { $script:Warning = 'Compress-Archive uzanti dislamayi desteklemez; tum dosyalar eklendi.' } Compress-Archive -Path $sources -DestinationPath $dest -CompressionLevel Optimal -Force } return $dest } # ---- MSSQL yedek ---- function Backup-Mssql($job, $workDir) { $c = $job.config $instance = if ($c.instance) { $c.instance } else { 'localhost' } $files = @() foreach ($db in $c.databases) { $bak = Join-Path $workDir ("$db-" + (Get-Date -Format 'yyyyMMdd-HHmm') + '.bak') $compress = if ($c.compress -eq $false) { '' } else { ', COMPRESSION' } $sql = "BACKUP DATABASE [$db] TO DISK = N'$bak' WITH INIT, CHECKSUM$compress" $args = @('-S', $instance, '-b', '-Q', $sql) if ($c.authType -eq 'SQL') { $args += @('-U', $c.sqlUser, '-P', $c.sqlPassword) } else { $args += '-E' } $sqlcmd = Get-Command sqlcmd -ErrorAction SilentlyContinue if ($sqlcmd) { $out = & sqlcmd @args 2>&1 if ($LASTEXITCODE -ne 0) { throw "SQL yedek hatasi ($db): $out" } } elseif (Get-Command Invoke-Sqlcmd -ErrorAction SilentlyContinue) { if ($c.authType -eq 'SQL') { Invoke-Sqlcmd -ServerInstance $instance -Username $c.sqlUser -Password $c.sqlPassword -Query $sql -QueryTimeout 0 } else { Invoke-Sqlcmd -ServerInstance $instance -Query $sql -QueryTimeout 0 } } else { throw 'sqlcmd veya SqlServer PowerShell modulu bulunamadi. SQL Server komut satiri araclarini kurun.' } if (-not (Test-Path $bak)) { throw "Yedek dosyasi olusmadi: $bak (SQL Server hizmetinin $workDir klasorune yazma izni olmali)" } $files += $bak Log "SQL yedek alindi: $db -> $([math]::Round((Get-Item $bak).Length/1MB,1)) MB" } return $files } # ---- Kullanici profili klasorleri ---- function Get-ProfilePaths($job) { $c = $job.config $paths = @() $users = @() if ($c.allUsers) { $users = Get-ChildItem 'C:\Users' -Directory | Where-Object { $_.Name -notin @('Public', 'Default', 'Default User', 'All Users') } | ForEach-Object { $_.FullName } } else { # Son oturum acan / en son degisen profil $users = @((Get-ChildItem 'C:\Users' -Directory | Where-Object { $_.Name -notin @('Public', 'Default', 'Default User', 'All Users') } | Sort-Object LastWriteTime -Descending | Select-Object -First 1).FullName) } foreach ($u in $users) { foreach ($f in $c.folders) { $p = Join-Path $u $f; if (Test-Path $p) { $paths += $p } } } return $paths } # ---- Cok parcali yukleme ---- function Upload-File($job, $filePath, $trigger, $startedAt) { $size = (Get-Item $filePath).Length $init = Api 'POST' '/api/agent/upload/init' @{ jobId = $job.id; fileName = (Split-Path $filePath -Leaf); sizeBytes = $size; trigger = $trigger; startedAt = $startedAt.ToString('o') } if (-not $init.ok) { throw "Yukleme baslatilamadi: $($init.error)" } $runId = $init.runId $ps = [int64]$init.partSizeBytes; if (-not $ps) { $ps = $partSize } $parts = @() $fs = [System.IO.File]::OpenRead($filePath) try { $partNo = 1 $buffer = New-Object byte[] $ps while (($read = $fs.Read($buffer, 0, $ps)) -gt 0) { $chunk = if ($read -eq $ps) { $buffer } else { $buffer[0..($read - 1)] } $pr = Api 'POST' '/api/agent/upload/part' @{ runId = $runId; partNumber = $partNo } $etag = $null for ($try = 1; $try -le 3; $try++) { try { $resp = Invoke-WebRequest -Method Put -Uri $pr.url -Body $chunk -ContentType 'application/octet-stream' -UseBasicParsing -TimeoutSec 600 $etag = $resp.Headers['ETag']; if ($etag -is [array]) { $etag = $etag[0] } break } catch { if ($try -eq 3) { throw "Parca $partNo yuklenemedi: $($_.Exception.Message)" }; Start-Sleep -Seconds (5 * $try) } } $parts += @{ ETag = $etag; PartNumber = $partNo } Log "Parca $partNo yuklendi ($([math]::Round($read/1MB,1)) MB)" $partNo++ } } finally { $fs.Close() } return @{ runId = $runId; parts = $parts } } # ---- Bir isi yurut ---- function Run-Job($job, $trigger) { $script:Warning = $null $script:RunLog.Clear() | Out-Null $started = [DateTime]::UtcNow $sw = [Diagnostics.Stopwatch]::StartNew() $workDir = Join-Path $TempDir ($job.id + '-' + (Get-Date -Format 'yyyyMMddHHmmss')) New-Item -ItemType Directory -Force -Path $workDir | Out-Null $runId = $null $shadows = @() try { Log "Is basliyor: $($job.name) [$($job.type)] ($trigger)" $stamp = Get-Date -Format 'yyyyMMdd-HHmm' $safeName = ($job.name -replace '[^\w\-]+', '_') $archive = Join-Path $workDir "$safeName-$stamp.zip" $sources = @() $excl = @() if ($job.type -eq 'MSSQL') { $sources = Backup-Mssql $job $workDir } elseif ($job.type -eq 'FOLDER') { $excl = @($job.config.excludeExtensions) foreach ($p in $job.config.paths) { if (-not (Test-Path $p)) { Log "Yol bulunamadi, atlaniyor: $p" 'WARN'; $script:Warning = "Bazi yollar bulunamadi: $p"; continue } $src = $p if ($job.config.useVss) { $drive = (Split-Path $p -Qualifier) $sc = $shadows | Where-Object { $_.Drive -eq $drive } | Select-Object -First 1 if (-not $sc) { $s = New-Shadow $drive; if ($s) { $sc = @{ Drive = $drive; Obj = $s }; $shadows += $sc } } if ($sc) { $link = Join-Path $workDir ('vss_' + $drive.TrimEnd(':')) if (-not (Test-Path $link)) { cmd /c mklink /d "$link" "$($sc.Obj.DeviceObject)\" | Out-Null } $src = Join-Path $link ($p.Substring(3)) Log "VSS uzerinden okunuyor: $p" } } $sources += $src } if ($sources.Count -eq 0) { throw 'Yedeklenecek hicbir klasor bulunamadi.' } } elseif ($job.type -eq 'USER_PROFILE') { $sources = Get-ProfilePaths $job if ($sources.Count -eq 0) { throw 'Secilen profil klasorleri bulunamadi.' } } else { throw "Bilinmeyen is turu: $($job.type)" } Log "Arsiv olusturuluyor: $archive" $archive = New-Archive $sources $archive $job.zipPassword $excl $size = (Get-Item $archive).Length Log "Arsiv hazir: $([math]::Round($size/1MB,1)) MB" $up = Upload-File $job $archive $trigger $started $runId = $up.runId $sw.Stop() $status = if ($script:Warning) { 'WARNING' } else { 'SUCCESS' } Log "Yukleme tamamlandi, sunucuya raporlaniyor ($status)" $done = Api 'POST' '/api/agent/upload/complete' @{ runId = $runId; parts = $up.parts; durationSec = [int]$sw.Elapsed.TotalSeconds; status = $status; warning = $script:Warning; log = $script:RunLog.ToString() } if (-not $done.ok) { throw "Sunucu tamamlama hatasi: $($done.error)" } Log "Is tamamlandi: $($job.name)" } catch { $sw.Stop() $err = $_.Exception.Message Log "HATA: $err" 'ERROR' try { Api 'POST' '/api/agent/upload/failed' @{ runId = $runId; jobId = $job.id; errorMessage = $err; durationSec = [int]$sw.Elapsed.TotalSeconds; trigger = $trigger; startedAt = $started.ToString('o'); log = $script:RunLog.ToString() } | Out-Null } catch { Log "Hata raporu gonderilemedi: $($_.Exception.Message)" 'ERROR' } } finally { foreach ($sc in $shadows) { try { $sc.Obj.Delete() } catch {} } try { Remove-Item $workDir -Recurse -Force -ErrorAction SilentlyContinue } catch {} } } # ---- Zamani gelen isleri bul ---- foreach ($job in $conf.jobs) { $trigger = $null if ($job.runNow) { $trigger = 'MANUAL' } elseif ($job.nextRunAt) { $next = ([DateTime]::Parse($job.nextRunAt)).ToUniversalTime() $lastLocal = $state[$job.id] $alreadyRan = $false if ($lastLocal) { $alreadyRan = ([DateTime]::Parse($lastLocal)).ToUniversalTime() -ge $next } # Zamani gelmis ve en fazla 6 saat gecikmisse calistir (daha eskiler sunucuda MISSED olarak islenir) if ($now -ge $next -and ($now - $next).TotalHours -le 6 -and -not $alreadyRan) { $trigger = 'SCHEDULE' } } if ($trigger) { $state[$job.id] = $now.ToString('o'); SaveState Run-Job $job $trigger } } } catch { Log "Beklenmeyen hata: $($_.Exception.Message)" 'ERROR' } finally { Remove-Item $LockPath -Force -ErrorAction SilentlyContinue }