software 2025 · varios ratos de fin de semana · 0 €

GuardianRDP: Bloqueo automático de atacantes RDP en Windows

Un servidor Windows con RDP abierto a internet aguanta, como mucho, unas horas antes de que lleguen los primeros hammerers: scripts automatizados que prueban miles de contraseñas sin parar. Aunque tengas una contraseña decente no pasa nada, pero los intentos llenan los logs, consumen CPU y ponen nervioso. GuardianRDP es un servicio Windows en C# que vigila las conexiones TCP en tiempo real y mete en el firewall a cualquier IP que no esté en lista blanca y toque los puertos críticos. Sin base de datos, sin agentes externos, sin complicaciones.

Ventana principal de GuardianRDP mostrando el log de IPs bloqueadas en tiempo real
GuardianRDP en funcionamiento: log en tiempo real de IPs detectadas y bloqueadas automáticamente en el firewall de Windows.

01 — El problema: los hammerers de RDP

RDP es el protocolo de escritorio remoto de Windows (Remote Desktop Protocol). El puerto estándar es el 3389. Si tienes un servidor Windows con ese puerto accesible desde internet, en cuestión de horas — a veces minutos — empezarás a ver en los logs de eventos de Windows intentos de login fallidos desde IPs de todo el mundo.

A esos atacantes automáticos se les suele llamar hammerers o brute-forcers: scripts que prueban millones de combinaciones de usuario y contraseña contra el puerto RDP sin parar, las 24 horas. Técnicamente es un ataque de fuerza bruta por diccionario. En la práctica, es ruido constante en los logs, CPU desperdiciada y una sensación incómoda de que hay alguien llamando a la puerta sin parar.

En Linux existe fail2ban, una herramienta clásica que lee los logs del sistema y bloquea IPs que superan un umbral de fallos. En Windows no hay nada equivalente de serie. Las alternativas suelen ser complejas (instalar un agente de terceros, configurar reglas de Group Policy, pagar por un servicio) o manuales (añadir IPs al firewall a mano cuando te hartas).

La realidad es que la mayoría de estos atacantes no van a entrar aunque los dejes seguir. El problema no es la seguridad en sí, sino el ruido. El objetivo de GuardianRDP es simple: detectarlos rápido y quitarlos del mapa sin complicaciones.

02 — La idea: vigilar el TCP en tiempo real

El enfoque habitual para detectar ataques de fuerza bruta es analizar logs: el atacante intenta entrar, Windows registra el fallo en el visor de eventos, un programa lee ese log y bloquea la IP. Funciona, pero tiene latencia — el atacante ya ha hecho varios intentos antes de que el bloqueo entre en efecto — y requiere parsear logs de texto, que pueden cambiar de formato, que no siempre son inmediatos y que tienen su complejidad.

GuardianRDP hace algo diferente: en lugar de mirar los logs, mira directamente la tabla de conexiones TCP del sistema operativo. Windows mantiene en memoria una tabla con todas las conexiones TCP activas en ese momento: qué IP local, qué puerto, qué IP remota, en qué estado. Esa tabla se puede leer desde .NET con una sola llamada a IPGlobalProperties.GetActiveTcpConnections().

La lógica es entonces directa: si una IP que no está en mi lista de confianza tiene una conexión activa hacia el puerto RDP, la bloqueo en el firewall de Windows inmediatamente — sin esperar a que falle ningún login, sin analizar ningún log. Una conexión al puerto es suficiente para actuar.

Hay un matiz importante: esto no distingue usuarios legítimos de atacantes basándose en comportamiento — simplemente confía en la lista blanca. Si tu IP de casa no está en la lista, te bloquea igual. Por eso la lista blanca es la pieza central del sistema: hay que tenerla bien configurada antes de encender el servicio.

03 — Arquitectura del servicio

