software 2025 · una tarde · 0 €

Hyper-V ACL Editor: GUI para reglas de firewall en adaptadores virtuales

Hyper-V tiene un firewall de capa 2/3 estupendo a nivel de adaptador virtual — las llamadas Extended ACL — pero Microsoft no se ha molestado nunca en darle interfaz gráfica. Si quieres bloquear una IP en una VM, te toca aprenderte cuatro cmdlets de PowerShell y tirar de notas. Este script es lo contrario: un único .ps1 que abre una ventana WinForms con la lista de reglas, botones de añadir/editar/borrar y un combo para cambiar de VM. El código está en GitHub: github.com/unmateria/HyperV-ACL-editor.

Ventana principal del Hyper-V ACL Editor mostrando la lista de reglas con sus columnas: Action, Direction, LocalIP, RemoteIP, puertos, protocolo, peso, stateful, idle timeout e isolation ID
La ventana principal: arriba selector de VM y de adaptador, en medio la rejilla con todas las reglas aplicadas, abajo los botones de Add / Edit / Delete / Close. (IP del cliente tachadas en rojo.)

01 — El problema: Hyper-V tiene firewall, pero no tiene UI

Hyper-V, el hipervisor que viene de serie en Windows Server, permite filtrar tráfico de red directamente en el adaptador virtual de cada VM. Es decir: en lugar de configurar el firewall dentro del sistema invitado (que requiere acceso a la VM, agente, GPO, lo que sea), las reglas viven en el host. La VM no se entera de que la están filtrando — es como si el cable de red tuviera un cortafuegos físico antes de llegar a la tarjeta. Esto es muy útil para servidores que no controlas tú directamente, para aislar VMs entre sí en un mismo host, o para bloquear IPs concretas sin tocar nada del invitado.

El mecanismo se llama Extended ACL (Access Control List extendida) y está disponible desde Windows Server 2016. Cada regla se gestiona con tres cmdlets de PowerShell: Get-VMNetworkAdapterExtendedAcl, Add-VMNetworkAdapterExtendedAcl y Remove-VMNetworkAdapterExtendedAcl. Funcionan, pero son verbosos y la salida por consola de una lista de quince reglas es prácticamente ilegible — cada regla escupe veinte propiedades en bloque.

Lo más sangrante es que la consola de Hyper-V Manager — la GUI que viene con Windows Server para gestionar VMs — no tiene ni un botón para esto. Las Extended ACL existen, son potentes, y son completamente invisibles para el administrador medio. El único que las ve es el que se las sabe de memoria.

El script de este proyecto resuelve eso con un único fichero .ps1 de unas 500 líneas que monta una ventana de Windows Forms — la API clásica de UI de .NET, accesible desde PowerShell sin instalar nada — con la lista de reglas en una tabla y los botones para gestionarlas. Sin instalador, sin DLLs, sin servicios. Lo abres con doble click (en una sesión elevada) y a funcionar.

02 — Qué es exactamente una Extended ACL

Una regla Extended ACL en Hyper-V es una entrada que dice: «en este adaptador virtual, para el tráfico que cumple tales condiciones, haz esto». Las condiciones son la combinación clásica de capa 3/4: dirección (entrante/saliente), IP local y remota, puerto local y remoto, y protocolo. La acción es Allow o Deny. Es básicamente un firewall de paquetes, igual que iptables en Linux pero atado a la NIC virtual de una VM concreta.

Cada regla tiene además tres propiedades menos obvias:

Las reglas pueden aplicarse a dos sitios distintos: a un adaptador de una VM concreta, o a un adaptador del propio SO de gestión del host (lo que en Hyper-V se llama Management OS: el sistema operativo del servidor físico que actúa como host). Por eso el primer combo del script ofrece «Management OS» como opción además de cada VM individual.

03 — Anatomía del script

El proyecto es un único fichero, ACLEditor.ps1. La cabecera marca el script como obligatoriamente elevado y carga los ensamblados de Windows Forms — la API de UI clásica de .NET, presente en cualquier Windows desde XP, accesible desde PowerShell sin instalar nada porque PowerShell vive sobre el mismo runtime de .NET.

#Requires -RunAsAdministrator

