在大概一年多以前,我写了一个简单的通过程序判断和阻止来自特定IP地址的远程桌面连接攻击的程序,一直以来是作为一个简单的控制台程序来使用的,用起来效果很不错。最近我在自己的阿里云服务器上也发现了很多这种远程桌面登录连接的尝试,这也没有办法,只要端口对外网暴露,就免不了会有这种攻击。于是我对之前的那个小程序做了一些调整和完善,主要改进了:

  • 程序不一定要监控特定端口,只要在日志里查找到了类型ID为4625的错误日志,就认为是远程桌面连接登录。
  • 当远程桌面登录失败尝试超过3次后,除了添加到防火墙之外,还记录该远程信息到日志,包括远程IP尝试登录时采用的用户名,IP归属地等信息。
  • 平台使用最新的.NET 8.0来重写,程序可以作为控制台程序,或Windows Service 安装运行。
  • 添加白名单机制,对于白名单里面的IP地址不做登录失败校验。

因为前几天刚申请成功了Gemini Advance试用,本程序大部分功能在Gemini的协助下完成。

组成部分


这个程序由几部分组成:

  • 一些配置文件,包括 appsettings.json 、log4net.config。appsettings.json 是程序的配置信息,在其BlockerSettings节点下包括失败次数阈值,日志文件路径,白名单列表。log4net.config 用来配置log4net的日志输出方式。
  • 日志相关,程序的日志分为两部分,一部分是log4net记录系统的运行状态,程序有可能以服务方式运行,所以需要打印详细信息(如果程序启动报错,可能要去Windows日志里面去查看详细信息);另外一部分日志是记录那些被加入到黑名单里面的尝试远程登录的信息,包括登录时使用的用户名,多次尝试登录但失败的时间,IP的归属地等。
  • IP地址归属地查询服务。
  • 防火墙操作相关服务,包括创建防火墙,查询当前防火墙里面的RemoteIP列表信息,更新防火墙信息等等。
  • 主逻辑服务,它是一个BackgroundService。
  • 程序的main函数入口,用来读取配置、注册服务、根据当前运行环境执行不同的启动服务逻辑。

具体实现


我使用的是Visual Studio 2022,可以创建类型为“Worker Services”名称为”RDPBlock“的工程文件,注意和传统的.NET Framework中的"Windows Service"稍有不同,

也可以直接使用以下命令创建:

dotnet new worker -n RDPBlock

接下来就在这个工程下面操作。

配置文件以及类


因为使用的是log4net来记录日志,所以需要添加log4net.config文件。

<?xml version="1.0" encoding="utf-8" ?>
<log4net>
	<root>
		<!--文件形式记录日志-->
		<appender-ref ref="LogFileAppender" />
	</root>
	<!--定义输出到文件中-->
	<appender name="LogFileAppender" type="log4net.Appender.RollingFileAppender">
		<param name="Encoding" value="utf-8" />
		<param name="file" value="./log/logfile_" />
		<param name="appendToFile" value="true" />
		<param name="rollingStyle" value="Date" />
		<param name="StaticLogFileName" value="false" />
		<datePattern value="yyyyMMdd'.log'" />
		<param name="Threshold" value="DEBUG" />
		<layout type="log4net.Layout.PatternLayout">
			<param name="ConversionPattern" value="[%d{yyyy-MM-dd HH:mm:ss,fff}] %5p - %m%n" />
		</layout>
	</appender>

	<appender name="ConsoleAppender" type="log4net.Appender.ConsoleAppender">
		<layout type="log4net.Layout.PatternLayout">
			<conversionPattern value="%date{HH:mm:ss} %-5level %logger - %message%newline%exception" />
		</layout>
	</appender>
</log4net>

接下来安装log4net 以及 Microsoft.Extensions.Logging.Log4Net.AspNetCore这两个NuGet包。在使用的时候,添加下面这一句即可:

builder.Logging.AddLog4Net("log4net.config");

具体的完整使用下面会介绍。

接下来就是程序中的配置文件,它定义在一个类中,主要包括四项。

// Class to hold configuration settings for the RDP Blocker.
// Loaded from appsettings.json.
public class BlockerSettings
{
    // Number of failed RDP attempts from a single IP before blocking it.
    // Default value used ONLY if missing from configuration.
    public int FailureThreshold { get; set; } = 3;

    // Path for the *operational* log file (recording failures and blocks).
    // General application logging goes to log4net targets.
    // Default value used ONLY if missing from configuration.
    public string LogFilePath { get; set; } = Path.Combine(AppContext.BaseDirectory, "log", "rdp_blocker_operational_log.txt");

    // Prefix used when creating Windows Firewall rule names.
    // Default value used ONLY if missing from configuration.
    public string FirewallRulePrefix { get; set; } = "RDPBlocker";
    
    // List of IP addresses to ignore (never block). Initialized to empty list.
    public List<string> WhitelistedIPs { get; set; } = new List<string>();
}

程序的配置文件放在appsettings.json里,如果没有该文件就添加,内容如下:

{
    // Logging section is now primarily handled by log4net.config,
    // but can still be used for other framework logging if needed.
    "Logging": {
        "LogLevel": {
            "Default": "Information", // This might be overridden by log4net config
            "Microsoft.Hosting.Lifetime": "Information"
        }
    },
    // Custom configuration section for the RDP Blocker application
    "BlockerSettings": {
        // The number of failed RDP login attempts allowed from a single IP address
        // before the service blocks that IP address via the firewall.
        "FailureThreshold": 3,

        // The full path to the file where the service will log detected failures and blocking actions.
        // This is the *operational* log, separate from the general debug/info log.
        // IMPORTANT: Ensure the directory exists and the account running the service
        // (e.g., LocalSystem) has write permissions to this path.
        //C:\\ProgramData\\RDPBlockerService\\rdp_blocker_operational_log.txt
        "LogFilePath": "",

        "FirewallRulePrefix": "RDPBlocker",
        // ** NEW: List of IP addresses that should never be blocked, even if they fail logins. **
        "WhitelistedIPs": [
            "192.168.1.101", // Example internal IP
            "::1" // Example IPv6 loopback (though loopback is already ignored)
        ]
    }
    // Optional: log4net configuration file name override
    // "Log4NetCore": {
    //   "Log4NetConfigFileName": "log4net.config"
    // }
}

配置文件的读取也很简单:

// --- Configuration ---
// Configure BlockerSettings from appsettings.json
builder.Services.Configure<BlockerSettings>(
    builder.Configuration.GetSection("BlockerSettings")
);

BackgroundService


程序的主要逻辑是一个名为Worker的继承自BackgroundService的类。这个类的原理就是监听系统里的4625事件发生的次数,如果次数超过配置文件里面设置的阈值,且来源IP地址不在白名单列表里,则将该来源IP地址添加到Windows防火墙里,禁止来自该IP地址的用户对本机的远程登录连接访问。为了便于后续统计分析,在获取IP地址后调用IP地址查询服务,查询IP信息,并调用防火墙接口来创建或更新防火墙设置,将当前来访的IP地址添加到防火墙里。

完整的代码如下:

// This class contains the core logic for monitoring and blocking RDP attempts.
// Inherits from BackgroundService for long-running tasks.
public class Worker : BackgroundService
{
    // ILogger provided by dependency injection (configured to use log4net)
    private readonly ILogger<Worker> _logger;
    private readonly BlockerSettings _settings;
    private readonly IpGeolocationService _ipGeoService; // Inject the geolocation service
    private readonly IFirewallService _firewallService; // Inject the firewall service
                                                        // ** Store queue of timestamps for each IP **
    private readonly ConcurrentDictionary<string, ConcurrentQueue<DateTimeOffset>> _failedAttempts = new();
    private EventLogWatcher? _watcher; // Monitors the Windows Event Log
    private readonly string _operationalLogFilePath; // Path for the operational log file (still needed for direct logging in Worker if any)
    private static readonly object _operationalLogLock = new object(); // Lock for operational log file writing (still needed for direct logging in Worker if any)
    private readonly HashSet<string> _whitelistedIpSet; // Efficient lookup for whitelist

    //// Constants for Event Log Query
    private const string LogName = "Security"; // Log to query
    private const int RdpFailureEventId = 4625; // Event ID for failed logon attempts
                                                //// Logon Type 10: RemoteInteractive (RDP)
                                                //// Status codes for bad username/password (adjust if needed based on your logs)
                                                //// 0xC000006D: STATUS_LOGON_FAILURE
                                                //// 0xC000006A: STATUS_WRONG_PASSWORD
    private string EventQuery = $@"
     <QueryList>
       <Query Id='0' Path='{LogName}'>
         <Select Path='{LogName}'> *[System[(EventID={RdpFailureEventId})]]</Select>
       </Query>
     </QueryList>";

    // Constructor: Injects logger, settings, IpGeolocationService, and FirewallService
    public Worker(ILogger<Worker> logger, IOptions<BlockerSettings> settings, IpGeolocationService ipGeoService, IFirewallService firewallService)
    {
        _logger = logger; // Injected logger (now log4net)
        _settings = settings.Value;
        _ipGeoService = ipGeoService; // Store injected service instance
        _firewallService = firewallService; // Store injected service instance
        _operationalLogFilePath = _settings.LogFilePath; // Path for specific block/fail logs
                                                         // Create a HashSet for efficient whitelist lookups (case-insensitive)
        _whitelistedIpSet = new HashSet<string>(_settings.WhitelistedIPs ?? new List<string>(), StringComparer.OrdinalIgnoreCase);
        _logger.LogInformation("Worker initialized. Operational Log Path used: {OperationalLogPath}", _operationalLogFilePath); // Log the actual path being used
        _logger.LogInformation("Settings: Threshold={Threshold}", _settings.FailureThreshold);
        _logger.LogInformation("Whitelist contains {Count} IPs: {IpList}", _whitelistedIpSet.Count, string.Join(", ", _whitelistedIpSet));
    }

    // Main execution method called by the host.
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("Worker.ExecuteAsync starting at: {time}", DateTimeOffset.Now);
        // LogOperation($"[{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss UTC}] Service/App Started. Operational Log Path: {_operationalLogFilePath}"); // Removed LogOperation call

        await Task.Yield(); // Ensure asynchronous operation

        try
        {
            _logger.LogInformation("Worker.ExecuteAsync: Checking administrator privileges...");
            // --- Permission Check ---
            var principal = new System.Security.Principal.WindowsPrincipal(System.Security.Principal.WindowsIdentity.GetCurrent());
            if (!principal.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator))
            {
                _logger.LogCritical("Application must run as Administrator. Exiting ExecuteAsync.");
                // LogOperation($"[{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss UTC}] CRITICAL ERROR: Application requires Administrator privileges. Shutting down."); // Removed LogOperation call
                return; // Exit ExecuteAsync if not admin
            }
            _logger.LogInformation("Worker.ExecuteAsync: Administrator privileges confirmed.");

            // --- Event Log Watcher Setup ---
            _logger.LogInformation("Worker.ExecuteAsync: Setting up Event Log watcher...");
            EventLogQuery query = new EventLogQuery(LogName, PathType.LogName, EventQuery);
            _watcher = new EventLogWatcher(query);
            _logger.LogInformation("Worker.ExecuteAsync: EventLogWatcher created.");

            // Make the event handler async void to allow await inside
            _watcher.EventRecordWritten += async (sender, args) => await EventLogEventHandlerAsync(sender, args, stoppingToken);
            _logger.LogInformation("Worker.ExecuteAsync: EventRecordWritten handler attached.");


            // Register callback for graceful shutdown
            stoppingToken.Register(() =>
            {
                _logger.LogInformation("Cancellation token invoked via Register callback. Stopping watcher.");
                // LogOperation($"[{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss UTC}] Cancellation requested via Register callback. Stopping watcher."); // Removed LogOperation call
                var watcherToDispose = _watcher; // Use temp var for thread safety
                if (watcherToDispose != null)
                {
                    watcherToDispose.Enabled = false;
                    watcherToDispose.Dispose();
                    _watcher = null; // Mark as disposed
                }
            });
            _logger.LogInformation("Worker.ExecuteAsync: stoppingToken.Register callback configured.");


            _watcher.Enabled = true; // Start watching
            _logger.LogInformation("Worker.ExecuteAsync: Event log watcher enabled successfully.");
            // LogOperation($"[{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss UTC}] Event log watcher started."); // Removed LogOperation call