El proyecto es un Worker Service de .NET 8. Un Worker Service es un tipo de aplicación .NET pensada para ejecutarse en segundo plano sin interfaz de usuario: puede correr como un servicio de Windows, como un contenedor Docker o simplemente como un proceso en la terminal. En nuestro caso, lo instalamos como servicio de Windows para que arranque solo con el sistema.

El código está dividido en tres clases con responsabilidades distintas:

Tabla TCP GetActiveTcp Connections() Worker Bucle cada 100 ms Detecta IPs Encola bloqueos Mantenimiento / 60 min WhitelistManager IP / rango / CIDR cola async FirewallManager HNetCfg .FwPolicy2 Windows Fire- wall URL remota (60 min)
Arquitectura de GuardianRDP: el Worker lee la tabla TCP del sistema, consulta la lista blanca y encola los bloqueos; el FirewallManager aplica las reglas en Windows Firewall.

Una decisión de diseño importante: el Worker detecta IPs y las encola, pero no las bloquea directamente. El bloqueo se procesa en una tarea paralela. Esto evita que una operación lenta en el firewall bloquee el bucle de vigilancia. En código, esto se implementa con un Channel<string>, que es la forma estándar en .NET de comunicar dos tareas de manera asíncrona sin bloqueos.

04 — El bucle de vigilancia

El fichero Program.cs es mínimo: configura la aplicación como servicio de Windows y arranca el Worker. Todo el trabajo real está en Worker.cs.

// Program.cs
IHost host = Host.CreateDefaultBuilder(args)
    .UseWindowsService()   // Habilita ejecución como servicio de Windows
    .ConfigureServices(services =>
    {
        services.AddHostedService<Worker>();
    })
    .Build();

await host.RunAsync();

La clase Worker hereda de BackgroundService, que es la clase base de .NET para servicios en segundo plano. El método que se ejecuta continuamente es ExecuteAsync. Al arrancar, carga la configuración del fichero config.txt y la lista blanca, y lanza en paralelo la tarea que procesa la cola de bloqueos.

public class Worker : BackgroundService
{
    private readonly ILogger<Worker> _logger;
    private FirewallManager _firewall;
    private readonly WhitelistManager _whitelist;

    private HashSet<int> _strictPorts = new();   // Bloqueo inmediato
    private HashSet<int> _webPorts = new();       // Bloqueo por acumulación
    private int _maxWebConnections = 30;
    private int _checkIntervalMs = 100;
    private string _whitelistUrl = "https://tudominio.es/listaBlanca.txt";

    // Cola asíncrona para desacoplar detección y bloqueo
    private readonly Channel<string> _pendingBlocks;

    // Caché local: IPs ya encoladas para no procesarlas dos veces
    private readonly HashSet<string> _localBlockCache = new();

    private DateTime _lastMaintenance = DateTime.MinValue;
    private readonly TimeSpan _maintenanceInterval = TimeSpan.FromMinutes(60);

    public Worker(ILogger<Worker> logger)
    {
        _logger = logger;
        _whitelist = new WhitelistManager(logger, "listaBlanca.txt");
        _pendingBlocks = Channel.CreateUnbounded<string>();
    }
}

La configuración se lee de un fichero de texto plano. Las líneas que empiezan por # son comentarios y se ignoran. Este formato sencillo permite editar la configuración sin recompilar ni reiniciar el servicio en el siguiente ciclo.

