Skip to content

Commit 884d2a9

Browse files
committed
Removed FluentScheduler because it's a static singleton, so you can't mutate its state without affecting other parallel tests.
1 parent ee1351c commit 884d2a9

File tree

14 files changed

+358
-96
lines changed

14 files changed

+358
-96
lines changed

Fail2Ban4Win.sln

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ VisualStudioVersion = 16.0.31129.286
55
MinimumVisualStudioVersion = 10.0.40219.1
66
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Fail2Ban4Win", "Fail2Ban4Win\Fail2Ban4Win.csproj", "{F87074CC-C205-403F-8113-5F41716BE1CB}"
77
EndProject
8-
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj", "{7A7D0B42-33B3-47F6-94F6-FAAA585A3B13}"
8+
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tests", "Tests\Tests.csproj", "{7A7D0B42-33B3-47F6-94F6-FAAA585A3B13}"
99
EndProject
1010
Global
1111
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -19,8 +19,7 @@ Global
1919
{F87074CC-C205-403F-8113-5F41716BE1CB}.Release|Any CPU.Build.0 = Release|Any CPU
2020
{7A7D0B42-33B3-47F6-94F6-FAAA585A3B13}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
2121
{7A7D0B42-33B3-47F6-94F6-FAAA585A3B13}.Debug|Any CPU.Build.0 = Debug|Any CPU
22-
{7A7D0B42-33B3-47F6-94F6-FAAA585A3B13}.Release|Any CPU.ActiveCfg = Release|Any CPU
23-
{7A7D0B42-33B3-47F6-94F6-FAAA585A3B13}.Release|Any CPU.Build.0 = Release|Any CPU
22+
{7A7D0B42-33B3-47F6-94F6-FAAA585A3B13}.Release|Any CPU.ActiveCfg = Debug|Any CPU
2423
EndGlobalSection
2524
GlobalSection(SolutionProperties) = preSolution
2625
HideSolutionNode = FALSE

Fail2Ban4Win/Config/Configuration.cs

Lines changed: 28 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,46 @@
11
using System;
22
using System.Collections.Generic;
3-
using System.Diagnostics.CodeAnalysis;
4-
using System.IO;
3+
using System.Linq;
54
using System.Net;
6-
using System.Text;
75
using System.Text.RegularExpressions;
8-
using Microsoft.Extensions.Configuration;
96
using NLog;
107

118
#nullable enable
129