            _logger.LogInformation("Worker.ExecuteAsync: Entering main monitoring loop (while !stoppingToken.IsCancellationRequested)...");
            // --- Keep Service Alive ---
            while (!stoppingToken.IsCancellationRequested)
            {
                // Wait efficiently without blocking the thread
                TimeSpan delayDuration = Debugger.IsAttached ? TimeSpan.FromSeconds(10) : TimeSpan.FromMinutes(5);
                _logger.LogDebug("Worker heartbeat. Waiting for {DelayDuration}...", delayDuration);
                await Task.Delay(delayDuration, stoppingToken);
            }
            // This log indicates the loop was exited gracefully via cancellation
            _logger.LogInformation("Worker.ExecuteAsync: Exited main monitoring loop because stoppingToken.IsCancellationRequested was true.");

        }
        catch (EventLogException ex)
        {
            _logger.LogCritical(ex, "Worker.ExecuteAsync: EventLogException occurred. Check permissions/log existence.");
            // LogOperation($"[{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss UTC}] CRITICAL ERROR: Failed to start Event Log watcher: {ex.Message}"); // Removed LogOperation call
            // Let ExecuteAsync complete, host will stop.
        }
        catch (UnauthorizedAccessException ex)
        {
            // This shouldn't be reached if the initial check works, but keep for safety
            _logger.LogCritical(ex, "Worker.ExecuteAsync: Caught UnauthorizedAccessException.");
            // Let ExecuteAsync complete, host will stop.
        }
        catch (OperationCanceledException)
        {
            // Expected when stopping gracefully (e.g., Ctrl+C in console, Task.Delay cancelled)
            _logger.LogInformation("Worker.ExecuteAsync: Task cancelled gracefully (OperationCanceledException). Likely due to stoppingToken cancellation.");
        }
        catch (Exception ex)
        {
            // Catch unexpected errors in the main loop or setup
            _logger.LogCritical(ex, "Worker.ExecuteAsync: An unexpected critical error occurred.");
            // LogOperation($"[{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss UTC}] UNEXPECTED CRITICAL RUNTIME ERROR in ExecuteAsync: {ex.Message}"); // Removed LogOperation call
            // Let ExecuteAsync complete, host will stop.
        }
        finally
        {
            // --- Cleanup ---
            _logger.LogInformation("Worker.ExecuteAsync: Entering finally block.");
            var watcherToDispose = _watcher; // Use temp var
            if (watcherToDispose != null)
            {
                _logger.LogInformation("Worker.ExecuteAsync: Disposing Event Log watcher in finally block.");
                watcherToDispose.Enabled = false;
                watcherToDispose.Dispose();
                _watcher = null;
                // LogOperation($"[{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss UTC}] Event log watcher stopped and disposed in finally block."); // Removed LogOperation call
            }
            else
            {
                _logger.LogInformation("Worker.ExecuteAsync: Watcher was already null or disposed in finally block.");
            }
        }

        // This log indicates ExecuteAsync completed its execution path
        _logger.LogInformation("Worker.ExecuteAsync method finished execution path at: {time}", DateTimeOffset.Now);
        // LogOperation($"[{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss UTC}] Service/App ExecuteAsync Method Ended."); // Removed LogOperation call
    }

    // Handles incoming Event Log records matching the query. Now Async.
    private async Task EventLogEventHandlerAsync(object? sender, EventRecordWrittenEventArgs arg, CancellationToken cancellationToken)
    {
        if (arg.EventRecord == null)
        {
            _logger.LogWarning("EventLogEventHandler called with null EventRecord.");
            return;
        }

        // Use 'using' to ensure the record is disposed promptly
        using (EventRecord record = arg.EventRecord)
        {
            string? ipAddress = null;
            string? userName = null;
            string? status = null;
            string ipString = string.Empty; // To store validated IP string
                                            // ** Get event time in LOCAL time **
            DateTimeOffset eventTime = record.TimeCreated?.ToLocalTime() ?? DateTimeOffset.Now;

            try
            {
                // --- Event Parsing ---
                string eventXml = record.ToXml();
                XDocument doc = XDocument.Parse(eventXml);
                XNamespace ns = doc.Root?.GetDefaultNamespace() ?? XNamespace.None;

                // Extract relevant data fields
                ipAddress = doc.Descendants(ns + "Data").FirstOrDefault(d => d.Attribute("Name")?.Value == "IpAddress")?.Value;
                userName = doc.Descendants(ns + "Data").FirstOrDefault(d => d.Attribute("Name")?.Value == "TargetUserName")?.Value;
                status = doc.Descendants(ns + "Data").FirstOrDefault(d => d.Attribute("Name")?.Value == "Status")?.Value;

                // --- IP Validation and Processing ---
                // Ignore empty, placeholder "-", or loopback addresses
                if (!string.IsNullOrEmpty(ipAddress) && ipAddress != "-")
                {
                    if (IPAddress.TryParse(ipAddress, out IPAddress? parsedIp) && !IPAddress.IsLoopback(parsedIp))
                    {
                        ipString = parsedIp.ToString(); // Store the valid IP string
                                                        // ** Whitelist Check **
                        if (_whitelistedIpSet.Contains(ipString))
                        {
                            _logger.LogInformation("Ignoring RDP failure from whitelisted IP: {IpAddress}, User: {UserName}", ipString, userName ?? "N/A");
                            return; // Skip further processing for whitelisted IPs
                        }

                        // Log detection via log4net (using local time)
                        _logger.LogWarning("RDP Failure Detected: IP={IpAddress}, User={UserName}, Status={Status}, Time={Time}",
                            ipString, userName ?? "N/A", status ?? "N/A", eventTime);

                        // --- Failure Tracking with Timestamps ---
                        var attemptQueue = _failedAttempts.GetOrAdd(ipString, _ => new ConcurrentQueue<DateTimeOffset>());
                        attemptQueue.Enqueue(eventTime); // Store local time

                        // Optional: Trim the queue if it grows too large (e.g., keep only last N attempts)
                        while (attemptQueue.Count > _settings.FailureThreshold * 2 && attemptQueue.TryDequeue(out _)) { } // Keep last N*2 attempts

                        int failureCount = attemptQueue.Count;
                        _logger.LogInformation("IP {IpAddress} failure count updated to {Count}", ipString, failureCount);

                        // --- Threshold Check and Blocking ---
                        if (failureCount >= _settings.FailureThreshold)
                        {
                            _logger.LogWarning("IP {IpAddress} reached failure threshold ({Threshold}). Initiating block.", ipString, _settings.FailureThreshold);

                            // ** Update the single firewall rule using the service **
                            _firewallService.UpdateFirewallRule(ipString);

                            // --- Get IP Geolocation Details (using injected service) ---
                            string ipDetails = await _ipGeoService.GetIpDetailsAsync(ipString, cancellationToken); // Use the service
                            if (!string.IsNullOrEmpty(ipDetails) && ipDetails != "Details unavailable (rate limited)")
                            {
                                _logger.LogInformation("IP Details for {IpAddress}: {Details}", ipString, ipDetails);
                            }

                            // ** Get timestamps for logging **
                            var timestamps = attemptQueue.ToList(); // Get a snapshot of current timestamps
                                                                    // Keep only the last N timestamps that triggered the block for the log message
                            var relevantTimestamps = timestamps.TakeLast(_settings.FailureThreshold);
                            // ** Format timestamps using local time with offset **
                            string timestampList = string.Join(", ", relevantTimestamps.Select(t => t.ToString("yyyy-MM-dd HH:mm:ss zzz")));

                            // ** Log blocking action WITH details AND LOCAL timestamps to operational log **
                            LogOperation($"[{DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss zzz}] BLOCKING IP: {ipString} (User: {userName ?? "N/A"}, Count: {failureCount}). Details: {ipDetails}. Failure Times: [{timestampList}]");

                            // Optional: Clear the queue for this IP after blocking so it can be blocked again if needed?
                            // _failedAttempts.TryRemove(ipString, out _);
                            // Or just remove the oldest entries?
                            // while(attemptQueue.TryDequeue(out _)) { } // Clear queue
                        }
                    }
                    else
                    {
                        // Log invalid IP format or loopback
                        _logger.LogWarning("Invalid IP format or loopback address extracted: {IpAddress}", ipAddress);
                    }
                }
                else
                {
                    // Log ignored events (useful for debugging the query)
                    _logger.LogDebug("Ignoring RDP failure event. No valid remote IP found. IP='{IpAddress}'", ipAddress ?? "null");
                }
            }
            catch (System.Xml.XmlException ex)
            {
                _logger.LogError(ex, "Error parsing event record XML.");
            }
            catch (FormatException ex)
            { // Catch specific IP parsing errors
                _logger.LogError(ex, "Error parsing IP address during event processing.");
            }
            catch (OperationCanceledException)
            {
                // This might happen if the app is shutting down while processing an event
                _logger.LogInformation("Operation cancelled during event processing (likely shutdown).");
            }
            catch (Exception ex)
            {
                // Catch-all for unexpected errors during event processing
                _logger.LogError(ex, "Error processing event record (ID: {EventId}, Time: {TimeCreated}). IP='{IpAddress}' User='{UserName}'",
                   record.Id, record.TimeCreated, ipAddress ?? "N/A", userName ?? "N/A");
            }
        } // End using (record) - ensures record.Dispose() is called
    }

    // Logs specific operational messages (IP Blocks and related errors) to a separate file.
    private void LogOperation(string message)
    {
        try
        {
            // Use a lock specific to this file to prevent concurrent writes
            lock (_operationalLogLock)
            {
                // Append message to the operational log file
                File.AppendAllText(_operationalLogFilePath, message + Environment.NewLine, Encoding.UTF8);
            }
        }
        catch (Exception ex)
        {
            // Log errors about operational logging failures to the main log4net logger
            _logger.LogError(ex, "Failed to write to operational log file: {OperationalLogFilePath}", _operationalLogFilePath);
            // Avoid infinite loops if the main logger also fails
        }
    }

    // Called when the host is stopping.
    public override Task StopAsync(CancellationToken cancellationToken)
    {
        _logger.LogInformation("RDP Blocker Service/App StopAsync called.");
        // Cleanup is handled by ExecuteAsync's finally block and token registration.
        return base.StopAsync(cancellationToken);
    }
}