private void LoadConfiguration()
{
    _strictPorts.Clear();
    _webPorts.Clear();

    if (File.Exists("config.txt"))
    {
        foreach (var line in File.ReadAllLines("config.txt"))
        {
            // Quitamos comentarios (todo lo que va después de #)
            var clean = line.Split('#')[0].Trim();
            if (string.IsNullOrWhiteSpace(clean)) continue;

            if (clean.StartsWith("PORTS="))
                ParsePorts(clean.Replace("PORTS=", ""), _strictPorts);

            if (clean.StartsWith("WEB_PORTS="))
                ParsePorts(clean.Replace("WEB_PORTS=", ""), _webPorts);

            if (clean.StartsWith("MAX_WEB_CONNECTIONS="))
                int.TryParse(clean.Split('=')[1], out _maxWebConnections);

            if (clean.StartsWith("INTERVAL="))
                int.TryParse(clean.Split('=')[1], out _checkIntervalMs);

            if (clean.StartsWith("WHITELIST_URL="))
                _whitelistUrl = clean.Split('=')[1].Trim();
        }
    }
    else
    {
        _logger.LogWarning("config.txt no encontrado. Usando valores por defecto.");
        _strictPorts.Add(3389);   // RDP estándar
    }

    // El FirewallManager necesita saber todos los puertos monitorizados
    // para que sus reglas solo afecten a esos puertos y no a todo el tráfico
    var allPorts = new HashSet<int>(_strictPorts);
    foreach (var p in _webPorts) allPorts.Add(p);
    _firewall = new FirewallManager(_logger, string.Join(",", allPorts));
}

private void ParsePorts(string raw, HashSet<int> target)
{
    foreach (var p in raw.Split(','))
        if (int.TryParse(p.Trim(), out int portNum))
            target.Add(portNum);
}

El bucle principal llama a IPGlobalProperties.GetActiveTcpConnections(), que devuelve un array con todas las conexiones TCP activas en ese momento en el sistema operativo. Cada elemento tiene la IP y puerto local, la IP y puerto remoto, y el estado de la conexión. Iteramos sobre ellas aplicando filtros sucesivos para descartar las que no nos interesan lo antes posible.

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    LoadConfiguration();
    _whitelist.Load();

    // Arranca la tarea de bloqueo en segundo plano
    _ = ProcessBlockingQueueAsync(stoppingToken);

    _logger.LogInformation("GuardianRDP iniciado.");

    while (!stoppingToken.IsCancellationRequested)
    {
        // Mantenimiento cada 60 minutos
        if (DateTime.Now - _lastMaintenance > _maintenanceInterval)
        {
            await PerformMaintenanceAsync();
            _lastMaintenance = DateTime.Now;
        }

        try
        {
            var properties = IPGlobalProperties.GetIPGlobalProperties();
            var connections = properties.GetActiveTcpConnections();
            var webConnectionCounts = new Dictionary<string, int>();

            foreach (var conn in connections)
            {
                int port = conn.LocalEndPoint.Port;

                // Filtro 1: ¿Es un puerto que nos importa?
                if (!_strictPorts.Contains(port) && !_webPorts.Contains(port)) continue;

                // Filtro 2: ¿Es una conexión local (loopback)? Las ignoramos siempre.
                if (IPAddress.IsLoopback(conn.RemoteEndPoint.Address)) continue;

                string remoteIp = conn.RemoteEndPoint.Address.ToString();

                // Filtro 3: ¿Ya la tenemos encolada para bloquear? No duplicar.
                if (_localBlockCache.Contains(remoteIp)) continue;

                if (_strictPorts.Contains(port))
                {
                    // MODO ESTRICTO: una sola conexión basta para bloquear
                    if (!_whitelist.IsWhitelisted(conn.RemoteEndPoint.Address))
                        await QueueBlock(remoteIp, $"Puerto crítico {port}");
                }
                else if (_webPorts.Contains(port))
                {
                    // MODO ACUMULACIÓN: contamos conexiones simultáneas
                    webConnectionCounts.TryGetValue(remoteIp, out int count);
                    webConnectionCounts[remoteIp] = count + 1;
                }
            }

            // Evaluar excesos en puertos web
            foreach (var kvp in webConnectionCounts)
            {
                if (kvp.Value >= _maxWebConnections)
                {
                    if (!_whitelist.IsWhitelisted(IPAddress.Parse(kvp.Key)))
                        await QueueBlock(kvp.Key, $"Límite web superado ({kvp.Value} conex.)");
                }
            }
        }
        catch (Exception ex)
        {
            _logger.LogError("Error en ciclo de vigilancia: {Msg}", ex.Message);
        }

        await Task.Delay(_checkIntervalMs, stoppingToken);
    }
}

