<#
.SYNOPSIS
Genera un informe HTML interactivo de la topologia de Active Directory.
.DESCRIPTION
Solo lectura. Recoge bosque, dominios, sitios, subredes, enlaces, DC, GC, FSMO
y conexiones de replicacion. Genera un unico HTML sin Visio, Graphviz ni Internet.
.PARAMETER OutputPath
Ruta del informe HTML.
.PARAMETER Server
DC opcional contra el que realizar las consultas.
.EXAMPLE
.\Get-ADTopologyDiagram.ps1
.EXAMPLE
.\Get-ADTopologyDiagram.ps1 -OutputPath C:\Informes\TopologiaAD.html -Server dc01.contoso.local
#>
[CmdletBinding()]
param(
[string]$OutputPath = (Join-Path $PSScriptRoot ("Topologia-AD-{0}.html" -f (Get-Date -Format 'yyyyMMdd-HHmmss'))),
[string]$Server
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function ConvertTo-HtmlSafe([object]$Value) {
if ($null -eq $Value) { return '' }
return [System.Net.WebUtility]::HtmlEncode([string]$Value)
}
function Get-RdnName([object]$Dn) {
if (-not $Dn) { return '' }
$s = [string]$Dn
if ($s -match '^CN=([^,]+)') { return $matches[1] }
return $s
}
function Invoke-ADCmdlet {
param([string]$Name, [hashtable]$Parameters)
if ($Server) { $Parameters['Server'] = $Server }
& $Name @Parameters
}
try { Import-Module ActiveDirectory -ErrorAction Stop }
catch { throw "No se pudo cargar el modulo ActiveDirectory. Instala RSAT: Active Directory Domain Services and LDS Tools. Detalle: $($_.Exception.Message)" }
$parent = Split-Path -Parent $OutputPath
if ($parent -and -not (Test-Path $parent)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null }
Write-Host '[1/6] Leyendo bosque y dominios...' -ForegroundColor Cyan
$forest = Invoke-ADCmdlet Get-ADForest @{}
$domains = foreach ($domainName in $forest.Domains) {
$p = @{ Identity = $domainName }
Invoke-ADCmdlet Get-ADDomain $p
}
Write-Host '[2/6] Leyendo sitios, subredes y enlaces...' -ForegroundColor Cyan
$sites = @(Invoke-ADCmdlet Get-ADReplicationSite @{ Filter='*'; Properties=@('Description','Location','InterSiteTopologyGenerator') })
$subnets = @(Invoke-ADCmdlet Get-ADReplicationSubnet @{ Filter='*'; Properties=@('Site','Location','Description') })
$siteLinks = @(Invoke-ADCmdlet Get-ADReplicationSiteLink @{ Filter='*'; Properties=@('SitesIncluded','Cost','ReplicationFrequencyInMinutes','InterSiteTransportProtocol','Description') })
Write-Host '[3/6] Leyendo controladores de dominio...' -ForegroundColor Cyan
$dcs = @()
foreach ($domainName in $forest.Domains) {
$p = @{ Filter='*'; Server=$domainName }
$domainDcs = @(Get-ADDomainController @p)
foreach ($dc in $domainDcs) {
$roles = [System.Collections.Generic.List[string]]::new()
$dom = $domains | Where-Object DNSRoot -eq $domainName | Select-Object -First 1
if ($dom.PDCEmulator -eq $dc.HostName) { $roles.Add('PDC') }
if ($dom.RIDMaster -eq $dc.HostName) { $roles.Add('RID') }
if ($dom.InfrastructureMaster -eq $dc.HostName) { $roles.Add('Infrastructure') }
if ($forest.SchemaMaster -eq $dc.HostName) { $roles.Add('Schema') }
if ($forest.DomainNamingMaster -eq $dc.HostName) { $roles.Add('DomainNaming') }
$dcs += [pscustomobject]@{
Name=$dc.Name; HostName=$dc.HostName; Domain=$dc.Domain; Site=$dc.Site
IPv4Address=$dc.IPv4Address; IPv6Address=$dc.IPv6Address
OperatingSystem=$dc.OperatingSystem; OperatingSystemVersion=$dc.OperatingSystemVersion
IsGlobalCatalog=[bool]$dc.IsGlobalCatalog; IsReadOnly=[bool]$dc.IsReadOnly
Roles=($roles -join ', ')
}
}
}
Write-Host '[4/6] Leyendo conexiones y estado de replicacion...' -ForegroundColor Cyan
$connections = @()
try {
$connections = @(Invoke-ADCmdlet Get-ADReplicationConnection @{ Filter='*' } | ForEach-Object {
[pscustomobject]@{ Name=$_.Name; ReplicateFromDirectoryServer=$_.ReplicateFromDirectoryServer; ReplicateToDirectoryServer=$_.ReplicateToDirectoryServer; AutoGenerated=$_.AutoGenerated; EnabledConnection=$_.EnabledConnection }
})
} catch { Write-Warning "No se pudieron leer las conexiones: $($_.Exception.Message)" }
$failures = @()
try {
$failures = @(Invoke-ADCmdlet Get-ADReplicationFailure @{ Target=$forest.Name; Scope='Forest' } | ForEach-Object {
[pscustomobject]@{ Server=$_.Server; Partner=$_.Partner; FailureCount=$_.FailureCount; FirstFailureTime=$_.FirstFailureTime; LastError=$_.LastError }
})
} catch { Write-Warning "No se pudo consultar el estado de replicacion. El diagrama se generara igualmente. Detalle: $($_.Exception.Message)" }
Write-Host '[5/6] Preparando datos y comprobaciones...' -ForegroundColor Cyan
$siteData = foreach ($site in $sites) {
$siteName = $site.Name
[pscustomobject]@{
Name=$siteName; Description=$site.Description; Location=$site.Location
ISTG=(Get-RdnName $site.InterSiteTopologyGenerator)
Subnets=@($subnets | Where-Object { (Get-RdnName $_.Site) -eq $siteName } | ForEach-Object { [pscustomobject]@{ Name=$_.Name; Location=$_.Location; Description=$_.Description } })
DCs=@($dcs | Where-Object Site -eq $siteName)
}
}
$linkData = foreach ($link in $siteLinks) {
[pscustomobject]@{
Name=$link.Name; Cost=$link.Cost; Frequency=$link.ReplicationFrequencyInMinutes
Protocol=[string]$link.InterSiteTransportProtocol
Sites=@($link.SitesIncluded | ForEach-Object { Get-RdnName $_ })
Description=$link.Description
}
}
$warnings = [System.Collections.Generic.List[string]]::new()
foreach ($s in $siteData) {
if ($s.Subnets.Count -eq 0) { $warnings.Add("El sitio '$($s.Name)' no tiene subredes asociadas.") }
if ($s.DCs.Count -eq 0) { $warnings.Add("El sitio '$($s.Name)' no contiene controladores de dominio.") }
}
foreach ($sn in $subnets) { if (-not $sn.Site) { $warnings.Add("La subred '$($sn.Name)' no tiene sitio asociado.") } }
foreach ($f in $failures) { $warnings.Add("Fallo de replicacion: $($f.Server) con $($f.Partner), errores: $($f.FailureCount), ultimo: $($f.LastError)") }
$data = [ordered]@{
Generated=(Get-Date).ToString('yyyy-MM-dd HH:mm:ss'); Forest=$forest.Name
ForestMode=[string]$forest.ForestMode; Domains=@($domains | ForEach-Object { $_.DNSRoot })
Sites=@($siteData); Links=@($linkData); DCs=@($dcs); Connections=@($connections)
Failures=@($failures); Warnings=@($warnings)
}
$json = $data | ConvertTo-Json -Depth 12 -Compress
# Evita que una cadena procedente de AD pueda cerrar el bloque script del informe.
$json = $json -replace '</script>', '<\/script>'
$htmlTemplate = @'
<!doctype html><html lang="es"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Topologia Active Directory - __FOREST_HTML__</title>
<style>
:root{--blue:#185abd;--green:#dff3df;--line:#62748a;--bg:#f4f7fb;--red:#b42318;--amber:#b54708}*{box-sizing:border-box}body{margin:0;font-family:Segoe UI,Arial,sans-serif;background:var(--bg);color:#182230}header{background:#102a43;color:white;padding:20px 28px}header h1{margin:0 0 6px;font-size:25px}.meta{opacity:.86}.wrap{padding:18px}.cards{display:grid;grid-template-columns:repeat(5,minmax(130px,1fr));gap:12px;margin-bottom:16px}.card,.panel{background:white;border:1px solid #d8e0eb;border-radius:10px;box-shadow:0 1px 3px #0001}.card{padding:13px}.card b{font-size:25px;color:var(--blue);display:block}.panel{padding:14px;margin-bottom:16px}.toolbar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px}button{border:1px solid #b7c4d6;background:white;border-radius:6px;padding:7px 11px;cursor:pointer}button:hover{background:#edf4ff}#diagram{width:100%;height:720px;border:1px solid #cdd7e5;background:#fff;overflow:auto}svg{min-width:100%;min-height:100%}.site-box{fill:var(--green);stroke:#4a9152;stroke-width:2}.site-title{font-size:15px;font-weight:700;fill:#123}.dc-box{fill:#fff;stroke:#4776a8}.dc-text{font-size:11px;fill:#172b4d}.subnet-text{font-size:10px;fill:#475467}.link{stroke:var(--line);stroke-width:2;fill:none}.link-label{font-size:10px;fill:#344054}table{width:100%;border-collapse:collapse;font-size:13px}th,td{padding:8px;border-bottom:1px solid #e5eaf1;text-align:left;vertical-align:top}th{background:#edf3fa;position:sticky;top:0}.bad{color:var(--red);font-weight:600}.warn{color:var(--amber)}details{margin:6px 0}.empty{color:#667085}.legend span{margin-right:18px}.dot{display:inline-block;width:12px;height:12px;border-radius:3px;margin-right:5px;vertical-align:-1px}@media(max-width:900px){.cards{grid-template-columns:repeat(2,1fr)}}
</style></head><body><header><h1>Topologia de Active Directory</h1><div class="meta">Bosque: __FOREST_HTML__ | Generado: __GENERATED_HTML__ | Solo lectura</div></header>
<div class="wrap"><div class="cards"><div class="card"><b id="nSites"></b>Sitios</div><div class="card"><b id="nSubnets"></b>Subredes</div><div class="card"><b id="nDCs"></b>Controladores</div><div class="card"><b id="nLinks"></b>Enlaces</div><div class="card"><b id="nFails"></b>Fallos replicacion</div></div>
<div class="panel"><div class="toolbar"><button onclick="zoom(1.15)">Ampliar</button><button onclick="zoom(.87)">Reducir</button><button onclick="resetZoom()">Restablecer</button><button onclick="window.print()">Imprimir / PDF</button></div><div class="legend"><span><i class="dot" style="background:#dff3df;border:1px solid #4a9152"></i>Sitio</span><span><i class="dot" style="background:#fff;border:1px solid #4776a8"></i>Controlador</span><span>GC = Catalogo global; RODC = solo lectura</span></div><div id="diagram"><svg id="svg"></svg></div></div>
<div class="panel"><h2>Advertencias</h2><div id="warnings"></div></div>
<div class="panel"><h2>Controladores de dominio</h2><div style="overflow:auto"><table><thead><tr><th>Nombre</th><th>Dominio</th><th>Sitio</th><th>IP</th><th>Sistema</th><th>GC</th><th>RODC</th><th>FSMO</th></tr></thead><tbody id="dcRows"></tbody></table></div></div>
<div class="panel"><h2>Enlaces entre sitios</h2><div style="overflow:auto"><table><thead><tr><th>Enlace</th><th>Sitios incluidos</th><th>Coste</th><th>Frecuencia</th><th>Protocolo</th></tr></thead><tbody id="linkRows"></tbody></table></div></div>
<div class="panel"><h2>Conexiones de replicacion</h2><div style="overflow:auto"><table><thead><tr><th>Nombre</th><th>Origen</th><th>Destino</th><th>Automatica</th><th>Activa</th></tr></thead><tbody id="connRows"></tbody></table></div></div></div>
<script>const D=__AD_JSON__;let scale=1;const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
function add(tag,a={},text=''){let e=document.createElementNS('http://www.w3.org/2000/svg',tag);Object.entries(a).forEach(([k,v])=>e.setAttribute(k,v));if(text)e.textContent=text;svg.appendChild(e);return e}
function render(){svg.innerHTML='';const cols=Math.max(1,Math.ceil(Math.sqrt(D.Sites.length))),w=390,h=250,gx=55,gy=70;let pos={};D.Sites.forEach((s,i)=>{let x=30+(i%cols)*(w+gx),y=35+Math.floor(i/cols)*(h+gy);pos[s.Name]={x:x+w/2,y:y+h/2};});
D.Links.forEach((l,li)=>{for(let i=0;i<l.Sites.length;i++)for(let j=i+1;j<l.Sites.length;j++){let a=pos[l.Sites[i]],b=pos[l.Sites[j]];if(!a||!b)continue;add('line',{x1:a.x,y1:a.y,x2:b.x,y2:b.y,class:'link'});add('text',{x:(a.x+b.x)/2,y:(a.y+b.y)/2-5,class:'link-label'},`${l.Name} | coste ${l.Cost} | ${l.Frequency} min`);}});
D.Sites.forEach((s,i)=>{let x=30+(i%cols)*(w+gx),y=35+Math.floor(i/cols)*(h+gy);add('rect',{x,y,width:w,height:h,rx:12,class:'site-box'});add('text',{x:x+14,y:y+24,class:'site-title'},s.Name);add('text',{x:x+14,y:y+43,class:'subnet-text'},`Subredes: ${s.Subnets.map(v=>v.Name).join(', ')||'ninguna'}`);add('text',{x:x+14,y:y+60,class:'subnet-text'},`ISTG: ${s.ISTG||'no identificado'}`);s.DCs.slice(0,8).forEach((d,k)=>{let dx=x+14+(k%2)*181,dy=y+78+Math.floor(k/2)*39;add('rect',{x:dx,y:dy,width:170,height:31,rx:5,class:'dc-box'});let flags=[d.IsGlobalCatalog?'GC':'',d.IsReadOnly?'RODC':'',d.Roles].filter(Boolean).join(' | ');add('text',{x:dx+7,y:dy+13,class:'dc-text'},d.Name);add('text',{x:dx+7,y:dy+26,class:'subnet-text'},`${d.IPv4Address||'sin IP'}${flags?' | '+flags:''}`)});if(s.DCs.length>8)add('text',{x:x+14,y:y+h-10,class:'subnet-text'},`+ ${s.DCs.length-8} controladores adicionales`)});let rows=Math.ceil(D.Sites.length/cols);svg.setAttribute('viewBox',`0 0 ${30+cols*(w+gx)} ${50+rows*(h+gy)}`);}
function zoom(f){scale*=f;svg.style.width=(scale*100)+'%'}function resetZoom(){scale=1;svg.style.width='100%'}
document.getElementById('nSites').textContent=D.Sites.length;document.getElementById('nSubnets').textContent=D.Sites.reduce((n,s)=>n+s.Subnets.length,0);document.getElementById('nDCs').textContent=D.DCs.length;document.getElementById('nLinks').textContent=D.Links.length;document.getElementById('nFails').textContent=D.Failures.length;
document.getElementById('warnings').innerHTML=D.Warnings.length?'<ul>'+D.Warnings.map(x=>`<li class="${x.startsWith('Fallo')?'bad':'warn'}">${esc(x)}</li>`).join('')+'</ul>':'<p class="empty">No se han detectado advertencias basicas.</p>';
document.getElementById('dcRows').innerHTML=D.DCs.map(d=>`<tr><td>${esc(d.Name)}</td><td>${esc(d.Domain)}</td><td>${esc(d.Site)}</td><td>${esc(d.IPv4Address)}</td><td>${esc(d.OperatingSystem)} ${esc(d.OperatingSystemVersion)}</td><td>${d.IsGlobalCatalog?'Si':'No'}</td><td>${d.IsReadOnly?'Si':'No'}</td><td>${esc(d.Roles)}</td></tr>`).join('');
document.getElementById('linkRows').innerHTML=D.Links.map(l=>`<tr><td>${esc(l.Name)}</td><td>${esc(l.Sites.join(', '))}</td><td>${esc(l.Cost)}</td><td>${esc(l.Frequency)} min</td><td>${esc(l.Protocol)}</td></tr>`).join('');
document.getElementById('connRows').innerHTML=D.Connections.map(c=>`<tr><td>${esc(c.Name)}</td><td>${esc(c.ReplicateFromDirectoryServer)}</td><td>${esc(c.ReplicateToDirectoryServer)}</td><td>${c.AutoGenerated?'Si':'No'}</td><td>${c.EnabledConnection?'Si':'No'}</td></tr>`).join('');render();</script></body></html>
'@
$html = $htmlTemplate.Replace('__FOREST_HTML__', (ConvertTo-HtmlSafe $forest.Name))
$html = $html.Replace('__GENERATED_HTML__', (Get-Date -Format 'dd/MM/yyyy HH:mm:ss'))
$html = $html.Replace('__AD_JSON__', $json)
$html | Set-Content -Path $OutputPath -Encoding UTF8
Write-Host '[6/6] Informe generado correctamente:' -ForegroundColor Green
Write-Host $OutputPath -ForegroundColor Yellow
try { Start-Process $OutputPath } catch { }