可以看到这个Worker依赖的外部服务,都是通过构造函数的参数注入进来的。包括日志服务、系统设置、IP地址查询服务、防火墙服务。这里的防火墙服务是一个接口,这是因为防火墙的创建或更新有两种方法,可以进行配置。

这里记录了非常详细的日志,这对于调试或运行发现问题非常有帮助。日志在这里涉及到两类,一类是使用log4net记录的是系统运行期间的日志,另外一个专门的txt文件记录的是那些被添加到防火墙里面禁止访问的IP的详细信息,包括尝试登录时使用的用户名,三次尝试登录但失败的时间,IP地址来源地的详细情况,包括IP地址所属的国家,城市,运营商等等。这些数据日后可以直接对这个txt文件进行统计分析,比如哪个时断攻击对多,来源的国家分布情况等等。

IP地址查询服务


这个就是名为IpGeolocationService的一个用来进行IP归属地查询的类,具体代码如下:

public class IpGeolocationService
{
    private readonly IHttpClientFactory _httpClientFactory;
    private readonly ILogger<IpGeolocationService> _logger;
    // Thread-safe dictionary to store the last time an IP's details were queried
    private readonly ConcurrentDictionary<string, DateTimeOffset> _lastIpQueryTimes = new();
    // Minimum time interval between querying the same IP details API
    private static readonly TimeSpan _minIpQueryInterval = TimeSpan.FromSeconds(5); // e.g., 5 seconds

    // Constructor: Injects dependencies
    public IpGeolocationService(IHttpClientFactory httpClientFactory, ILogger<IpGeolocationService> logger)
    {
        _httpClientFactory = httpClientFactory;
        _logger = logger;
        _logger.LogInformation("IpGeolocationService initialized.");
    }

    // Fetches geolocation details for an IP address using ip-api.com, with rate limiting.
    public virtual async Task<string> GetIpDetailsAsync(string ipAddress, CancellationToken cancellationToken)
    {
        DateTimeOffset now = DateTimeOffset.UtcNow;
        string details = "Details unavailable"; // Default message

        // --- Rate Limiting Check ---
        if (_lastIpQueryTimes.TryGetValue(ipAddress, out DateTimeOffset lastQueryTime))
        {
            if (now - lastQueryTime < _minIpQueryInterval)
            {
                _logger.LogInformation("Rate limit hit for IP {IpAddress}. Skipping API query.", ipAddress);
                return "Details unavailable (rate limited)"; // Return specific message
            }
        }
        // --- End Rate Limiting Check ---

        // Use injected HttpClientFactory to create a client
        HttpClient client = _httpClientFactory.CreateClient();
        // Request specific fields to potentially reduce response size/cost
        string apiUrl = $"http://ip-api.com/json/{ipAddress}?fields=status,message,country,regionName,city,isp,org";

        try
        {
            _logger.LogDebug("Querying IP API for {IpAddress} at {ApiUrl}", ipAddress, apiUrl);
            HttpResponseMessage response = await client.GetAsync(apiUrl, cancellationToken);

            // Update last query time *after* the request attempt, regardless of success/failure,
            // to prevent hammering the API even on failures.
            _lastIpQueryTimes.AddOrUpdate(ipAddress, now, (key, oldTime) => now);

            if (response.IsSuccessStatusCode)
            {
                string jsonResponse = await response.Content.ReadAsStringAsync(cancellationToken);
                _logger.LogDebug("IP API Response for {IpAddress}: {JsonResponse}", ipAddress, jsonResponse);

                // Parse the JSON response
                using (JsonDocument document = JsonDocument.Parse(jsonResponse))
                {
                    if (document.RootElement.TryGetProperty("status", out JsonElement statusElement) && statusElement.GetString() == "success")
                    {
                        // Extract fields safely
                        string country = document.RootElement.TryGetProperty("country", out var el) ? el.GetString() ?? "N/A" : "N/A";
                        string region = document.RootElement.TryGetProperty("regionName", out el) ? el.GetString() ?? "N/A" : "N/A";
                        string city = document.RootElement.TryGetProperty("city", out el) ? el.GetString() ?? "N/A" : "N/A";
                        string isp = document.RootElement.TryGetProperty("isp", out el) ? el.GetString() ?? "N/A" : "N/A";
                        string org = document.RootElement.TryGetProperty("org", out el) ? el.GetString() ?? "N/A" : "N/A";
                        details = $"Country: {country}, Region: {region}, City: {city}, ISP: {isp}, Org: {org}";
                    }
                    // Handle API-level errors reported in the JSON body
                    else if (document.RootElement.TryGetProperty("message", out JsonElement messageElement))
                    {
                        details = $"API Error: {messageElement.GetString()}";
                        _logger.LogWarning("IP API returned error for {IpAddress}: {ApiError}", ipAddress, details);
                    }
                }
            }
            else
            {
                // Handle HTTP-level errors
                details = $"HTTP Error: {response.StatusCode}";
                _logger.LogWarning("HTTP Error fetching IP details for {IpAddress}: {StatusCode}", ipAddress, response.StatusCode);
            }
        }
        catch (HttpRequestException ex)
        {
            details = $"HTTP Request Error: {ex.Message}";
            _logger.LogError(ex, "HttpRequestException fetching IP details for {IpAddress}", ipAddress);
        }
        catch (JsonException ex)
        {
            details = "JSON Parsing Error";
            _logger.LogError(ex, "JsonException parsing IP API response for {IpAddress}", ipAddress);
        }
        catch (OperationCanceledException)
        {
            // Don't update details if cancelled
            _logger.LogInformation("IP detail lookup cancelled for {IpAddress}.", ipAddress);
            // Re-throw or return specific cancellation indicator if needed downstream
            // For now, just return the default "Details unavailable"
            details = "Details unavailable (cancelled)";
        }
        catch (Exception ex)
        {
            details = $"Unexpected Error: {ex.Message}";
            _logger.LogError(ex, "Unexpected error fetching IP details for {IpAddress}", ipAddress);
        }

        return details;
    }
}

具体就是往 http://ip-api.com/json/ 这个地址发起一个请求,需要注意的是,这里需要做频率限制(允许1分钟查询50次,这里为了简单做的限制是5秒查询一次),超过了直接返回,因为这个不是特别重要,所以没有做回补操作。

防火墙操作服务 


操作Windows防火墙,需要拥有管理员权限,所以程序必须以管理员身份运行。它的整个逻辑是,如果有一个IP地址需要加入到防火墙中禁止访问。

  1. 需要查询指定名称的防火墙规则是否存在,就是调用查询防火墙规则信息的接口。
  2. 如果不存在,则创建一个防火墙,并把当前禁止访问的IP地址添加进去。
  3. 如果存在,读取目前防火墙里面的禁止访问的IP地址列表,看当前待添加的IP地址信息是否存在于列表中,如果已经存在,则忽略;如果不存在,则将当前IP地址添加到已经存在的IP地址列表后面,用逗号分隔。然后更新防火墙设置。

操作防火墙的创建、查询、修改有两种方式,一种是使用 netsh advfirewall命令,比如查询名为“RDPBlocker"名称的防火墙配置:

netsh advfirewall firewall show rule name="RDPBlocker" verbose

输出结果如下:

规则名称:                             RDPBlocker
----------------------------------------------------------------------
描述:                                 Blocked RDP IPs by RDPBlockerService
已启用:                               是
方向:                                 入
配置文件:                             公用
分组:
本地 IP:                              任何
远程 IP:                              14.198.6.42/32,36.140.10.145/32,36.140.162.129/32,39.150.130.100/32,42.177.94.52/32,58.211.255.186/32,58.221.177.230/32,79.124.40.94/32,101.33.196.173/32,106.38.55.253/32,112.28.149.33/32,116.204.96.242/32,120.220.44.48/32,123.7.18.116/32,161.189.211.93/32,183.239.210.213/32,185.147.124.60/32,185.147.124.61/32,185.147.124.63/32,185.147.124.64/32,202.61.86.79/32,222.73.173.159/32,223.75.187.13/32
协议:                                 任何
边缘遍历:                             否
接口类型:                             任何
安全:                                 NotRequired
规则源:                          本地设置
操作:                                 阻止
确定。

创建防火墙命令:

netsh advfirewall firewall add rule name="RDPBlocker" dir=in action=block remoteip=192.168.1.1 enable=yes profile=any description="Blocked RDP IPs by RDPBlockerService"

这里面name和remoteip可以自定义。

修改防火墙命令,可以看到,如果有多个IP地址,只需要用逗号分隔:

netsh advfirewall firewall set rule name="RDPBlocker" new remoteip="192.168.1.101,192.168.3.10"

完整的 FirewallService 代码如下:

public class FirewallService : IFirewallService
{
    private readonly ILogger<FirewallService> _logger;
    private readonly string _operationalLogFilePath;
    private static readonly object _operationalLogLock = new object(); // Lock for operational log file writing
    private static readonly object _firewallLock = new object(); // Lock for firewall operations

    // Fixed name for the single firewall rule
    private const string FirewallRuleName = "RDPBlocker";
    // Define the possible labels for the Remote IP field
    private static readonly string[] RemoteIpLabels = { "RemoteIP:", "远程 IP:" }; // Add other languages if needed
                                                                                 // Constructor: Injects dependencies
    public FirewallService(ILogger<FirewallService> logger, IOptions<BlockerSettings> settings)
    {
        _logger = logger;
        // Get operational log path from settings
        _operationalLogFilePath = settings.Value.LogFilePath;
        _logger.LogInformation("FirewallService initialized. Operational Log Path: {OperationalLogPath}", _operationalLogFilePath);
    }

    // Updates the single firewall rule "RDPBlocker" to include the new IP address.
    // Creates the rule if it doesn't exist.
    public virtual void UpdateFirewallRule(string newIpAddress)
    {
        // Use a lock to prevent race conditions when multiple events trigger updates concurrently
        lock (_firewallLock)
        {
            _logger.LogInformation("Attempting to update firewall rule '{RuleName}' to block IP {IpAddress}", FirewallRuleName, newIpAddress);

            string? currentRemoteIps = GetCurrentBlockedIps(FirewallRuleName);

            if (currentRemoteIps == null) // Rule doesn't exist or error occurred getting it
            {
                // Attempt to create only if GetCurrentBlockedIps explicitly returned null (not found)
                // If it returned string.Empty (parse error) or other error, we might not want to create.
                // For simplicity here, we try creating if null.
                _logger.LogInformation("Rule '{RuleName}' not found or error retrieving. Attempting to create new rule.", FirewallRuleName);
                CreateFirewallRule(FirewallRuleName, newIpAddress);
            }
            else // Rule exists
            {
                // Check if IP is already in the list (case-insensitive check)
                var ipList = currentRemoteIps.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
                                             .Select(ip => ip.Trim())
                                             .ToList();

                if (ipList.Contains(newIpAddress, StringComparer.OrdinalIgnoreCase))
                {
                    _logger.LogInformation("IP {IpAddress} is already present in rule '{RuleName}'. No update needed.", newIpAddress, FirewallRuleName);
                    return;
                }

                // Append new IP
                string updatedRemoteIps = string.IsNullOrEmpty(currentRemoteIps) || currentRemoteIps.Equals("Any", StringComparison.OrdinalIgnoreCase)
                                          ? newIpAddress
                                          : $"{currentRemoteIps},{newIpAddress}";

                _logger.LogInformation("Updating rule '{RuleName}' with new RemoteIP list: {IpList}", FirewallRuleName, updatedRemoteIps);
                SetFirewallRuleRemoteIps(FirewallRuleName, updatedRemoteIps);
            }
        }
    }