05 — Los dos modos de bloqueo

El sistema distingue dos tipos de puerto según el nivel de tolerancia:

Los puertos estrictos (PORTS en config) bloquean a la primera conexión. El razonamiento es simple: en un puerto como RDP, cualquier conexión de una IP desconocida es sospechosa. No tiene sentido esperar a que falle el login. Si no estás en la lista blanca y estás tocando el 3389, adiós.

Los puertos web (WEB_PORTS) usan un umbral de conexiones simultáneas. Un usuario normal no abre treinta conexiones simultáneas a tu servidor web. Un escáner o un DDoS de baja intensidad sí. El límite por defecto es 30; si una IP supera ese número de conexiones simultáneas, se bloquea.

GetActiveTcpConnections() Cada 100 ms Filtros rápidos (por conexión) ¿Puerto monitorizado? · ¿Es loopback? · ¿Ya cacheada? Tipo de puerto ¿STRICT o WEB? STRICT ¿En lista blanca? IsWhitelisted(ip) Sí → OK No WEB Acumular webCount[ip]++ ¿≥ MAX_WEB? (30 por defecto) No → OK QueueBlock(ip) Channel<string> FirewallManager.BlockIp() Añade IP a regla de Windows Firewall
Flujo de detección por cada conexión TCP activa: filtros rápidos primero, luego distinción entre modo estricto (bloqueo inmediato) y modo web (umbral de acumulación).

El encolado y el bloqueo están desacoplados a propósito. QueueBlock añade la IP a la caché local y al canal; ProcessBlockingQueueAsync consume ese canal en una tarea separada. Así, si el firewall tarda en responder, el bucle de vigilancia no se detiene.

private async Task QueueBlock(string ip, string reason)
{
    if (_localBlockCache.Contains(ip)) return;

    _logger.LogWarning("DETECTADO: {Ip} | {Reason}", ip, reason);
    _localBlockCache.Add(ip);
    await _pendingBlocks.Writer.WriteAsync(ip);
}

private async Task ProcessBlockingQueueAsync(CancellationToken token)
{
    // ReadAllAsync espera nuevos elementos y los entrega uno a uno
    await foreach (var ip in _pendingBlocks.Reader.ReadAllAsync(token))
    {
        // Doble comprobación: por si la whitelist se actualizó mientras la IP
        // estaba esperando en la cola
        if (!_whitelist.IsWhitelisted(ip))
        {
            _firewall.BlockIp(ip);
        }
    }
}

06 — La API de Windows Firewall

Windows Firewall tiene una API de programación que se puede usar desde cualquier lenguaje que soporte COM. COM (Component Object Model) es una tecnología antigua de Microsoft para comunicación entre componentes de software; sigue siendo la forma estándar de controlar el firewall de Windows desde código sin llamar a comandos externos.

En .NET se puede usar COM mediante late binding con dynamic: le pedimos a Windows que nos dé una instancia del objeto COM por su identificador de programa (ProgID) y a partir de ahí llamamos a sus métodos y propiedades como si fuera un objeto normal. No es lo más elegante del mundo, pero funciona y no necesita instalar nada.

public class FirewallManager
{
    private readonly ILogger _logger;
    private readonly string _allMonitoredPorts;
    private const string RulePrefix = "AutoBlock_Guardian_";
    private const int MaxIpsPerRule = 200;

    private dynamic? _fwPolicy2;

    public FirewallManager(ILogger logger, string allMonitoredPorts)
    {
        _logger = logger;
        _allMonitoredPorts = allMonitoredPorts;

        // Instanciar el objeto COM del firewall de Windows
        Type? type = Type.GetTypeFromProgID("HNetCfg.FwPolicy2");
        if (type != null)
            _fwPolicy2 = Activator.CreateInstance(type);
    }

