jueves, 22 de octubre de 2026

WINDOWS. AD. ADTopologyDiagram

 


Propósito:

Me han pedido hacer un informe de la Topologia de mi AD. En su momento usé la herramienta de microsoft "Microsoft Active Directory Topology Diagrammer" (https://gonsystem.blogspot.com/2010/10/topologia-del-ad.html), pero la han descontinuado. Así que con la ayuda de la IA he intentado generar un resultado parecido sin usar herramientas que no vengan por defecto en el S.O. de Microsoft. 

Pasos:


 El programa consulta en modo solo lectura:
  • Bosque y dominios.
  • Sitios de Active Directory.
  • Subredes asociadas a cada sitio.
  • Enlaces entre sitios.
  • Coste y frecuencia de replicación de cada enlace.
  • Controladores de dominio por sitio.
  • Dirección IPv4.
  • Versión del sistema operativo.
  • Catálogos globales.
  • RODC.
  • Roles FSMO.
  • ISTG de cada sitio.
  • Conexiones de replicación.
  • Fallos de replicación detectados.
  • Sitios sin subredes.
  • Sitios sin controladores de dominio.
  • Subredes sin sitio asociado.
El resultado es un HTML autocontenido, por lo que:

  • No necesita Microsoft Visio.
  • No necesita Graphviz.
  • No necesita conexión a Internet.
  • Puede abrirse en Edge o Chrome.
  • Puede imprimirse o guardarse como PDF.
  • Incluye zoom básico, diagrama y tablas técnicas.

SALIDA:




CODIGO

COMANDO: .\Get-ADTopologyDiagram.ps1


<#
.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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 { }



by GoN | Published: Oct 2026 | Last Updated:


No hay comentarios: