# ============================================================================ # Elaborado por Bryan Ramirez para Banco Promerica # ---------------------------------------------------------------------------- # Sync-WebConfig.ps1 - Replica bidireccional REALTIME de CARPETAS ENTERAS # entre 2 servidores (10.9.6.32 <-> 10.9.6.33). # # Replica TODO el contenido de cada carpeta de $AppPaths: subcarpetas, # archivos, atributos, timestamps y permisos NTFS/ACL + propietario. # - Cambio/creacion de un archivo -> se propaga al INSTANTE (solo ese archivo). # - Borrado de un archivo -> se borra tambien en el otro (al instante). # - Gana siempre la version MAS RECIENTE (robocopy /XO). # # Realtime sin pesar ni alarmar EDR/antivirus: # - FileSystemWatcher da la RUTA EXACTA del archivo que cambio -> se copia # SOLO ese archivo (no se reescanea ni se hace md5 de todo el arbol). # - Rafagas grandes (>$BulkThreshold) -> un robocopy de carpeta. # - Solo binarios nativos firmados de Windows (robocopy.exe). Nada que instalar. # Archivos a medio escribir: # - Antes de copiar se verifica que el archivo este ESTABLE (3 muestras). # Si sigue creciendo queda en cola y se reintenta -> nunca copia a medias. # Borrado seguro (sin falsos borrados): # - El baseline = archivos confirmados en AMBOS lados tras el ultimo sync. # Un archivo que aun NO llego al peer NO entra al baseline -> se trata como # nuevo y se reintdenta; jamas se borra por no haber llegado. # - El paso de borrado se OMITE si el peer no esta accesible o si su lectura # viene vacia/sospechosa (protege de borrado masivo por glitch de red). # - Cada borrado se CONFIRMA con Test-Path directo del archivo puntual. # Anti-ping-pong: # - /XO solo copia si el origen es mas nuevo; tras copiar conserva el # timestamp, asi la direccion inversa lo omite. # ============================================================================ $ErrorActionPreference = 'Stop' # ----------------------------- CONFIG --------------------------------------- $NodeA = '10.9.6.32' $NodeB = '10.9.6.33' $AppPaths = @( 'E:\Produccion' 'E:\Fotos_Padron' 'C:\inetpub\wwwroot' ) $LogFile = 'C:\Supervisor\logs\webconfig_sync.log' $StateDir = 'C:\Supervisor\state' $ReconcileSeconds = 30 $DebounceMs = 250 $StableMs = 300 # ventana entre muestras para "archivo terminado" $BulkThreshold = 25 $RoboFile = @('/COPY:DATSO','/B','/XO','/R:2','/W:2','/NJH','/NJS','/NDL','/NP','/NFL') $RoboCommon = @('/E','/XO','/COPY:DATSO','/DCOPY:DAT','/B','/R:2','/W:2','/XJ', '/NJH','/NJS','/NDL','/NP','/NFL') # ---------------------------------------------------------------------------- $localIPs = @(Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | Select-Object -Expand IPAddress) if ($localIPs -contains $NodeA) { $LocalIP = $NodeA; $Peer = $NodeB } elseif ($localIPs -contains $NodeB) { $LocalIP = $NodeB; $Peer = $NodeA } else { throw "Este host no tiene $NodeA ni $NodeB. Revisa NodeA/NodeB en el CONFIG." } foreach ($d in @((Split-Path $LogFile), $StateDir)) { if (-not (Test-Path $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null } } $jobs = foreach ($ap in $AppPaths) { $full = $ap.TrimEnd('\') $qualifier = Split-Path -Qualifier $full $driveLetter = $qualifier.TrimEnd(':') $rest = $full.Substring($qualifier.Length).TrimStart('\') [pscustomobject]@{ AppPath = $full LocalDir = $full RemoteDir = "\\$Peer\$driveLetter`$\$rest" ShareRoot = "\\$Peer\$driveLetter`$" StateFile = Join-Path $StateDir (($full -replace '[^A-Za-z0-9]','_') + '.txt') } } function Log([string]$m){ $line = "[{0}] [{1}->{2}] {3}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $LocalIP, $Peer, $m Add-Content -LiteralPath $LogFile -Value $line } function Get-RelSet([string]$dir){ $base = $dir.TrimEnd('\') + '\' $set = @{} Get-ChildItem -LiteralPath $dir -Recurse -File -Force -ErrorAction SilentlyContinue | ForEach-Object { $set[$_.FullName.Substring($base.Length)] = $true } return $set } # El archivo termino de escribirse? Estable en 3 muestras (~2x StableMs). # NO usa apertura exclusiva: archivos que IIS tiene abiertos pero estables pasan. function Test-FileReady([string]$path){ try { $sig = @() for ($i = 0; $i -lt 3; $i++) { $it = Get-Item -LiteralPath $path -Force -ErrorAction Stop $sig += "$($it.Length)|$($it.LastWriteTimeUtc.Ticks)" if ($i -lt 2) { Start-Sleep -Milliseconds $StableMs } } return ($sig[0] -eq $sig[1] -and $sig[1] -eq $sig[2]) } catch { return $false } } $mutex = New-Object System.Threading.Mutex($false, 'Global\WebConfigSyncReconcile') function Invoke-Robo($src,$dst){ $null = & robocopy $src $dst @RoboCommon; return $LASTEXITCODE } # --- Propagacion INSTANTANEA de un solo archivo (realtime) --- function Propagate-File($job, [string]$rel, [string]$type){ if ([string]::IsNullOrEmpty($rel)) { return } $peerPath = Join-Path $job.RemoteDir $rel if ($type -eq 'Deleted') { if (Test-Path -LiteralPath $peerPath) { Remove-Item -LiteralPath $peerPath -Force -ErrorAction SilentlyContinue Log "DEL peer (instant): $rel" } return } $localPath = Join-Path $job.LocalDir $rel if (-not (Test-Path -LiteralPath $localPath -PathType Leaf)) { return } $srcDir = Split-Path -Parent $localPath $dstDir = Split-Path -Parent $peerPath $name = Split-Path -Leaf $localPath $null = & robocopy $srcDir $dstDir $name @RoboFile if ($LASTEXITCODE -ge 8) { Log "ERROR push instant ($rel) exit=$LASTEXITCODE" } else { Log "PUSH peer (instant): $rel" } } # --- Reconcile COMPLETO de una carpeta (red de seguridad / rafaga grande) --- function Sync-Folder($job, [string]$reason){ if (-not $mutex.WaitOne(30000)) { return } try { if (-not (Test-Path -LiteralPath $job.ShareRoot)) { Log "WARN [$($job.AppPath)] peer no accesible: $($job.ShareRoot) (reintento en ${ReconcileSeconds}s)" return } if (-not (Test-Path -LiteralPath $job.LocalDir)) { Log "WARN [$($job.AppPath)] carpeta local no existe: $($job.LocalDir)" return } $remoteOk = Test-Path -LiteralPath $job.RemoteDir # la carpeta del peer existe/lee $L = Get-RelSet $job.LocalDir $R = if ($remoteOk) { Get-RelSet $job.RemoteDir } else { @{} } # --- BORRADO seguro via baseline --- $dels = 0 $baseline = if (Test-Path -LiteralPath $job.StateFile) { @(Get-Content -LiteralPath $job.StateFile -EA SilentlyContinue) } else { @() } # Guard anti-borrado-masivo: si el peer no lee o devuelve vacio teniendo baseline+local, NO borrar. $suspicious = (-not $remoteOk) -or ($R.Count -eq 0 -and $baseline.Count -gt 0 -and $L.Count -gt 0) if ($baseline.Count -gt 0 -and -not $suspicious) { foreach ($rel in $baseline) { if ([string]::IsNullOrEmpty($rel)) { continue } $inL = $L.ContainsKey($rel); $inR = $R.ContainsKey($rel) if ($inL -and -not $inR) { # estaba antes y ya no esta en peer -> CONFIRMAR con Test-Path directo if (-not (Test-Path -LiteralPath (Join-Path $job.RemoteDir $rel))) { Remove-Item -LiteralPath (Join-Path $job.LocalDir $rel) -Force -EA SilentlyContinue $L.Remove($rel); $dels++; Log "DEL local (borrado en peer): $rel" } } elseif ($inR -and -not $inL) { if (-not (Test-Path -LiteralPath (Join-Path $job.LocalDir $rel))) { Remove-Item -LiteralPath (Join-Path $job.RemoteDir $rel) -Force -EA SilentlyContinue $R.Remove($rel); $dels++; Log "DEL peer (borrado en local): $rel" } } } } elseif ($suspicious) { Log "WARN [$($job.AppPath)] paso de borrado OMITIDO (lectura del peer vacia/sospechosa: protege de borrado masivo)" } # --- COPIA (adds + gana el mas nuevo), no borra --- $cPush = Invoke-Robo $job.LocalDir $job.RemoteDir $cPull = Invoke-Robo $job.RemoteDir $job.LocalDir if (($cPush -ge 8) -or ($cPull -ge 8)) { Log "ERROR [$($job.AppPath)] robocopy push=$cPush pull=$cPull ($reason)" } # --- Baseline = INTERSECCION (solo lo confirmado en AMBOS lados) --- # Asi un archivo que NO llego al peer no entra al baseline y nunca se borra # por "no estar en el peer"; se re-intenta como si fuera nuevo. try { $Lf = Get-RelSet $job.LocalDir $Rf = if (Test-Path -LiteralPath $job.RemoteDir) { Get-RelSet $job.RemoteDir } else { @{} } @($Lf.Keys | Where-Object { $Rf.ContainsKey($_) }) | Set-Content -LiteralPath $job.StateFile -Encoding UTF8 } catch { Log "WARN [$($job.AppPath)] no se pudo guardar baseline: $($_.Exception.Message)" } if ((($cPush -band 1) -ne 0) -or (($cPull -band 1) -ne 0) -or $dels -gt 0) { Log "SYNC [$($job.AppPath)] push=$cPush pull=$cPull borrados=$dels ($reason)" } } catch { Log "ERROR [$($job.AppPath)] ($reason): $($_.Exception.Message)" } finally{ $mutex.ReleaseMutex() } } # --- Instancia unica --- try { Get-CimInstance Win32_Process -Filter "Name='powershell.exe'" -ErrorAction SilentlyContinue | Where-Object { $_.ProcessId -ne $PID -and $_.CommandLine -like '*Sync-WebConfig.ps1*' } | ForEach-Object { Log "Instancia previa detectada (PID $($_.ProcessId)) -> la termino (instancia unica)" Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } } catch { } Log "==== Sync iniciado ==== carpetas=$($jobs.Count) Local=$LocalIP Peer=$Peer" foreach ($job in $jobs) { Log " carpeta: $($job.LocalDir) <-> $($job.RemoteDir)" } foreach ($job in $jobs) { Sync-Folder $job 'startup' } # --- Watchers: uno por carpeta local, RECURSIVO; encolan la RUTA del cambio --- $queue = [System.Collections.Queue]::Synchronized((New-Object System.Collections.Queue)) $sb = { $e = $Event.SourceEventArgs $old = if ($e -is [System.IO.RenamedEventArgs]) { $e.OldFullPath } else { $null } $Event.MessageData.Queue.Enqueue([pscustomobject]@{ Key = $Event.MessageData.Key; Path = $e.FullPath; Old = $old; Type = [string]$e.ChangeType }) } $watchers = @() for ($i = 0; $i -lt $jobs.Count; $i++) { $job = $jobs[$i] if (-not (Test-Path -LiteralPath $job.LocalDir)) { Log "WARN [$($job.AppPath)] carpeta local no existe; watcher omitido (el timer igual la revisa): $($job.LocalDir)" continue } $fsw = New-Object System.IO.FileSystemWatcher $job.LocalDir $fsw.NotifyFilter = [System.IO.NotifyFilters]'LastWrite, Size, FileName, DirectoryName, CreationTime' $fsw.IncludeSubdirectories = $true $fsw.EnableRaisingEvents = $true $md = [pscustomobject]@{ Queue = $queue; Key = $i } Register-ObjectEvent $fsw Changed -MessageData $md -Action $sb | Out-Null Register-ObjectEvent $fsw Created -MessageData $md -Action $sb | Out-Null Register-ObjectEvent $fsw Renamed -MessageData $md -Action $sb | Out-Null Register-ObjectEvent $fsw Deleted -MessageData $md -Action $sb | Out-Null $watchers += $fsw } # Cola de DIFERIDOS: archivos detectados que aun no estan listos (a medio escribir). $deferred = [ordered]@{} $lastTimer = Get-Date while ($true) { Start-Sleep -Milliseconds 250 if ($queue.Count -gt 0) { Start-Sleep -Milliseconds $DebounceMs while ($queue.Count -gt 0) { $it = $queue.Dequeue() $job = $jobs[$it.Key] $base = $job.LocalDir.TrimEnd('\') + '\' if ($it.Path.StartsWith($base, [StringComparison]::OrdinalIgnoreCase)) { $rel = $it.Path.Substring($base.Length) $deferred["$($it.Key)|$rel"] = [pscustomobject]@{ Key=$it.Key; Rel=$rel; Type=$it.Type } } if ($it.Old -and $it.Old.StartsWith($base, [StringComparison]::OrdinalIgnoreCase)) { $relOld = $it.Old.Substring($base.Length) $deferred["$($it.Key)|$relOld"] = [pscustomobject]@{ Key=$it.Key; Rel=$relOld; Type='Deleted' } } } } if ($deferred.Count -gt 0) { foreach ($g in (@($deferred.Values) | Group-Object Key)) { $job = $jobs[[int]$g.Name] if ($g.Count -gt $BulkThreshold) { Sync-Folder $job 'fswatch-bulk' foreach ($v in $g.Group) { $deferred.Remove("$($v.Key)|$($v.Rel)") } } else { foreach ($v in $g.Group) { $key = "$($v.Key)|$($v.Rel)" $loc = Join-Path $job.LocalDir $v.Rel if ($v.Type -eq 'Deleted') { Propagate-File $job $v.Rel 'Deleted'; $deferred.Remove($key) } elseif (-not (Test-Path -LiteralPath $loc -PathType Leaf)) { $deferred.Remove($key) } elseif (Test-FileReady $loc) { Propagate-File $job $v.Rel $v.Type; $deferred.Remove($key) } } } } $lastTimer = Get-Date } elseif (((Get-Date) - $lastTimer).TotalSeconds -ge $ReconcileSeconds) { foreach ($job in $jobs) { Sync-Folder $job 'timer' } $lastTimer = Get-Date } }