    public bool BlockIp(string ipToBlock)
    {
        if (_fwPolicy2 == null) return false;

        try
        {
            // lock: evita carreras si varios hilos llaman a BlockIp a la vez
            lock (_fwPolicy2)
            {
                dynamic rule = GetOrCreateRule();
                string current = rule.RemoteAddresses;

                // No añadir si ya está
                if (current.Contains(ipToBlock)) return true;

                rule.RemoteAddresses = (current == "" || current == "*")
                    ? ipToBlock
                    : current + "," + ipToBlock;
            }

            _logger.LogInformation("BLOQUEADA: {Ip}", ipToBlock);
            return true;
        }
        catch (Exception ex)
        {
            _logger.LogError("Error bloqueando {Ip}: {Msg}", ipToBlock, ex.Message);
            return false;
        }
    }
}

Las reglas de firewall no admiten un número ilimitado de IPs. El código agrupa las IPs de bloqueo en lotes de 200 como máximo: si la última regla está llena, crea una nueva. Las reglas se llaman AutoBlock_Guardian_001, AutoBlock_Guardian_002, etc., lo que las hace fáciles de identificar y limpiar manualmente si hace falta.

private dynamic GetOrCreateRule()
{
    int maxIndex = 0;
    dynamic? lastRule = null;

    // Buscar la última regla existente
    foreach (dynamic rule in _fwPolicy2.Rules)
    {
        string name = rule.Name;
        if (!name.StartsWith(RulePrefix)) continue;

        if (int.TryParse(name.Replace(RulePrefix, ""), out int idx))
        {
            if (idx >= maxIndex)
            {
                maxIndex = idx;
                lastRule = rule;
            }
        }
    }

    // Si la última regla tiene sitio, la reutilizamos
    if (lastRule != null)
    {
        string remote = lastRule.RemoteAddresses;
        int count = string.IsNullOrEmpty(remote) ? 0 : remote.Split(',').Length;
        if (count < MaxIpsPerRule) return lastRule;
    }

    // Si no hay reglas o la última está llena, crear una nueva
    return CreateNewBlockRule(maxIndex + 1);
}

private dynamic CreateNewBlockRule(int index)
{
    string ruleName = $"{RulePrefix}{index:000}";

    Type? tRule = Type.GetTypeFromProgID("HNetCfg.FwRule");
    dynamic rule = Activator.CreateInstance(tRule!)!;

    rule.Name        = ruleName;
    rule.Description = $"GuardianRDP bloqueo automático lote {index}";
    rule.Protocol    = 6;                      // TCP
    rule.LocalPorts  = _allMonitoredPorts;     // Solo los puertos monitorizados
    rule.Direction   = 1;                      // Inbound (entrante)
    rule.Action      = 0;                      // Block
    rule.Enabled     = true;
    rule.InterfaceTypes = "All";

    _fwPolicy2.Rules.Add(rule);

    _logger.LogInformation("Nueva regla creada: {Name}", ruleName);
    return rule;
}

Una ventaja de usar la API directamente en lugar de llamar a netsh o PowerShell es que los cambios son inmediatos: en cuanto se modifica la propiedad RemoteAddresses de una regla, el firewall aplica el bloqueo. No hay que esperar a que un script termine de ejecutarse.

07 — La lista blanca y los rangos de IP

La lista blanca es el fichero listaBlanca.txt. Es un fichero de texto plano que se puede editar a mano y que el servicio recarga automáticamente. Las líneas que empiezan por # son comentarios ignorados.

Soporta tres formatos:

Ejemplo de listaBlanca.txt:

# Rango de mi proveedor de internet
79.116.0.0/16

# Mi servidor en la nube (IP fija)
203.0.113.5

# Red local de la oficina
192.168.1.1-192.168.1.255

# Siempre incluir localhost
127.0.0.1