# Cargar los ensamblados de Windows Forms
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing

# Comprobar que el módulo Hyper-V está disponible
try {
    Import-Module Hyper-V -ErrorAction Stop
} catch {
    [System.Windows.Forms.MessageBox]::Show(
        "Hyper-V module not found. Please install the Hyper-V role.",
        "Error", [System.Windows.Forms.MessageBoxButtons]::OK,
        [System.Windows.Forms.MessageBoxIcon]::Error
    )
    exit
}

La directiva #Requires -RunAsAdministrator es nativa de PowerShell: si el script se lanza desde una sesión sin elevación, PowerShell se niega a ejecutarlo y muestra un error claro en lugar de fallar de forma confusa al primer cmdlet de Hyper-V que requiera permisos. Toda la gestión de Hyper-V exige administrador, así que esto evita perder tiempo depurando errores de permisos.

El módulo Hyper-V es el que aporta los cmdlets Get-VM, Get-VMNetworkAdapter, Get-VMNetworkAdapterExtendedAcl, etc. Viene preinstalado en cualquier Windows Server con el rol de Hyper-V activado. Si no está, mostramos un MessageBox y salimos — más amigable que petar a la primera llamada.

El resto del fichero se organiza en tres bloques claros:

  1. Función Show-ACLEditor: define el diálogo modal de añadir/editar una regla. Se llama tanto desde el botón Add (sin parámetros) como desde Edit (pasándole la regla a editar como parámetro -EditRule).
  2. Definición de la ventana principal: combo de VM, combo de adaptador, botón de refresh, rejilla con las reglas y cuatro botones inferiores.
  3. Funciones de carga (Load-VMList, Load-AdapterList, Load-ACLRules) y handlers de eventos de los botones y combos.

04 — La ventana principal

Windows Forms en PowerShell es totalmente posicional: se crean los controles uno a uno, se les asigna posición y tamaño en píxeles a mano, y se añaden al formulario padre con $form.Controls.Add(...). No hay layout managers, no hay XAML, no hay binding. Es código de los 90, pero tiene una virtud importantísima: funciona literalmente en cualquier Windows sin dependencias.

$mainForm = New-Object System.Windows.Forms.Form
$mainForm.Text = "Hyper-V Extended ACL Editor"
$mainForm.Size = New-Object System.Drawing.Size(1000, 600)
$mainForm.StartPosition = "CenterScreen"

# Selector de VM
$lblVM = New-Object System.Windows.Forms.Label
$lblVM.Location = New-Object System.Drawing.Point(10, 20)
$lblVM.Size = New-Object System.Drawing.Size(100, 20)
$lblVM.Text = "VM or Host:"
$mainForm.Controls.Add($lblVM)

$cmbVM = New-Object System.Windows.Forms.ComboBox
$cmbVM.Location = New-Object System.Drawing.Point(120, 20)
$cmbVM.Size = New-Object System.Drawing.Size(200, 20)
$cmbVM.DropDownStyle = "DropDownList"
$cmbVM.DisplayMember = "Name"   # Mostrar la propiedad .Name de cada item
$mainForm.Controls.Add($cmbVM)

El detalle interesante es DisplayMember = "Name": al combo se le añaden objetos completos (instancias de VirtualMachine o VMNetworkAdapter), no strings. Pero Windows Forms muestra en pantalla solo la propiedad Name de cada uno. Cuando luego leamos $cmbVM.SelectedItem recuperamos el objeto entero — con todos sus métodos y propiedades —, no un texto. Eso ahorra tener que mantener un diccionario aparte para mapear «nombre seleccionado» a «objeto».

El control central es un DataGridView: la rejilla de Windows Forms equivalente al DataGrid de WPF o al QTableView de Qt. Le decimos que rellene el ancho automáticamente, que la selección sea por fila completa, que solo se pueda seleccionar una fila a la vez, y que sea de solo lectura (la edición se hace siempre por el diálogo modal, no in-place):

