Skip to content

Commit

Permalink
Removed FluentScheduler because it's a static singleton, so you can't…
Browse files Browse the repository at this point in the history
… mutate its state without affecting other parallel tests.
  • Loading branch information
Aldaviva committed Apr 12, 2021
1 parent ee1351c commit 884d2a9
Show file tree
Hide file tree
Showing 14 changed files with 358 additions and 96 deletions.
5 changes: 2 additions & 3 deletions Fail2Ban4Win.sln
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ VisualStudioVersion = 16.0.31129.286
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Fail2Ban4Win", "Fail2Ban4Win\Fail2Ban4Win.csproj", "{F87074CC-C205-403F-8113-5F41716BE1CB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj", "{7A7D0B42-33B3-47F6-94F6-FAAA585A3B13}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tests", "Tests\Tests.csproj", "{7A7D0B42-33B3-47F6-94F6-FAAA585A3B13}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Expand All @@ -19,8 +19,7 @@ Global
{F87074CC-C205-403F-8113-5F41716BE1CB}.Release|Any CPU.Build.0 = Release|Any CPU
{7A7D0B42-33B3-47F6-94F6-FAAA585A3B13}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7A7D0B42-33B3-47F6-94F6-FAAA585A3B13}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7A7D0B42-33B3-47F6-94F6-FAAA585A3B13}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7A7D0B42-33B3-47F6-94F6-FAAA585A3B13}.Release|Any CPU.Build.0 = Release|Any CPU
{7A7D0B42-33B3-47F6-94F6-FAAA585A3B13}.Release|Any CPU.ActiveCfg = Debug|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
79 changes: 28 additions & 51 deletions Fail2Ban4Win/Config/Configuration.cs
Original file line number Diff line number Diff line change
@@ -1,34 +1,46 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Configuration;
using NLog;

#nullable enable