Para comparar si una IP cae dentro de un rango, el código convierte todas las IPs a números enteros grandes. Una dirección IPv4 es en realidad un número de 32 bits: los cuatro octetos son los cuatro bytes de ese número. 192.168.1.1 es 3232235777 en decimal. Comparar si una IP está en un rango es entonces solo comparar si ese número está entre dos valores — una operación muy rápida.

El código usa BigInteger en lugar de int o long por seguridad: un IPv4 cabe en un uint de 32 bits, pero BigInteger permite añadir soporte a IPv6 en el futuro sin cambiar la lógica de comparación, y evita problemas de signo con los números que tienen el bit más significativo a 1 (IPs altas como 255.x.x.x).

public class WhitelistManager
{
    private readonly ILogger _logger;
    private readonly string _filePath;
    private readonly HttpClient _httpClient = new();

    // Todos los rangos se almacenan como pares (inicio, fin) de BigInteger
    private List<(BigInteger Start, BigInteger End)> _ranges = new();

    public void Load()
    {
        _ranges.Clear();

        if (!File.Exists(_filePath))
            File.WriteAllText(_filePath, "# Lista blanca vacía\n127.0.0.1\n");

        foreach (var line in File.ReadAllLines(_filePath))
        {
            var clean = line.Split('#')[0].Trim();
            if (string.IsNullOrWhiteSpace(clean)) continue;

            try
            {
                if (clean.Contains('-'))
                {
                    // Rango: 192.168.1.1-192.168.1.255
                    var parts = clean.Split('-');
                    _ranges.Add((
                        IpToNumber(IPAddress.Parse(parts[0].Trim())),
                        IpToNumber(IPAddress.Parse(parts[1].Trim()))
                    ));
                }
                else if (clean.Contains('/'))
                {
                    // CIDR: 79.116.0.0/16
                    var parts = clean.Split('/');
                    _ranges.Add(CalculateCidrRange(
                        IPAddress.Parse(parts[0].Trim()),
                        int.Parse(parts[1].Trim())
                    ));
                }
                else
                {
                    // IP individual
                    var n = IpToNumber(IPAddress.Parse(clean));
                    _ranges.Add((n, n));
                }
            }
            catch (Exception ex)
            {
                _logger.LogWarning("Línea de lista blanca inválida '{Line}': {Msg}", line, ex.Message);
            }
        }

        _logger.LogInformation("Lista blanca cargada: {Count} rangos activos.", _ranges.Count);
    }

    public bool IsWhitelisted(IPAddress ip)
    {
        var n = IpToNumber(ip);
        foreach (var (start, end) in _ranges)
            if (n >= start && n <= end) return true;
        return false;
    }

    public bool IsWhitelisted(string ipString) =>
        IPAddress.TryParse(ipString, out var ip) && IsWhitelisted(ip);

    private BigInteger IpToNumber(IPAddress ip)
    {
        // GetAddressBytes() devuelve los bytes en orden de red (big-endian):
        // para 192.168.1.1 devuelve [192, 168, 1, 1]
        byte[] originalBytes = ip.GetAddressBytes();

        // El constructor BigInteger(byte[]) espera little-endian, así que invertimos
        Array.Reverse(originalBytes);

        // Añadimos un byte 0 al final (que tras el reverse queda al principio en
        // representación big-endian) para garantizar que BigInteger lo trate
        // como número positivo aunque el bit más significativo sea 1
        byte[] unsignedBytes = new byte[originalBytes.Length + 1];
        Array.Copy(originalBytes, 0, unsignedBytes, 0, originalBytes.Length);

        return new BigInteger(unsignedBytes);
    }

    private (BigInteger Start, BigInteger End) CalculateCidrRange(IPAddress ip, int prefix)
    {
        int totalBits = ip.GetAddressBytes().Length * 8;   // 32 para IPv4
        BigInteger ipNum = IpToNumber(ip);

        // Máscara de red: 1s en los primeros `prefix` bits
        BigInteger mask = BigInteger.Pow(2, totalBits) - BigInteger.Pow(2, totalBits - prefix);

        BigInteger start = ipNum & mask;
        BigInteger end   = start + BigInteger.Pow(2, totalBits - prefix) - 1;

        return (start, end);
    }
}