$grid = New-Object System.Windows.Forms.DataGridView
$grid.Location = New-Object System.Drawing.Point(10, 90)
$grid.Size = New-Object System.Drawing.Size(960, 400)
$grid.AutoSizeColumnsMode = [System.Windows.Forms.DataGridViewAutoSizeColumnsMode]::Fill
$grid.SelectionMode = [System.Windows.Forms.DataGridViewSelectionMode]::FullRowSelect
$grid.MultiSelect = $false
$grid.ReadOnly = $true
$grid.AllowUserToAddRows = $false
$grid.AllowUserToDeleteRows = $false
$mainForm.Controls.Add($grid)

05 — Cargar VMs y adaptadores

El primer combo se rellena con todas las VMs del host, más una entrada artificial al principio para representar el SO de gestión. Como las VMs son objetos reales devueltos por Get-VM, y el «Management OS» no lo es, lo simulamos con un PSCustomObject que tiene una propiedad Name — para que el combo lo muestre — y una propiedad bandera IsManagementOS que usaremos luego para distinguirlo:

function Load-VMList {
    $cmbVM.Items.Clear()

    # Primera entrada: SO de gestión (objeto sintético)
    $mgmt = [PSCustomObject]@{ Name = "Management OS"; IsManagementOS = $true }
    $cmbVM.Items.Add($mgmt) | Out-Null

    # Siguientes entradas: todas las VMs ordenadas por nombre
    $vms = Get-VM | Sort-Object Name
    foreach ($vm in $vms) {
        $cmbVM.Items.Add($vm) | Out-Null
    }

    if ($cmbVM.Items.Count -gt 0) {
        $cmbVM.SelectedIndex = 0
    }
}

El | Out-Null al final de cada Add es un idiom típico de PowerShell: el método Items.Add devuelve el índice del elemento añadido, y si no lo descartamos PowerShell se lo va a guardar en el pipeline implícito y aparecerá como salida de la función. Con el pipe a Out-Null nos lo cargamos en el sitio.

El segundo combo, el de adaptadores, depende del primero. Cada vez que cambia la selección de VM hay que vaciar y recargar este combo con los adaptadores de esa VM concreta — o de la consola de gestión si es el caso especial:

function Load-AdapterList {
    $cmbAdapter.Items.Clear()
    $selected = $cmbVM.SelectedItem

    if ($selected.IsManagementOS) {
        # El switch -ManagementOS pide los adaptadores virtuales del propio host
        $adapters = Get-VMNetworkAdapter -ManagementOS | Sort-Object Name
    } else {
        $adapters = Get-VMNetworkAdapter -VM $selected | Sort-Object Name
    }

    foreach ($adapter in $adapters) {
        $cmbAdapter.Items.Add($adapter) | Out-Null
    }

    if ($cmbAdapter.Items.Count -gt 0) {
        $cmbAdapter.SelectedIndex = 0
    } else {
        # Placeholder para que no se vea el combo vacío
        $placeholder = [PSCustomObject]@{ Name = "No adapters"; Adapter = $null }
        $cmbAdapter.Items.Add($placeholder) | Out-Null
        $cmbAdapter.SelectedIndex = 0
    }

    $script:selectedAdapter = $null
}

La variable $script:selectedAdapter usa el modificador de ámbito script: que en PowerShell hace que la variable sea visible desde todas las funciones del fichero. Es el equivalente más razonable a una variable global dentro del script — sin contaminar el ámbito global de la sesión de PowerShell del usuario.

06 — Leer las reglas del adaptador

Cuando el usuario selecciona un adaptador, hay que pedirle a Hyper-V todas sus reglas Extended ACL y volcarlas a la rejilla. Get-VMNetworkAdapterExtendedAcl devuelve un objeto por regla, con todas las propiedades que ya hemos visto. Las ordenamos por Weight para verlas siempre en orden de prioridad:

function Load-ACLRules {
    $adapter = $cmbAdapter.SelectedItem

    # El placeholder "No adapters" no es un adaptador real, lo descartamos
    if ($adapter -eq $null -or -not ($adapter -is [Microsoft.HyperV.PowerShell.VMNetworkAdapterBase])) {
        $grid.DataSource = $null
        $script:selectedAdapter = $null
        return
    }
    $script:selectedAdapter = $adapter

    try {
        $rules = Get-VMNetworkAdapterExtendedAcl -VMNetworkAdapter $adapter -ErrorAction Stop |
                 Sort-Object Weight
    } catch {
        [System.Windows.Forms.MessageBox]::Show(
            "Failed to load ACL rules: $($_.Exception.Message)",
            "Error", [System.Windows.Forms.MessageBoxButtons]::OK,
            [System.Windows.Forms.MessageBoxIcon]::Error
        )
        $rules = @()
    }
    ...
}