    // Gets the current RemoteIP string from a firewall rule. Returns null if rule not found or error, empty if parse fails, or the IP list.
    protected virtual string? GetCurrentBlockedIps(string ruleName)
    {
        // ** Regex updated to match "RemoteIP" or "远程 IP" at the start of the line, ignoring case **
        //const string remoteIpPattern = @"^\s*(RemoteIP|远程 IP)\s*:\s*(.*)"; // Regex pattern

        string command = $"advfirewall firewall show rule name=\"{ruleName}\" verbose";
        ProcessStartInfo psi = CreateNetshPsi(command);
        string output = string.Empty;
        string error = string.Empty;

        try
        {
            using (Process? process = Process.Start(psi))
            {
                if (process == null) throw new InvalidOperationException($"Failed to start netsh process to show rule '{ruleName}'.");
                // Read streams asynchronously to prevent deadlocks
                var outputTask = process.StandardOutput.ReadToEndAsync();
                var errorTask = process.StandardError.ReadToEndAsync();
                // Wait for process exit with timeout
                if (!process.WaitForExit(TimeSpan.FromSeconds(10))) // Shorter timeout for show command
                {
                    _logger.LogWarning("Netsh process timed out while showing rule '{RuleName}'.", ruleName);
                    try { process.Kill(); } catch (Exception killEx) { _logger.LogError(killEx, "Failed to kill timed-out netsh process."); }
                    return null; // Indicate error
                }
                output = outputTask.Result; // Get results after process exit
                error = errorTask.Result;
                if (process.ExitCode != 0)
                {
                    if (!string.IsNullOrWhiteSpace(error) && error.Contains("No rules match the specified criteria", StringComparison.OrdinalIgnoreCase))
                    {
                        _logger.LogInformation("Firewall rule '{RuleName}' not found via netsh.", ruleName);
                        return null; // Rule doesn't exist
                    }
                    else
                    {
                        _logger.LogError("Failed to show firewall rule '{RuleName}'. Exit Code: {ExitCode}. Error: {Error}", ruleName, process.ExitCode, error.Trim());
                        return null; // Indicate error or non-existence
                    }
                }
            }

            // Parse the output line by line
            using (var reader = new StringReader(output))
            {
                string? line;
                while ((line = reader.ReadLine()) != null)
                {
                    string trimmedLine = line.Trim();
                    // Check if the line starts with any of the known labels
                    foreach (string label in RemoteIpLabels)
                    {
                        // Check if the line starts with the label (case-insensitive)
                        if (trimmedLine.StartsWith(label, StringComparison.OrdinalIgnoreCase))
                        {
                            // Find the index of the colon after the label
                            int colonIndex = trimmedLine.IndexOf(':');
                            if (colonIndex > 0 && colonIndex + 1 < trimmedLine.Length)
                            {
                                // Extract the value after the colon
                                string remoteIpValue = trimmedLine.Substring(colonIndex + 1).Trim();
                                _logger.LogDebug("Found current RemoteIP for rule '{RuleName}': {RemoteIpValue}", ruleName, remoteIpValue);
                                return remoteIpValue; // Return the found list (could be "Any")
                            }
                        }
                    }
                }
            }

            _logger.LogWarning("Could not find/parse RemoteIP from 'netsh show rule' output for rule '{RuleName}'. Output snippet: {Output}", ruleName, output.Length > 500 ? output.Substring(0, 500) : output);
            return string.Empty; // Indicate parsing failure but rule exists

        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Exception occurred while trying to get firewall rule '{RuleName}'.", ruleName);
            return null; // Indicate error
        }
    }

    // Creates the initial firewall rule.
    protected virtual void CreateFirewallRule(string ruleName, string initialIpAddress)
    {
        string command = $"advfirewall firewall add rule name=\"{ruleName}\" dir=in action=block remoteip={initialIpAddress} enable=yes profile=any description=\"Blocked RDP IPs by RDPBlockerService\"";
        ExecuteNetshCommand(command, $"create firewall rule '{ruleName}' with IP {initialIpAddress}");
    }

    // Updates the RemoteIP list for an existing firewall rule.
    protected virtual void SetFirewallRuleRemoteIps(string ruleName, string remoteIpList)
    {
        // Ensure list isn't excessively long (netsh might have limits) - Optional check
        if (remoteIpList.Length > 2000) // Arbitrary limit, adjust as needed
        {
            _logger.LogWarning("Remote IP list for rule '{RuleName}' is getting very long ({Length} chars). Consider manual cleanup or alternative strategy.", ruleName, remoteIpList.Length);
        }

        // Escape the comma-separated list if necessary, though usually fine for IPs
        string command = $"advfirewall firewall set rule name=\"{ruleName}\" new remoteip=\"{remoteIpList}\"";
        ExecuteNetshCommand(command, $"update firewall rule '{ruleName}'");
    }

    // Helper method to execute a netsh command and log results.
    private void ExecuteNetshCommand(string commandArguments, string operationDescription)
    {
        ProcessStartInfo psi = CreateNetshPsi(commandArguments);
        try
        {
            using (Process? process = Process.Start(psi))
            {
                if (process == null)
                {
                    throw new InvalidOperationException($"Failed to start netsh process for operation '{operationDescription}'. Process.Start returned null.");
                }

                // Read streams async
                var outputTask = process.StandardOutput.ReadToEndAsync();
                var errorTask = process.StandardError.ReadToEndAsync();


                if (!process.WaitForExit(TimeSpan.FromSeconds(15)))
                {
                    _logger.LogWarning("Netsh process timed out during operation '{Operation}'. Status uncertain.", operationDescription);
                    try { process.Kill(); } catch (Exception killEx) { _logger.LogError(killEx, "Failed to kill timed-out netsh process."); }
                    return;
                }

                string output = outputTask.Result;
                string error = errorTask.Result;


                if (process.ExitCode == 0)
                {
                    _logger.LogInformation("Successfully executed netsh operation: '{Operation}'.", operationDescription);
                    // Optionally log success to operational log if needed for specific operations
                    // LogOperation($"[{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss UTC}] SUCCESS: Operation '{operationDescription}'.");
                }
                else
                {
                    _logger.LogError("Failed to execute netsh operation '{Operation}'. Exit Code: {ExitCode}. Error: {Error}. Output: {Output}",
                        operationDescription, process.ExitCode, error.Trim(), output.Trim());
                }
            }
        }
        catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 1223) // ERROR_CANCELLED (UAC prompt declined)
        {
            _logger.LogError(ex, "Netsh execution cancelled (UAC?) during operation '{Operation}'. Ensure service runs elevated.", operationDescription);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Exception executing netsh operation '{Operation}'.", operationDescription);
        }
    }

    // Helper to create ProcessStartInfo for netsh.
    private ProcessStartInfo CreateNetshPsi(string arguments)
    {
        return new ProcessStartInfo("netsh", arguments)
        {
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false,
            CreateNoWindow = true,
            Verb = "runas"
        };
    }
}

这是Gemini原本提供的解决方案,但是它存在一个很致命的问题,就是在查询防火墙规则的时候,它依赖的是对返回字符串的解析,但是这个字符串又与本地语言和文字相关,比如执行上述的查询防火墙规则的语句:

netsh advfirewall firewall show rule name="RDPBlocker" verbose

如果操作系统语言是中文,则输出如下:

规则名称:                             RDPBlocker
----------------------------------------------------------------------
描述:                                 Blocked RDP IPs by RDPBlockerService
已启用:                               是
方向:                                 入
配置文件:                             公用
分组:
本地 IP:                              任何
远程 IP:                              14.198.6.42/32,36.140.10.145/32 
协议:                                 任何
边缘遍历:                             否
接口类型:                             任何
安全:                                 NotRequired
规则源:                          本地设置
操作:                                 阻止

这里主要解析的是"远程 IP:" 这个字段。

如果操作系统是英文,则上述汉字都会变成英文,”远程 IP“就会是“RemoteIP” ,这还算是好的,如果终端的输出字符设置与操作系统的设置不一致,则有可能出现乱码,比如我在一台机器的日志上看到的:

瑙勫垯鍚嶇О:                             RDPBlocker
----------------------------------------------------------------------
鎻忚堪:                                 Blocked RDP IPs by RDPBlockerService
宸插惎鐢?                               鏄?
鏂瑰悜:                                 鍏?
閰嶇疆鏂囦欢:                             鍩?涓撶敤,鍏敤
鍒嗙粍:                                 
鏈湴 IP:                              浠讳綍
杩滅▼ IP:                              5.181.158.243/32
鍗忚:                                 浠讳綍

这怎么搞?是不是很 “手持两把锟斤拷,口中疾呼烫烫烫”。在这点上Gemini似乎走入了死胡同。

考虑到前篇文章中使用的方法,我提醒Gemini采用 NetFwTypeLib 的方案,它表示非常赞同,整个代码得到了极大的简化,不用处理烦人的文本解析,一切都是强类型的,在INetFwRule中就有RemoteAddresses属性。第二个版本的FirewallServiceV2代码如下:

public class FirewallServiceV2 : IFirewallService
{
    private readonly ILogger<FirewallServiceV2> _logger;
    private readonly string _operationalLogFilePath;
    private static readonly object _firewallLock = new object(); // Lock for firewall operations

    // Fixed name for the single firewall rule
    private const string FirewallRuleName = "RDPBlocker"; // Keep the same rule name

    // Constructor: Injects dependencies
    public FirewallServiceV2(ILogger<FirewallServiceV2> logger, IOptions<BlockerSettings> settings)
    {
        _logger = logger;
        // Get operational log path from settings
        _operationalLogFilePath = settings.Value.LogFilePath;
        _logger.LogInformation("FirewallServiceV2 initialized. Operational Log Path: {OperationalLogPath}", _operationalLogFilePath);
    }

    // Gets the INetFwPolicy2 firewall policy object.
    private INetFwPolicy2? GetFirewallPolicy()
    {
        try
        {
            Type policy2Type = Type.GetTypeFromProgID("HNetCfg.FwPolicy2", throwOnError: true)!;
            return (INetFwPolicy2)Activator.CreateInstance(policy2Type)!;
        }
        catch (Exception ex)
        {
            _logger.LogCritical(ex, "Failed to get INetFwPolicy2 instance. Firewall operations will fail. Ensure Firewall service is running and COM permissions are correct.");
            return null;
        }
    }

    // Gets an existing firewall rule by name. Returns null if not found or error.
    private INetFwRule? GetRule(INetFwPolicy2 policy, string ruleName)
    {
        try
        {
            // Accessing Rules might require elevation even if policy object was retrieved.
            return policy.Rules.Item(ruleName);
        }
        catch (FileNotFoundException)
        {
            _logger.LogInformation("Firewall rule '{RuleName}' not found.", ruleName);
            return null; // Rule doesn't exist
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error retrieving firewall rule '{RuleName}'.", ruleName);
            return null; // Indicate error
        }
    }

    // Updates the single firewall rule "RDPBlocker" to include the new IP address.
    // Creates the rule if it doesn't exist.
    // ** Made public virtual for mocking **
    public virtual void UpdateFirewallRule(string newIpAddress)
    {
        // Use a lock to prevent race conditions
        lock (_firewallLock)
        {
            _logger.LogInformation("Attempting to update firewall rule '{RuleName}' using INetFwPolicy2 to block IP {IpAddress}", FirewallRuleName, newIpAddress);

            INetFwPolicy2? policy = GetFirewallPolicy();
            if (policy == null) return; // Error already logged

            INetFwRule? rule = GetRule(policy, FirewallRuleName);

            try
            {
                if (rule == null) // Rule doesn't exist
                {
                    _logger.LogInformation("Rule '{RuleName}' not found. Creating new rule.", FirewallRuleName);
                    CreateFirewallRuleInternal(policy, FirewallRuleName, newIpAddress);
                }
                else // Rule exists
                {
                    string currentRemoteIps = rule.RemoteAddresses;
                    _logger.LogDebug("Current RemoteAddresses for rule '{RuleName}': {Addresses}", FirewallRuleName, currentRemoteIps);

                    // Check if IP is already in the list (case-insensitive check)
                    // Handle "*" (Any) and empty string cases
                    if (currentRemoteIps == "*" || string.IsNullOrEmpty(currentRemoteIps))
                    {
                        // If it's Any or empty, replace it with the new IP
                        _logger.LogInformation("Rule '{RuleName}' currently allows 'Any' or is empty. Setting RemoteAddresses to {NewIpAddress}.", FirewallRuleName, newIpAddress);
                        rule.RemoteAddresses = newIpAddress;
                    }
                    else
                    {
                        var ipList = currentRemoteIps.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
                                                     .Select(ip => ip.Trim())
                                                     .ToList();

                        if (ipList.Contains(newIpAddress, StringComparer.OrdinalIgnoreCase))
                        {
                            _logger.LogInformation("IP {IpAddress} is already present in rule '{RuleName}'. No update needed.", newIpAddress, FirewallRuleName);
                            return; // Already blocked
                        }

                        // Append new IP (ensure CIDR format if needed, though single IPs are fine)
                        string updatedRemoteIps = $"{currentRemoteIps},{newIpAddress}";

                        // Optional: Check length limit (though COM interface might handle it better than netsh)
                        if (updatedRemoteIps.Length > 2000) // Arbitrary check
                        {
                            _logger.LogWarning("Remote addresses list for rule '{RuleName}' is getting very long ({Length} chars).", FirewallRuleName, updatedRemoteIps.Length);
                        }

                        _logger.LogInformation("Updating rule '{RuleName}' with new RemoteAddresses list: {IpList}", FirewallRuleName, updatedRemoteIps);
                        rule.RemoteAddresses = updatedRemoteIps;
                    }
                }
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Failed to update firewall rule '{RuleName}' for IP {IpAddress}.", FirewallRuleName, newIpAddress);
            }
            finally
            {
                // Release COM objects
                if (rule != null) Marshal.ReleaseComObject(rule);
                if (policy != null) Marshal.ReleaseComObject(policy);
            }
        }
    }

    // Creates the initial firewall rule using COM.
    // ** Made protected virtual for mocking in unit tests **
    protected virtual void CreateFirewallRuleInternal(INetFwPolicy2 policy, string ruleName, string initialIpAddress)
    {
        INetFwRule? newRule = null;
        try
        {
            Type ruleType = Type.GetTypeFromProgID("HNetCfg.FwRule", throwOnError: true)!;
            newRule = (INetFwRule)Activator.CreateInstance(ruleType)!;

            newRule.Name = ruleName;
            newRule.Description = "Blocked RDP IPs by RDPBlockerService";
            newRule.Protocol = (int)NET_FW_IP_PROTOCOL_.NET_FW_IP_PROTOCOL_ANY; // Block any protocol from the IP
            newRule.RemoteAddresses = initialIpAddress;
            newRule.Direction = NET_FW_RULE_DIRECTION_.NET_FW_RULE_DIR_IN; // Inbound rule
            newRule.Action = NET_FW_ACTION_.NET_FW_ACTION_BLOCK; // Block traffic
            newRule.Profiles = policy.CurrentProfileTypes; // Apply to current profiles
            newRule.Enabled = true;

            policy.Rules.Add(newRule);
            _logger.LogInformation("Successfully created firewall rule '{RuleName}' for IP {IpAddress}.", ruleName, initialIpAddress);
            // LogOperation($"[{DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss zzz}] SUCCESS: Created firewall rule '{ruleName}' with IP {initialIpAddress}."); // Optional: Log success
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to create firewall rule '{RuleName}'.", ruleName);
        }
        finally
        {
            // Release COM object for the new rule if created
            if (newRule != null) Marshal.ReleaseComObject(newRule);
        }
    }
}