El método UpdateFromUrlAsync descarga la lista blanca desde una URL remota y la guarda en el disco, sobreescribiendo la anterior. Esto permite mantener una lista centralizada en un servidor web: si añades una IP nueva a la lista remota, el servicio la recogerá en el siguiente ciclo de mantenimiento sin que tengas que conectarte al servidor protegido.

public async Task UpdateFromUrlAsync(string url)
{
    try
    {
        _logger.LogInformation("Descargando lista blanca desde {Url}...", url);
        string content = await _httpClient.GetStringAsync(url);

        if (!string.IsNullOrWhiteSpace(content))
        {
            await File.WriteAllTextAsync(_filePath, content);
            Load();   // Recargar en memoria
        }
    }
    catch (Exception ex)
    {
        _logger.LogError("Error descargando lista blanca: {Msg}", ex.Message);
        // Si falla la descarga, seguimos usando la versión que hay en disco
    }
}

08 — Mantenimiento automático

Cada 60 minutos el Worker ejecuta un ciclo de mantenimiento con cuatro pasos:

  1. Descarga la lista blanca actualizada desde la URL remota. Si la descarga falla, se queda con la copia local.
  2. Limpia las reglas del firewall: recorre todas las IPs bloqueadas y elimina las que ahora están en la lista blanca. Esto es importante si, por ejemplo, un proveedor de internet cambia el rango de IPs que asigna a sus clientes y una IP legítima acaba bloqueada. En el siguiente ciclo de mantenimiento se libera sola.
  3. Vacía la caché local: el HashSet que evita procesar IPs ya encoladas se borra. Esto permite que IPs que estaban en la caché (por ejemplo, porque antes no estaban en la lista blanca) sean re-evaluadas con la lista actualizada.
  4. Ejecuta gpupdate /force: fuerza una actualización de las Políticas de Grupo de Windows. En entornos de dominio, las políticas de grupo pueden sobreescribir reglas del firewall local; este paso asegura que las reglas de GuardianRDP no queden anuladas por una política de dominio pendiente de aplicarse.
private async Task PerformMaintenanceAsync()
{
    _logger.LogInformation("Iniciando mantenimiento programado...");

    // 1. Actualizar lista blanca
    await _whitelist.UpdateFromUrlAsync(_whitelistUrl);

    // 2. Limpiar firewall: quitar IPs que ahora son de confianza
    _firewall.CleanWhitelistedIps(_whitelist);

    // 3. Vaciar caché local
    _localBlockCache.Clear();

    // 4. Forzar actualización de Políticas de Grupo (útil en entornos de dominio)
    try
    {
        Process.Start(new ProcessStartInfo("gpupdate", "/force")
        {
            CreateNoWindow  = true,
            UseShellExecute = false
        });
    }
    catch (Exception ex)
    {
        _logger.LogError("Error ejecutando gpupdate: {Msg}", ex.Message);
    }

    _logger.LogInformation("Mantenimiento completado.");
}

La limpieza del firewall también se encarga de eliminar reglas que quedan vacías. Cuando todas las IPs de una regla pasan a la lista blanca, la regla se vacía y se podría borrar, pero la API de Windows Firewall a veces da error al borrar reglas con RemoteAddresses vacío, así que simplemente se deja vacía y harmless.