namespace Fail2Ban4Win.Config {

public class Configuration {
public class Configuration: ICloneable {

public bool isDryRun { get; set; }
public int maxAllowedFailures { get; set; }
public TimeSpan failureWindow { get; set; }
public TimeSpan banPeriod { get; set; }
public byte? banSubnetBits { get; set; }
public int? banRepeatedOffenseCoefficient { get; set; }
public int? banRepeatedOffenseMax { get; set; }
public LogLevel? logLevel { get; set; }
public IEnumerable<IPNetwork>? neverBanSubnets { get; set; }
public IEnumerable<EventLogSelector> eventLogSelectors { get; set; } = null!;
public ICollection<IPNetwork>? neverBanSubnets { get; set; }
public ICollection<EventLogSelector> eventLogSelectors { get; set; } = null!;

public override string ToString() =>
$"{nameof(maxAllowedFailures)}: {maxAllowedFailures}, {nameof(failureWindow)}: {failureWindow}, {nameof(banPeriod)}: {banPeriod}, {nameof(banSubnetBits)}: {banSubnetBits}, {nameof(neverBanSubnets)}: [{{{string.Join("}, {", neverBanSubnets ?? new IPNetwork[0])}}}], {nameof(eventLogSelectors)}: [{{{string.Join("}, {", eventLogSelectors)}}}], {nameof(isDryRun)}: {isDryRun}, {nameof(logLevel)}: {logLevel}";
$"{nameof(maxAllowedFailures)}: {maxAllowedFailures}, {nameof(failureWindow)}: {failureWindow}, {nameof(banPeriod)}: {banPeriod}, {nameof(banSubnetBits)}: {banSubnetBits}, {nameof(banRepeatedOffenseCoefficient)}: {banRepeatedOffenseCoefficient}, {nameof(banRepeatedOffenseMax)}: {banRepeatedOffenseMax}, {nameof(neverBanSubnets)}: [{{{string.Join("}, {", neverBanSubnets ?? new IPNetwork[0])}}}], {nameof(eventLogSelectors)}: [{{{string.Join("}, {", eventLogSelectors)}}}], {nameof(isDryRun)}: {isDryRun}, {nameof(logLevel)}: {logLevel}";

public object Clone() => new Configuration {
isDryRun = isDryRun,
maxAllowedFailures = maxAllowedFailures,
failureWindow = failureWindow,
banPeriod = banPeriod,
banSubnetBits = banSubnetBits,
banRepeatedOffenseCoefficient = banRepeatedOffenseCoefficient,
banRepeatedOffenseMax = banRepeatedOffenseMax,
logLevel = logLevel,
neverBanSubnets = neverBanSubnets is not null ? new List<IPNetwork>(neverBanSubnets) : null,
eventLogSelectors = eventLogSelectors.Select(selector => (EventLogSelector) selector.Clone()).ToList()
};

}

public class EventLogSelector {
public class EventLogSelector: ICloneable {

public string logName { get; set; } = null!;
public string? source { get; set; }
Expand All @@ -39,48 +51,13 @@ public class EventLogSelector {
public override string ToString() =>
$"{nameof(logName)}: {logName}, {nameof(source)}: {source}, {nameof(eventId)}: {eventId}, {nameof(ipAddressPattern)}: {ipAddressPattern}, {nameof(ipAddressEventDataName)}: {ipAddressEventDataName}";

}

[ExcludeFromCodeCoverage]
public class Test {

public static void Main() {
string json = @"{
""maxAllowedFailures"": 9,
""failureWindow"": ""1.00:00:00"",
""banPeriod"": ""1.00:00:00"",
""banSubnetBits"": 24,
""neverBanSubnets"": [
""127.0.0.1/8"",
""192.168.1.0/24"",
""67.210.32.33"",
""73.202.12.148""
],
""eventLogSelectors"": [
{
""logName"": ""Security"",
""eventId"": 4625,
""ipAddressEventDataName"": ""IpAddress""
}, {
""logName"": ""Application"",
""source"": ""sshd"",
""eventId"": 0,
""ipAddressPattern"": ""^sshd: PID \\d+: Failed password for(?: invalid user)? \\S+ from (?<ipAddress>(?:\\d{1,3}\\.){3}\\d{1,3}) port \\d+ ssh\\d?$""
}
],
""isDryRun"": true,
""logLevel"": ""info""
}";
IPNetworkDeserializer.register();
RegexDeserializer.register();

Stream jsonStream = new MemoryStream(Encoding.UTF8.GetBytes(json));
IConfigurationRoot configuration = new ConfigurationBuilder()
.AddJsonStream(jsonStream)
.Build();
var deserialized = configuration.Get<Configuration>();
Console.WriteLine(deserialized);
}
public object Clone() => new EventLogSelector {
ipAddressEventDataName = ipAddressEventDataName,
eventId = eventId,
ipAddressPattern = ipAddressPattern is not null ? new Regex(ipAddressPattern.ToString(), ipAddressPattern.Options, ipAddressPattern.MatchTimeout) : null,
logName = logName,
source = source
};

}

Expand Down
20 changes: 20 additions & 0 deletions Fail2Ban4Win/Data/EnumerableExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System.Collections.Generic;
using System.Linq;

#nullable enable

// ReSharper disable InconsistentNaming - library functions that are supposed to look like Linq methods, which user UpperCase naming.

namespace Fail2Ban4Win.Data {

public static class EnumerableExtensions {

/// <summary>Remove null values.</summary>
/// <returns>Input enumerable with null values removed.</returns>
public static IEnumerable<T> Compact<T>(this IEnumerable<T?> source) where T: class {
return source.Where(item => item is not null)!;
}

}

}
5 changes: 4 additions & 1 deletion Fail2Ban4Win/Facades/EventLogWatcherFacade.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,15 @@ public interface EventLogWatcherFacade: IDisposable {

}

internal class EventLogWatcherFacadeImpl: EventLogWatcherFacade {
public class EventLogWatcherFacadeImpl: EventLogWatcherFacade {

private readonly EventLogWatcher watcher;

public event EventHandler<EventRecordWrittenEventArgsFacade>? EventRecordWritten;

/// <summary>Determines whether this object starts delivering events to the event delegate.</summary>
/// <returns>Returns <see langword="true" /> when this object can deliver events to the event delegate, and returns <see langword="false" /> when this object has stopped delivery.</returns>
/// <exception cref="EventLogNotFoundException">If the <c>EventLogQueryFacade.path</c> cannot be found while setting <c>Enabled</c> to <see langword="true" />.</exception>
public bool Enabled {
get => watcher.Enabled;
set => watcher.Enabled = value;
Expand Down
9 changes: 5 additions & 4 deletions Fail2Ban4Win/Fail2Ban4Win.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
<Compile Include="Config\Configuration.cs" />
<Compile Include="Config\IPNetworkDeserializer.cs" />
<Compile Include="Config\RegexDeserializer.cs" />
<Compile Include="Data\EnumerableExtensions.cs" />
<Compile Include="Facades\EventLogWatcherFacade.cs" />
<Compile Include="Facades\FirewallFacade.cs" />
<Compile Include="Injection\ConfigurationModule.cs" />
Expand All @@ -92,7 +93,10 @@
<None Include="App.config" />
<None Include="app.manifest" />
<None Include="configuration.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Include="Install service.ps1">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
Expand All @@ -104,9 +108,6 @@
<Content Include="pifmgr_37.ico" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentScheduler">
<Version>5.5.1</Version>
</PackageReference>
<PackageReference Include="ILRepack.Lib.MSBuild.Task">
<Version>2.0.18.2</Version>
</PackageReference>
Expand Down
3 changes: 3 additions & 0 deletions Fail2Ban4Win/Install service.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
$binaryPathName = Resolve-Path ".\Fail2Ban4Win.exe"

New-Service -Name "Fail2Ban4Win" -DisplayName "Fail2Ban4Win" -Description "After enough incorrect passwords from a remote client, block them using Windows Firewall." -BinaryPathName $binaryPathName.Path
36 changes: 25 additions & 11 deletions Fail2Ban4Win/Services/BanManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using WindowsFirewallHelper;
using WindowsFirewallHelper.Addresses;
using WindowsFirewallHelper.FirewallRules;
using Fail2Ban4Win.Config;
using Fail2Ban4Win.Data;
using Fail2Ban4Win.Facades;
using Fail2Ban4Win.Injection;
using FluentScheduler;
using NLog;

#nullable enable
Expand All @@ -33,15 +34,14 @@ public class BanManagerImpl: BanManager {
private readonly Configuration configuration;
private readonly FirewallFacade firewall;

private readonly ConcurrentDictionary<IPNetwork, SubnetFailureHistory> failures = new();
private readonly ConcurrentDictionary<IPNetwork, SubnetFailureHistory> failures = new();
private readonly CancellationTokenSource cancellationTokenSource = new();

public BanManagerImpl(EventLogListener eventLogListener, Configuration configuration, FirewallFacade firewall) {
this.eventLogListener = eventLogListener;
this.configuration = configuration;
this.firewall = firewall;

JobManager.Initialize(new Registry());

IEnumerable<FirewallWASRule> oldRules = firewall.Rules.Where(isBanRule()).ToList();
if (oldRules.Any()) {
LOGGER.Info("Deleting {0} existing {1} rules from Windows Firewall because they may be stale.", oldRules.Count(), GROUP_NAME);
Expand Down Expand Up @@ -104,11 +104,11 @@ private bool shouldBan(IPNetwork subnet, SubnetFailureHistory clientFailureHisto
private void ban(IPNetwork subnet, SubnetFailureHistory clientFailureHistory) {
clientFailureHistory.banCount++;

DateTime now = DateTime.Now;
DateTime unbanTime = now + TimeSpan.FromMilliseconds(Math.Min(clientFailureHistory.banCount, 4) * configuration.banPeriod.TotalMilliseconds);
DateTime now = DateTime.Now;
TimeSpan unbanDuration = getUnbanDuration(clientFailureHistory.banCount);

var rule = new FirewallWASRuleWin7(getRuleName(subnet), FirewallAction.Block, FirewallDirection.Inbound, ALL_PROFILES) {
Description = $"Created on {now:F}, to be deleted on {unbanTime:F} (offense #{clientFailureHistory.banCount:N0}).",
Description = $"Banned {now:s}. Will unban {now + unbanDuration:s}. Offense #{clientFailureHistory.banCount:N0}.",
Grouping = GROUP_NAME,
RemoteAddresses = new IAddress[] { new NetworkAddress(subnet.Network, subnet.Netmask) }
};
Expand All @@ -117,9 +117,10 @@ private void ban(IPNetwork subnet, SubnetFailureHistory clientFailureHistory) {
firewall.Rules.Add(rule);
}

JobManager.AddJob(() => unban(subnet), schedule => schedule.ToRunOnceAt(unbanTime));
Task.Delay(unbanDuration, cancellationTokenSource.Token)
.ContinueWith(_ => unban(subnet), cancellationTokenSource.Token, TaskContinuationOptions.LongRunning | TaskContinuationOptions.NotOnCanceled, TaskScheduler.Current);

LOGGER.Info("Added Windows Firewall rule to block inbound traffic from {0}, which will be removed at {1:F} (in {2:g}).", subnet, unbanTime, configuration.banPeriod);
LOGGER.Info("Added Windows Firewall rule to block inbound traffic from {0}, which will be removed at {1:F} (in {2:g}).", subnet, unbanDuration, configuration.banPeriod);

LOGGER.Trace("Clearing internal history of failures for {0} now that a firewall rule has been created.", subnet);

Expand All @@ -128,6 +129,20 @@ private void ban(IPNetwork subnet, SubnetFailureHistory clientFailureHistory) {
}
}

/// <summary>For first offenses, this returns <c>banPeriod</c> (from <c>configuration.json</c>). For repeated offenses, the ban period is increased by <c>banRepeatedOffenseCoefficient</c> each time. The ban period stops increasing after <c>banRepeatedOffenseMax</c> offenses. <list type="bullet">sfsdf</list></summary>
/// <remarks>
/// <para>Example using <c>banPeriod</c> = 1 day, <c>banRepeatedOffenseCoefficient</c> = 1, and <c>banRepeatedOffenseMax</c> = 4:</para>
/// <list type="table"><listheader><term>Offense</term> <description>Ban duration</description></listheader> <item><term>1st</term> <description>1 day</description></item> <item><term>2nd</term> <description>2 days</description></item> <item><term>3rd</term> <description>3 days</description></item> <item><term>4th</term> <description>4 days</description></item> <item><term>5th</term> <description>4 days</description></item> <item><term>6th</term> <description>4 days</description></item></list></remarks>
/// <param name="banCount">How many times the subnet in question has been banned, including this time. Starts at <c>1</c> for a new subnet that is being banned for the first time.</param>
/// <returns>How long the offending subnet should be banned.</returns>
public TimeSpan getUnbanDuration(int banCount) {
banCount = Math.Max(1, banCount);
return configuration.banPeriod + TimeSpan.FromMilliseconds(
(Math.Min(banCount, configuration.banRepeatedOffenseMax ?? 4) - 1) *
(configuration.banRepeatedOffenseCoefficient ?? 1) *
configuration.banPeriod.TotalMilliseconds);
}

private void unban(IPNetwork subnet) {
IEnumerable<FirewallWASRule> rulesToRemove = firewall.Rules.Where(isBanRule(subnet));
foreach (FirewallWASRule rule in rulesToRemove) {
Expand All @@ -147,8 +162,7 @@ private static Func<FirewallWASRule, bool> isBanRule(IPNetwork? subnet = null) {

public void Dispose() {
eventLogListener.failure -= onFailure;
JobManager.Stop();
JobManager.RemoveAllJobs();
cancellationTokenSource.Cancel();
}

}
Expand Down
15 changes: 11 additions & 4 deletions Fail2Ban4Win/Services/EventLogListener.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using System.Text;
using System.Text.RegularExpressions;
using Fail2Ban4Win.Config;
using Fail2Ban4Win.Data;
using Fail2Ban4Win.Facades;
using NLog;

Expand Down Expand Up @@ -44,11 +45,19 @@ public EventLogListenerImpl(Configuration configuration, Func<EventLogQueryFacad
onEventRecordWritten(record, selector);
}
};
watcher.Enabled = true;
try {
watcher.Enabled = true;
} catch (EventLogNotFoundException e) {
LOGGER.Warn("Failed to listen for events in log {0}: {1}. Skipping this event selector.", selector.logName, e.Message);
watcher.Dispose();
return null;
}
LOGGER.Info("Listening for Event Log records from the {0} log with event ID {1} and {2}.", selector.logName, selector.eventId,
selector.source is not null ? "source " + selector.source : "any source");
return watcher;
}).ToList();
}).Compact().ToList();
}

private void onEventRecordWritten(EventLogRecordFacade record, EventLogSelector selector) {
Expand All @@ -71,8 +80,6 @@ private void onEventRecordWritten(EventLogRecordFacade record, EventLogSelector
LOGGER.Info("Authentication failure detected from {0} (log={1}, event={2}, source={3}).", failingIpAddress, record.LogName, record.Id, record.ProviderName);
failure?.Invoke(this, failingIpAddress);
}
} else {
LOGGER.Trace("Could not find any IPv4 addresses in {0}", stringContainingIpAddress);
}
}
}
Expand Down
9 changes: 8 additions & 1 deletion Fail2Ban4Win/configuration.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
"failureWindow": "1.00:00:00",
"banPeriod": "1.00:00:00",
"banSubnetBits": 8,
"banRepeatedOffenseCoefficient": 1,
"banRepeatedOffenseMax": 4,
"logLevel": "Debug",
"neverBanSubnets": [
"67.210.32.33/32",
Expand All @@ -18,7 +20,12 @@
"logName": "Application",
"source": "sshd",
"eventId": 0,
"ipAddressPattern": "^sshd: PID \\d+: Failed password for(?: invalid user)? \\S+ from (?<ipAddress>(?:\\d{1,3}\\.){3}\\d{1,3}) port \\d{1,5} ssh\\d?$"
"ipAddressPattern": "^sshd: PID \\d+: Failed password for(?: invalid user)? .+ from (?<ipAddress>(?:\\d{1,3}\\.){3}\\d{1,3}) port \\d{1,5} ssh\\d?$"
}, {
"logName": "OpenSSH/Operational",
"eventId": 4,
"ipAddressEventDataName": "payload",
"ipAddressPattern": "^Failed password for(?: invalid user)? .+ from (?<ipAddress>(?:\\d{1,3}\\.){3}\\d{1,3}) port \\d{1,5} ssh\\d?$"
}
]
}
Loading

0 comments on commit 884d2a9

Please sign in to comment.