El DataGridView trabaja mejor con un System.Data.DataTable que con una lista de objetos de PowerShell. La razón práctica es que con un DataTable cada fila tiene columnas tipadas y nombres explícitos, lo que nos permite tener una columna oculta que guarda el objeto original de la regla. Así, cuando el usuario selecciona una fila y pulsa Edit, podemos rescatar el objeto Hyper-V original sin tener que volver a buscarlo:

$table = New-Object System.Data.DataTable
$table.Columns.Add("Action", [string]) | Out-Null
$table.Columns.Add("Direction", [string]) | Out-Null
$table.Columns.Add("LocalIP", [string]) | Out-Null
$table.Columns.Add("RemoteIP", [string]) | Out-Null
$table.Columns.Add("LocalPort", [string]) | Out-Null
$table.Columns.Add("RemotePort", [string]) | Out-Null
$table.Columns.Add("Protocol", [string]) | Out-Null
$table.Columns.Add("Weight", [string]) | Out-Null
$table.Columns.Add("Stateful", [string]) | Out-Null
$table.Columns.Add("IdleTimeout", [string]) | Out-Null
$table.Columns.Add("IsolationID", [string]) | Out-Null
$table.Columns.Add("Rule", [object]) | Out-Null   # Columna OCULTA con el objeto original

foreach ($rule in $rules) {
    $row = $table.NewRow()
    $row["Action"]      = $rule.Action
    $row["Direction"]   = $rule.Direction
    $row["LocalIP"]     = if ($rule.LocalIPAddress)  { $rule.LocalIPAddress }  else { "" }
    $row["RemoteIP"]    = if ($rule.RemoteIPAddress) { $rule.RemoteIPAddress } else { "" }
    $row["LocalPort"]   = if ($rule.LocalPort)       { $rule.LocalPort }       else { "" }
    $row["RemotePort"]  = if ($rule.RemotePort)      { $rule.RemotePort }      else { "" }
    $row["Protocol"]    = if ($rule.Protocol)        { $rule.Protocol }        else { "" }
    $row["Weight"]      = $rule.Weight
    $row["Stateful"]    = if ($rule.Stateful) { "Yes" } else { "No" }
    $row["IdleTimeout"] = if ($rule.IdleSessionTimeout) { $rule.IdleSessionTimeout } else { "" }
    $row["IsolationID"] = if ($rule.IsolationID)        { $rule.IsolationID }        else { "" }
    $row["Rule"]        = $rule   # ← aquí guardamos el objeto completo
    $table.Rows.Add($row) | Out-Null
}

$grid.DataSource = $table
# Ocultamos la columna "Rule" — está ahí para uso interno, no para mostrar
if ($grid.Columns.Contains("Rule")) {
    $grid.Columns["Rule"].Visible = $false
}

07 — El diálogo de añadir/editar

La función Show-ACLEditor abre un diálogo modal con un campo por cada propiedad de la regla. Acepta un parámetro opcional -EditRule: si se pasa, los campos se rellenan con los valores de esa regla y el título de la ventana cambia a «Edit ACL Rule»; si no, se queda con valores por defecto y el título es «Add ACL Rule». La función retorna un PSCustomObject con los valores introducidos, o $null si el usuario cancela.

function Show-ACLEditor {
    param(
        $EditRule = $null   # Regla existente (modo edición) o $null (modo creación)
    )

    $form = New-Object System.Windows.Forms.Form
    $form.Text = if ($EditRule) { "Edit ACL Rule" } else { "Add ACL Rule" }
    $form.Size = New-Object System.Drawing.Size(450, 460)
    $form.StartPosition = "CenterParent"
    $form.FormBorderStyle = "FixedDialog"   # No se puede redimensionar
    $form.MaximizeBox = $false
    $form.MinimizeBox = $false
    ...
}