// En FirewallManager.cs
public void CleanWhitelistedIps(WhitelistManager whitelist)
{
    if (_fwPolicy2 == null) return;

    int removedCount = 0;

    foreach (dynamic rule in _fwPolicy2.Rules)
    {
        if (!((string)rule.Name).StartsWith(RulePrefix)) continue;

        string remoteAddresses = rule.RemoteAddresses;
        if (string.IsNullOrWhiteSpace(remoteAddresses) || remoteAddresses == "*") continue;

        var ips = remoteAddresses.Split(',');
        var validIps = new List<string>();
        bool modified = false;

        foreach (var ip in ips)
        {
            if (whitelist.IsWhitelisted(ip.Trim()))
            {
                removedCount++;
                modified = true;
                _logger.LogInformation("Desbloqueando IP ahora en lista blanca: {Ip}", ip);
            }
            else
            {
                validIps.Add(ip);
            }
        }

        if (modified)
            rule.RemoteAddresses = validIps.Count > 0 ? string.Join(",", validIps) : "";
    }

    _logger.LogInformation("Limpieza completada: {Count} IPs liberadas.", removedCount);
}

09 — Compilación e instalación

El proyecto es un ejecutable autocontenido para Windows x64: incluye el runtime de .NET en el propio ejecutable, así que no hace falta instalar .NET en el servidor destino. Sí hace falta el SDK de .NET 8 en la máquina donde se compila.

Compilar y publicar:

dotnet restore
dotnet publish -c Release -o C:\GuardianRDP

Esto deja en C:\GuardianRDP el ejecutable y los ficheros de configuración. Antes de instalar el servicio, edita config.txt y listaBlanca.txt con tus valores. Importante: añade tu IP a la lista blanca antes de encender el servicio — si no, te bloquearás a ti mismo en cuanto te conectes por RDP.

Contenido de config.txt:

# Puertos ESTRICTOS: se bloquea al primer intento
# RDP estándar=3389, FTP=21, NetBIOS=135/139
# Si usas un puerto no estándar para RDP, ponlo aquí también
PORTS=3389,21,135,139

# Puertos WEB: se bloquea si se superan MAX_WEB_CONNECTIONS conexiones simultáneas
WEB_PORTS=80,443
MAX_WEB_CONNECTIONS=30

# Tiempo en milisegundos entre chequeos (100ms = 10 veces por segundo)
INTERVAL=100

# URL de descarga de la lista blanca (se actualiza cada 60 minutos)
# Si no usas esta función, déjala apuntando a un fichero con tu lista
WHITELIST_URL=https://tudominio.es/listaBlanca.txt

Instalar y arrancar el servicio de Windows:

sc create "GuardianRDP" binPath= "C:\GuardianRDP\GuardianRDP.exe" start= auto
sc start GuardianRDP

sc create registra el ejecutable como servicio de Windows con inicio automático. sc start lo arranca inmediatamente sin tener que reiniciar. A partir de ese momento, GuardianRDP arranca solo con el sistema y escribe su actividad en el visor de eventos de Windows (Applications and Services Logs), que es donde el framework de .NET redirige los logs de un Windows Service.

Para detener y desinstalar:

sc stop GuardianRDP
sc delete GuardianRDP

Para ver las reglas de firewall que ha creado, abre el Firewall de Windows con seguridad avanzada (ejecuta wf.msc) y filtra las reglas de entrada por el prefijo AutoBlock_Guardian_. Desde ahí puedes borrar reglas manualmente, exportarlas o simplemente ver cuántas IPs lleva bloqueadas.

El servicio necesita ejecutarse con una cuenta con permisos de administrador para poder modificar las reglas del firewall. Al instalarlo con sc create sin especificar cuenta, se ejecuta como LocalSystem, que tiene esos permisos. Si prefieres una cuenta de servicio específica, añade obj= "DOMINIO\usuario" password= "contraseña" al comando de creación.

Una advertencia honesta: el sistema de detección por conexión TCP activa tiene un límite. Si el atacante usa conexiones de muy corta duración que se abren y cierran en menos de 100 ms, puede que el Worker no las vea. En la práctica, los ataques de fuerza bruta contra RDP mantienen la conexión abierta varios segundos mientras negocian el protocolo, así que el sistema los pilla sin problema. Pero no es infalible: no está diseñado para serlo. Está diseñado para quitar del medio a los hammerers habituales con el mínimo mantenimiento posible.