1310
namespace Fail2Ban4Win.Config {
1411

15-
public class Configuration {
12+
public class Configuration: ICloneable {
1613

1714
public bool isDryRun { get; set; }
1815
public int maxAllowedFailures { get; set; }
1916
public TimeSpan failureWindow { get; set; }
2017
public TimeSpan banPeriod { get; set; }
2118
public byte? banSubnetBits { get; set; }
19+
public int? banRepeatedOffenseCoefficient { get; set; }
20+
public int? banRepeatedOffenseMax { get; set; }
2221
public LogLevel? logLevel { get; set; }
23-
public IEnumerable<IPNetwork>? neverBanSubnets { get; set; }
24-
public IEnumerable<EventLogSelector> eventLogSelectors { get; set; } = null!;
22+
public ICollection<IPNetwork>? neverBanSubnets { get; set; }
23+
public ICollection<EventLogSelector> eventLogSelectors { get; set; } = null!;
2524

2625
public override string ToString() =>
27-
$"{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}";
26+
$"{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}";
27+
28+
public object Clone() => new Configuration {
29+
isDryRun = isDryRun,
30+
maxAllowedFailures = maxAllowedFailures,
31+
failureWindow = failureWindow,
32+
banPeriod = banPeriod,
33+
banSubnetBits = banSubnetBits,
34+
banRepeatedOffenseCoefficient = banRepeatedOffenseCoefficient,
35+
banRepeatedOffenseMax = banRepeatedOffenseMax,
36+
logLevel = logLevel,
37+
neverBanSubnets = neverBanSubnets is not null ? new List<IPNetwork>(neverBanSubnets) : null,
38+
eventLogSelectors = eventLogSelectors.Select(selector => (EventLogSelector) selector.Clone()).ToList()
39+
};
2840

2941
}
3042

31-
public class EventLogSelector {
43+
public class EventLogSelector: ICloneable {
3244

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

42-
}
43-
44-
[ExcludeFromCodeCoverage]
45-
public class Test {
46-
47-
public static void Main() {
48-
string json = @"{
49-
""maxAllowedFailures"": 9,
50-
""failureWindow"": ""1.00:00:00"",
51-
""banPeriod"": ""1.00:00:00"",
52-
""banSubnetBits"": 24,
53-
""neverBanSubnets"": [
54-
""127.0.0.1/8"",
55-
""192.168.1.0/24"",
56-
""67.210.32.33"",
57-
""73.202.12.148""
58-
],
59-
""eventLogSelectors"": [
60-
{
61-
""logName"": ""Security"",
62-
""eventId"": 4625,
63-
""ipAddressEventDataName"": ""IpAddress""
64-
}, {
65-
""logName"": ""Application"",
66-
""source"": ""sshd"",
67-
""eventId"": 0,
68-
""ipAddressPattern"": ""^sshd: PID \\d+: Failed password for(?: invalid user)? \\S+ from (?<ipAddress>(?:\\d{1,3}\\.){3}\\d{1,3}) port \\d+ ssh\\d?$""
69-
}
70-
],
71-
""isDryRun"": true,
72-
""logLevel"": ""info""
73-
}";
74-
IPNetworkDeserializer.register();
75-
RegexDeserializer.register();
76-
77-
Stream jsonStream = new MemoryStream(Encoding.UTF8.GetBytes(json));
78-
IConfigurationRoot configuration = new ConfigurationBuilder()
79-
.AddJsonStream(jsonStream)
80-
.Build();
81-
var deserialized = configuration.Get<Configuration>();
82-
Console.WriteLine(deserialized);
83-
}
54+
public object Clone() => new EventLogSelector {
55+
ipAddressEventDataName = ipAddressEventDataName,
56+
eventId = eventId,
57+
ipAddressPattern = ipAddressPattern is not null ? new Regex(ipAddressPattern.ToString(), ipAddressPattern.Options, ipAddressPattern.MatchTimeout) : null,
58+
logName = logName,
59+
source = source
60+
};
8461

8562
}
8663

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
using System.Collections.Generic;
2+
using System.Linq;
3+
4+
#nullable enable
5+
6+
// ReSharper disable InconsistentNaming - library functions that are supposed to look like Linq methods, which user UpperCase naming.
7+
8+
namespace Fail2Ban4Win.Data {
9+
10+
public static class EnumerableExtensions {
11+
12+
/// <summary>Remove null values.</summary>
13+
/// <returns>Input enumerable with null values removed.</returns>
14+
public static IEnumerable<T> Compact<T>(this IEnumerable<T?> source) where T: class {
15+
return source.Where(item => item is not null)!;
16+
}
17+
18+
}
19+
20+
}

Fail2Ban4Win/Facades/EventLogWatcherFacade.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,15 @@ public interface EventLogWatcherFacade: IDisposable {
1717

1818
}
1919

20-
internal class EventLogWatcherFacadeImpl: EventLogWatcherFacade {
20+
public class EventLogWatcherFacadeImpl: EventLogWatcherFacade {
2121

2222
private readonly EventLogWatcher watcher;
2323

2424
public event EventHandler<EventRecordWrittenEventArgsFacade>? EventRecordWritten;
2525

26+
/// <summary>Determines whether this object starts delivering events to the event delegate.</summary>
27+
/// <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>
28+
/// <exception cref="EventLogNotFoundException">If the <c>EventLogQueryFacade.path</c> cannot be found while setting <c>Enabled</c> to <see langword="true" />.</exception>
2629
public bool Enabled {
2730
get => watcher.Enabled;
2831
set => watcher.Enabled = value;

Fail2Ban4Win/Fail2Ban4Win.csproj

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
<Compile Include="Config\Configuration.cs" />
7070
<Compile Include="Config\IPNetworkDeserializer.cs" />
7171
<Compile Include="Config\RegexDeserializer.cs" />
72+
<Compile Include="Data\EnumerableExtensions.cs" />
7273
<Compile Include="Facades\EventLogWatcherFacade.cs" />
7374
<Compile Include="Facades\FirewallFacade.cs" />
7475
<Compile Include="Injection\ConfigurationModule.cs" />
@@ -92,7 +93,10 @@
9293
<None Include="App.config" />
9394
<None Include="app.manifest" />
9495
<None Include="configuration.json">
95-
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
96+
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
97+
</None>
98+
<None Include="Install service.ps1">
99+
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
96100
</None>
97101
</ItemGroup>
98102
<ItemGroup>
@@ -104,9 +108,6 @@
104108
<Content Include="pifmgr_37.ico" />
105109
</ItemGroup>
106110
<ItemGroup>
107-
<PackageReference Include="FluentScheduler">
108-
<Version>5.5.1</Version>
109-
</PackageReference>
110111
<PackageReference Include="ILRepack.Lib.MSBuild.Task">
111112
<Version>2.0.18.2</Version>
112113
</PackageReference>

Fail2Ban4Win/Install service.ps1

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
$binaryPathName = Resolve-Path ".\Fail2Ban4Win.exe"
2+
3+
New-Service -Name "Fail2Ban4Win" -DisplayName "Fail2Ban4Win" -Description "After enough incorrect passwords from a remote client, block them using Windows Firewall." -BinaryPathName $binaryPathName.Path

Fail2Ban4Win/Services/BanManager.cs

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@
44
using System.Linq;
55
using System.Net;
66
using System.Net.Sockets;
7+
using System.Threading;
8+
using System.Threading.Tasks;
79
using WindowsFirewallHelper;
810
using WindowsFirewallHelper.Addresses;
911
using WindowsFirewallHelper.FirewallRules;
1012
using Fail2Ban4Win.Config;
1113
using Fail2Ban4Win.Data;
1214
using Fail2Ban4Win.Facades;
1315
using Fail2Ban4Win.Injection;
14-
using FluentScheduler;
1516
using NLog;
1617

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

36-
private readonly ConcurrentDictionary<IPNetwork, SubnetFailureHistory> failures = new();
37+
private readonly ConcurrentDictionary<IPNetwork, SubnetFailureHistory> failures = new();
38+
private readonly CancellationTokenSource cancellationTokenSource = new();
3739

3840
public BanManagerImpl(EventLogListener eventLogListener, Configuration configuration, FirewallFacade firewall) {
3941
this.eventLogListener = eventLogListener;
4042
this.configuration = configuration;
4143
this.firewall = firewall;
4244

43-
JobManager.Initialize(new Registry());
44-
4545
IEnumerable<FirewallWASRule> oldRules = firewall.Rules.Where(isBanRule()).ToList();
4646
if (oldRules.Any()) {
4747
LOGGER.Info("Deleting {0} existing {1} rules from Windows Firewall because they may be stale.", oldRules.Count(), GROUP_NAME);
@@ -104,11 +104,11 @@ private bool shouldBan(IPNetwork subnet, SubnetFailureHistory clientFailureHisto
104104
private void ban(IPNetwork subnet, SubnetFailureHistory clientFailureHistory) {
105105
clientFailureHistory.banCount++;
106106

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

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

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

122-
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);
123+
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);
123124

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

@@ -128,6 +129,20 @@ private void ban(IPNetwork subnet, SubnetFailureHistory clientFailureHistory) {
128129
}
129130
}
130131

132+
/// <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>
133+
/// <remarks>
134+
/// <para>Example using <c>banPeriod</c> = 1 day, <c>banRepeatedOffenseCoefficient</c> = 1, and <c>banRepeatedOffenseMax</c> = 4:</para>
135+
/// <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>
136+
/// <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>
137+
/// <returns>How long the offending subnet should be banned.</returns>
138+
public TimeSpan getUnbanDuration(int banCount) {
139+
banCount = Math.Max(1, banCount);
140+
return configuration.banPeriod + TimeSpan.FromMilliseconds(
141+
(Math.Min(banCount, configuration.banRepeatedOffenseMax ?? 4) - 1) *
142+
(configuration.banRepeatedOffenseCoefficient ?? 1) *
143+
configuration.banPeriod.TotalMilliseconds);
144+
}
145+
131146
private void unban(IPNetwork subnet) {
132147
IEnumerable<FirewallWASRule> rulesToRemove = firewall.Rules.Where(isBanRule(subnet));
133148
foreach (FirewallWASRule rule in rulesToRemove) {
@@ -147,8 +162,7 @@ private static Func<FirewallWASRule, bool> isBanRule(IPNetwork? subnet = null) {
147162

148163
public void Dispose() {
149164
eventLogListener.failure -= onFailure;
150-
JobManager.Stop();
151-
JobManager.RemoveAllJobs();
165+
cancellationTokenSource.Cancel();
152166
}
153167

154168
}

Fail2Ban4Win/Services/EventLogListener.cs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using System.Text;
88
using System.Text.RegularExpressions;
99
using Fail2Ban4Win.Config;
10+
using Fail2Ban4Win.Data;
1011
using Fail2Ban4Win.Facades;
1112
using NLog;
1213

@@ -44,11 +45,19 @@ public EventLogListenerImpl(Configuration configuration, Func<EventLogQueryFacad
4445
onEventRecordWritten(record, selector);
4546
}
4647
};
47-
watcher.Enabled = true;
48+
49+
try {
50+
watcher.Enabled = true;
51+
} catch (EventLogNotFoundException e) {
52+
LOGGER.Warn("Failed to listen for events in log {0}: {1}. Skipping this event selector.", selector.logName, e.Message);
53+
watcher.Dispose();
54+
return null;
55+
}
56+
4857
LOGGER.Info("Listening for Event Log records from the {0} log with event ID {1} and {2}.", selector.logName, selector.eventId,
4958
selector.source is not null ? "source " + selector.source : "any source");
5059
return watcher;
51-
}).ToList();
60+
}).Compact().ToList();
5261
}
5362

5463
private void onEventRecordWritten(EventLogRecordFacade record, EventLogSelector selector) {
@@ -71,8 +80,6 @@ private void onEventRecordWritten(EventLogRecordFacade record, EventLogSelector
7180
LOGGER.Info("Authentication failure detected from {0} (log={1}, event={2}, source={3}).", failingIpAddress, record.LogName, record.Id, record.ProviderName);
7281
failure?.Invoke(this, failingIpAddress);
7382
}
74-
} else {
75-
LOGGER.Trace("Could not find any IPv4 addresses in {0}", stringContainingIpAddress);
7683
}
7784
}
7885
}

Fail2Ban4Win/configuration.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
"failureWindow": "1.00:00:00",
55
"banPeriod": "1.00:00:00",
66
"banSubnetBits": 8,
7+
"banRepeatedOffenseCoefficient": 1,
8+
"banRepeatedOffenseMax": 4,
79
"logLevel": "Debug",
810
"neverBanSubnets": [
911
"67.210.32.33/32",
@@ -18,7 +20,12 @@
1820
"logName": "Application",
1921
"source": "sshd",
2022
"eventId": 0,
21-
"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?$"
23+
"ipAddressPattern": "^sshd: PID \\d+: Failed password for(?: invalid user)? .+ from (?<ipAddress>(?:\\d{1,3}\\.){3}\\d{1,3}) port \\d{1,5} ssh\\d?$"
24+
}, {
25+
"logName": "OpenSSH/Operational",
26+
"eventId": 4,
27+
"ipAddressEventDataName": "payload",
28+
"ipAddressPattern": "^Failed password for(?: invalid user)? .+ from (?<ipAddress>(?:\\d{1,3}\\.){3}\\d{1,3}) port \\d{1,5} ssh\\d?$"
2229
}
2330
]
2431
}

0 commit comments

Comments
 (0)