它需要引用名为“NetFwTypeLib”的COM组件。

程序入口


在program.cs文件里,它有一个异步的main函数,它的主要工作就是注入一些服务、判断当前的运行环境:是以console模式运行还是以windows service模式运行,然后启动程序:

internal class Program
{
    public static async Task Main(string[] args)
    {
        // --- Host Builder Setup ---
        HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

        // --- Configuration ---
        // Configure BlockerSettings from appsettings.json
        builder.Services.Configure<BlockerSettings>(
            builder.Configuration.GetSection("BlockerSettings")
        );

        // --- Add HttpClient for IP lookups ---
        // Register HttpClient for dependency injection
        builder.Services.AddHttpClient();

        // --- Register Custom Services ---
        // ** CRITICAL: Register IpGeolocationService as a Singleton **
        // Ensure the class IpGeolocationService exists and is in the RDPBlockerService namespace.
        builder.Services.AddSingleton<RDPBlock.IpGeolocationService>();

        // Register FirewallService as a Singleton
        // ** Register FirewallServiceV2 AS IFirewallService **
        builder.Services.AddSingleton<IFirewallService, RDPBlock.FirewallServiceV2>(); // Register concrete type V2 f

        // --- Logging Configuration (Using log4net) ---
        builder.Logging.ClearProviders(); // Clear default providers
                                          // Add log4net provider, specifying the config file name.
        builder.Logging.AddLog4Net("log4net.config");
        Console.WriteLine("Configured logging with log4net provider."); // Log to console during startup/debug

        // --- Service Registration ---
        builder.Services.AddHostedService<Worker>(); // Register the core logic worker

        // --- Conditional Hosting ---
        bool isService = !(Debugger.IsAttached || args.Contains("--console"));
        if (isService && !Environment.UserInteractive)
        {
            builder.Services.AddWindowsService(options =>
            {
                options.ServiceName = "RDP Blocker Service";
            });
            Console.WriteLine("Configuring as Windows Service.");
        }
        else
        {
            Console.WriteLine("Configuring as Console Application.");
            // Console logging is handled by log4net.config if ConsoleAppender is added there
        }
        // --- End Conditional Hosting ---

        // --- Build and Run Host ---
        try
        {
            IHost host = builder.Build();

            // --- Post-Build Validation/Setup ---
            var settings = host.Services.GetRequiredService<IOptions<BlockerSettings>>().Value;
            var logger = host.Services.GetRequiredService<ILogger<Program>>(); // Get logger instance

            try
            {
                // Ensure operational log directory exists
                string? logDir = Path.GetDirectoryName(settings.LogFilePath);
                if (!string.IsNullOrEmpty(logDir) && !Directory.Exists(logDir))
                {
                    logger.LogInformation("Attempting to create operational log directory: {LogDir}", logDir);
                    Directory.CreateDirectory(logDir);
                }
                // Ensure log4net directory exists if using a fixed path (e.g., C:\ProgramData)
                // Note: Using %APPDATA% in log4net.config avoids needing manual creation here usually.
            }
            catch (Exception ex)
            {
                logger.LogWarning(ex, "Could not create operational log directory {LogDirectory}. Operational log writing might fail.", Path.GetDirectoryName(settings.LogFilePath));
                Console.WriteLine($"WARNING: Could not create operational log directory '{Path.GetDirectoryName(settings.LogFilePath)}'. Error: {ex.Message}");
            }
            // --- End Post-Build Validation ---

            logger.LogInformation("Host built successfully. Starting application run...");
            await host.RunAsync(); // Run the application
            logger.LogInformation("Host stopped successfully.");
            Console.WriteLine("Host stopped successfully.");
        }
        catch (Exception ex)
        {
            // Log critical failure during startup using Console as fallback
            Console.WriteLine($"FATAL ERROR: Host terminated unexpectedly. {ex}");
            // Try logging to a fallback file
            try { File.AppendAllText(Path.Combine(AppContext.BaseDirectory, "fatal_error.log"), $"[{DateTime.UtcNow}] {ex}\n"); } catch { }
            // If log4net was initialized, try logging via its static instance
            log4net.LogManager.GetLogger(typeof(Program))?.Fatal("Host terminated unexpectedly.", ex);
            Environment.ExitCode = 1; // Indicate failure
        }
        finally
        {
            // Ensure log4net logs are flushed and resources released.
            log4net.LogManager.Shutdown();
            Console.WriteLine("log4net shutdown completed.");
            // ** Add this for console debugging **
            if (!Environment.UserInteractive || Debugger.IsAttached || args.Contains("--console"))
            {
                Console.WriteLine("\nPress ENTER to exit...");
                Console.ReadLine();
            }
        }
    }
}

安装和运行


程序可以以Console或者Windows Service的方式运行,一般Console模式可以用来进行调试,如果没问题了可以直接安装为Windows服务。

编译完成,并发布之后,

dotnet publish -r win-x64 --self-contained true /p:PublishSingleFile=true

转到发布之后的目录。可以在该目录下创建快捷方式,快捷方式中,在目标程序RDPBlocker.exe后面添加“--console”,然后以管理员权限运行快捷方式,就能以Console模式运行程序了。

或者以管理员方式运行命令行程序,切换到目标程序目录,运行:

.\RDPBlocker.exe --console

若要以服务的方式运行,则需要进行安装,安装和删除服务的命令和传统的.NET Framework时期的有所不同。.NET Framework时期的命令为:

InstallUtil.exe  “Path/WinServiceName.exe”     //安装服务
net start ServiceName                                      //启动服务
net stop ServiceName                                      //停止服务
InstallUtil.exe /u  “Path/WinServiceName.exe” //删除服务

而在.NET 之后的安装服务命令则为:

//安装服务
sc.exe create "RDPBlockerService " binPath="C:\Your\Full\Path\To\publish\RDPBlock.exe"  DisplayName= "RDP Failed Login Blocker" start=auto
//添加服务描述
sc.exe description "RDPBlockerService " "Monitors failed RDP login attempts and blocks suspicious IP addresses using the Windows Firewall."
//启动服务
sc.exe start RDPBlockerService
//停止服务
sc.exe stop RDPBlockerService
//删除服务
sc.exe delete RDPBlockerService

单元测试


使用Gemini,可以非常方便的为代码生成单元测试,这里就不列出来了。