El protocolo merece un comentario aparte. Hyper-V acepta «TCP», «UDP» o el número del protocolo IP — por ejemplo «1» para ICMP. Para que el usuario no tenga que recordar que ICMP es 1, el combo lo presenta como «ICMP (1)» y traducimos a «1» antes de devolverlo. Y como hay infinidad de protocolos IP que no incluimos en la lista, el combo es DropDown en lugar de DropDownList — el usuario puede teclear cualquier valor a mano:

$cmbProtocol = New-Object System.Windows.Forms.ComboBox
$cmbProtocol.DropDownStyle = "DropDown"   # Editable, no solo seleccionable
$cmbProtocol.Items.AddRange(@("", "TCP", "UDP", "ICMP (1)"))
...

# Al devolver, traducir la etiqueta amigable al valor real
if ($out.Protocol -eq "ICMP (1)") {
    $out.Protocol = "1"
}

Para los campos numéricos usamos NumericUpDown (las flechitas arriba/abajo de los formularios de Windows de toda la vida). El peso permite todo el rango de un Int32 con signo, porque así lo acepta el cmdlet de Hyper-V:

$numWeight = New-Object System.Windows.Forms.NumericUpDown
$numWeight.Minimum = -2147483648
$numWeight.Maximum = 2147483647
$numWeight.Value = 1

Al cerrar con OK, recogemos los valores y construimos el objeto de salida. Lo importante: las propiedades vacías se eliminan con $out.PSObject.Properties.Remove(...) en lugar de pasarse como string vacío. Esto es porque el cmdlet Add-VMNetworkAdapterExtendedAcl trata «no especificado» y «especificado pero vacío» de manera distinta — y queremos que «no especificado» signifique «cualquier valor», que es lo que se interpreta cuando se omite el parámetro entero:

# Quitar parámetros opcionales que han quedado vacíos
foreach ($key in @("LocalIPAddress","RemoteIPAddress","LocalPort","RemotePort","Protocol")) {
    if ([string]::IsNullOrEmpty($out.$key)) {
        $out.PSObject.Properties.Remove($key)
    }
}
if ($out.IdleSessionTimeout -eq $null) { $out.PSObject.Properties.Remove("IdleSessionTimeout") }
if ($out.IsolationID -eq $null)        { $out.PSObject.Properties.Remove("IsolationID") }

return $out

08 — Aplicar los cambios al firewall

El handler de Add recoge el objeto que devuelve el diálogo, monta una hashtable con los parámetros y la pasa al cmdlet usando splatting (@params). El splatting es la sintaxis idiomática de PowerShell para pasar muchos parámetros a un cmdlet: en vez de escribir cuarenta -Param value en una línea ilegible, se construye un hashtable y se prefija con @ en lugar de $ en la llamada:

$btnAdd.add_Click({
    if ($script:selectedAdapter -eq $null) {
        [System.Windows.Forms.MessageBox]::Show(
            "Please select a network adapter first.",
            "Error", [System.Windows.Forms.MessageBoxButtons]::OK,
            [System.Windows.Forms.MessageBoxIcon]::Error
        )
        return
    }

    $newRule = Show-ACLEditor
    if ($newRule -ne $null) {
        try {
            # Parámetros obligatorios
            $params = @{
                VMNetworkAdapter = $script:selectedAdapter
                Action           = $newRule.Action
                Direction        = $newRule.Direction
                Weight           = $newRule.Weight
                Stateful         = $newRule.Stateful
            }

            # Parámetros opcionales: solo se añaden si la regla los trae
            if ($newRule.PSObject.Properties["LocalIPAddress"])     { $params.LocalIPAddress    = $newRule.LocalIPAddress }
            if ($newRule.PSObject.Properties["RemoteIPAddress"])    { $params.RemoteIPAddress   = $newRule.RemoteIPAddress }
            if ($newRule.PSObject.Properties["LocalPort"])          { $params.LocalPort         = $newRule.LocalPort }
            if ($newRule.PSObject.Properties["RemotePort"])         { $params.RemotePort        = $newRule.RemotePort }
            if ($newRule.PSObject.Properties["Protocol"])           { $params.Protocol          = $newRule.Protocol }
            if ($newRule.PSObject.Properties["IdleSessionTimeout"]) { $params.IdleSessionTimeout= $newRule.IdleSessionTimeout }
            if ($newRule.PSObject.Properties["IsolationID"])        { $params.IsolationID       = $newRule.IsolationID }

            # Splatting: cada clave del hashtable se convierte en parámetro -Clave
            Add-VMNetworkAdapterExtendedAcl @params -ErrorAction Stop

            Load-ACLRules   # Refrescar la rejilla
            [System.Windows.Forms.MessageBox]::Show("Rule added successfully.", ...)
        } catch {
            [System.Windows.Forms.MessageBox]::Show("Failed to add rule: $($_.Exception.Message)", ...)
        }
    }
})

Editar es ligeramente más complicado porque los cmdlets de Hyper-V no permiten modificar reglas Extended ACL en sitio: no existe un Set-VMNetworkAdapterExtendedAcl. La única forma de cambiar una regla es borrarla y crearla de nuevo. El handler de Edit hace exactamente eso: pasa $ruleObj | Remove-VMNetworkAdapterExtendedAcl y luego un Add con los nuevos valores. Si el segundo paso falla la regla original ya se ha perdido — por eso al final llamamos a Load-ACLRules también en el branch del catch, para que el usuario vea el estado real del firewall y no una versión obsoleta.

Borrar es lo más simple: se pide confirmación con un MessageBox, y si el usuario dice Yes se pasa el objeto regla por pipeline a Remove-VMNetworkAdapterExtendedAcl:

$btnDelete.add_Click({
    if ($grid.SelectedRows.Count -eq 0) {
        [System.Windows.Forms.MessageBox]::Show("Please select a rule to delete.", ...)
        return
    }

    $selectedRow = $grid.SelectedRows[0]
    $ruleObj = $selectedRow.DataBoundItem["Rule"]   # Recuperamos el objeto de la columna oculta

    $answer = [System.Windows.Forms.MessageBox]::Show(
        "Are you sure you want to delete the selected rule?",
        "Confirm Delete", [System.Windows.Forms.MessageBoxButtons]::YesNo,
        [System.Windows.Forms.MessageBoxIcon]::Question
    )
    if ($answer -eq "Yes") {
        try {
            $ruleObj | Remove-VMNetworkAdapterExtendedAcl -ErrorAction Stop
            Load-ACLRules
        } catch {
            [System.Windows.Forms.MessageBox]::Show("Failed to delete rule: $($_.Exception.Message)", ...)
        }
    }
})

09 — Cómo usarlo

El uso es trivial:

  1. Descarga ACLEditor.ps1 del repositorio en GitHub.
  2. Cópialo a una carpeta del host Hyper-V (cualquier sitio sirve).
  3. Abre PowerShell como administrador.
  4. Si es la primera vez que ejecutas un script de PowerShell sin firmar en esa máquina, ajusta la política de ejecución para la sesión actual:
    Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
    
    -Scope Process hace que el cambio dure solo lo que dure la sesión actual de PowerShell — es lo más seguro, no toca la política global de la máquina.
  5. Lanza el script:
    .\ACLEditor.ps1
    

Selecciona arriba la VM (o «Management OS») y luego el adaptador concreto. La rejilla se rellena sola con las reglas que ya existen ordenadas por peso. Add abre el diálogo en blanco; Edit lo abre con los valores de la fila seleccionada; Delete pregunta y borra; Refresh recarga la lista entera de VMs y adaptadores por si has creado o quitado alguna fuera del editor.

Una nota práctica: cuando la rejilla está vacía no significa que no haya filtrado de tráfico — significa que no hay ninguna regla Extended ACL aplicada a ese adaptador, y por defecto eso quiere decir «todo permitido» (las Extended ACL no son deny by default). En cuanto añades la primera regla Allow, el comportamiento por defecto pasa a ser deny, y a partir de ahí solo pasa lo que esté explícitamente permitido. Es importante tenerlo en cuenta: la primera regla cambia el modelo entero. Si vas a empezar a usar Extended ACL en un adaptador con tráfico activo, lo razonable es añadir primero una regla Allow ANY con peso bajo y luego ir afinando.

El código completo está publicado bajo licencia abierta en github.com/unmateria/HyperV-ACL-editor. Issues y pull requests bienvenidos.