Skip to content

Commit 41a19eb

Browse files
committed
introduce IAccessRule to allow access to NodeBase to be configured using with IServiceCollection
1 parent 501aca1 commit 41a19eb

File tree

8 files changed

+240
-12
lines changed

8 files changed

+240
-12
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// SPDX-FileCopyrightText: 2024 smdn <smdn@smdn.jp>
2+
// SPDX-License-Identifier: MIT
3+
using System.Net;
4+
5+
namespace Smdn.Net.MuninNode;
6+
7+
public interface IAccessRule {
8+
bool IsAcceptable(IPEndPoint remoteEndPoint);
9+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// SPDX-FileCopyrightText: 2024 smdn <smdn@smdn.jp>
2+
// SPDX-License-Identifier: MIT
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Net;
6+
using System.Net.Sockets;
7+
8+
using Microsoft.Extensions.DependencyInjection;
9+
using Microsoft.Extensions.DependencyInjection.Extensions;
10+
11+
namespace Smdn.Net.MuninNode;
12+
13+
public static class IAccessRuleServiceCollectionExtensions {
14+
internal class AddressListAccessRule : IAccessRule {
15+
private readonly IReadOnlyList<IPAddress> addressListAllowFrom;
16+
17+
public AddressListAccessRule(IReadOnlyList<IPAddress> addressListAllowFrom)
18+
{
19+
this.addressListAllowFrom = addressListAllowFrom ?? throw new ArgumentNullException(nameof(addressListAllowFrom));
20+
}
21+
22+
public bool IsAcceptable(IPEndPoint remoteEndPoint)
23+
{
24+
if (remoteEndPoint is null)
25+
throw new ArgumentNullException(nameof(remoteEndPoint));
26+
27+
var remoteAddress = remoteEndPoint.Address;
28+
29+
foreach (var addressAllowFrom in addressListAllowFrom) {
30+
if (addressAllowFrom.AddressFamily == AddressFamily.InterNetwork) {
31+
// test for client acceptability by IPv4 address
32+
if (remoteAddress.IsIPv4MappedToIPv6)
33+
remoteAddress = remoteAddress.MapToIPv4();
34+
}
35+
36+
if (addressAllowFrom.Equals(remoteAddress))
37+
return true;
38+
}
39+
40+
return false;
41+
}
42+
}
43+
44+
/// <param name="services">The <see cref="IServiceCollection"/> to add services to.</param>
45+
/// <param name="addressListAllowFrom">The <see cref="IReadOnlyList{IPAddress}"/> indicates the read-only list of addresses allowed to access <see cref="NodeBase"/>.</param>
46+
public static IServiceCollection AddMuninNodeAccessRule(
47+
this IServiceCollection services,
48+
IReadOnlyList<IPAddress> addressListAllowFrom
49+
)
50+
=> AddMuninNodeAccessRule(
51+
services: services ?? throw new ArgumentNullException(nameof(services)),
52+
accessRule: new AddressListAccessRule(
53+
addressListAllowFrom: addressListAllowFrom ?? throw new ArgumentNullException(nameof(addressListAllowFrom))
54+
)
55+
);
56+
57+
/// <param name="services">The <see cref="IServiceCollection"/> to add services to.</param>
58+
/// <param name="accessRule">The <see cref="IAccessRule"/> which defines access rules to <see cref="NodeBase"/>.</param>
59+
public static IServiceCollection AddMuninNodeAccessRule(
60+
this IServiceCollection services,
61+
IAccessRule accessRule
62+
)
63+
{
64+
#pragma warning disable CA1510
65+
if (services is null)
66+
throw new ArgumentNullException(nameof(services));
67+
if (accessRule is null)
68+
throw new ArgumentNullException(nameof(accessRule));
69+
#pragma warning restore CA1510
70+
71+
services.TryAdd(
72+
ServiceDescriptor.Singleton(typeof(IAccessRule), accessRule)
73+
);
74+
75+
return services;
76+
}
77+
}

src/Smdn.Net.MuninNode/Smdn.Net.MuninNode/LocalNode.Create.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ public ConcreteLocalNode(
3737
IServiceProvider? serviceProvider = null
3838
)
3939
: base(
40+
accessRule: serviceProvider?.GetService<IAccessRule>(),
4041
logger: serviceProvider?.GetService<ILoggerFactory>()?.CreateLogger<LocalNode>()
4142
)
4243
{

src/Smdn.Net.MuninNode/Smdn.Net.MuninNode/LocalNode.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,18 @@ public abstract partial class LocalNode : NodeBase {
1717
/// <summary>
1818
/// Initializes a new instance of the <see cref="LocalNode"/> class.
1919
/// </summary>
20+
/// <param name="accessRule">
21+
/// The <see cref="IAccessRule"/> to determine whether to accept or reject a remote host that connects to <see cref="LocalNode"/>.
22+
/// </param>
2023
/// <param name="logger">
2124
/// The <see cref="ILogger"/> to report the situation.
2225
/// </param>
2326
protected LocalNode(
27+
IAccessRule? accessRule,
2428
ILogger? logger = null
2529
)
2630
: base(
31+
accessRule: accessRule,
2732
logger: logger
2833
)
2934
{
@@ -77,9 +82,4 @@ protected override Socket CreateServerSocket()
7782
throw;
7883
}
7984
}
80-
81-
protected override bool IsClientAcceptable(IPEndPoint remoteEndPoint)
82-
=> IPAddress.IsLoopback(
83-
(remoteEndPoint ?? throw new ArgumentNullException(nameof(remoteEndPoint))).Address
84-
);
8585
}

src/Smdn.Net.MuninNode/Smdn.Net.MuninNode/NodeBase.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,18 @@ public abstract class NodeBase : IDisposable, IAsyncDisposable {
4040

4141
protected ILogger? Logger { get; }
4242

43+
private readonly IAccessRule? accessRule;
44+
4345
private Socket? server;
4446

4547
public EndPoint LocalEndPoint => server?.LocalEndPoint ?? throw new InvalidOperationException("not yet bound or already disposed");
4648

4749
protected NodeBase(
50+
IAccessRule? accessRule,
4851
ILogger? logger
4952
)
5053
{
54+
this.accessRule = accessRule;
5155
Logger = logger;
5256
}
5357

@@ -131,8 +135,6 @@ public void Start()
131135
Logger?.LogInformation("started (end point: {LocalEndPoint})", server.LocalEndPoint);
132136
}
133137

134-
protected abstract bool IsClientAcceptable(IPEndPoint remoteEndPoint);
135-
136138
/// <summary>
137139
/// Starts accepting multiple sessions.
138140
/// The <see cref="ValueTask" /> this method returns will never complete unless the cancellation requested by the <paramref name="cancellationToken" />.
@@ -211,7 +213,7 @@ public async ValueTask AcceptSingleSessionAsync(
211213
return;
212214
}
213215

214-
if (!IsClientAcceptable(remoteEndPoint)) {
216+
if (accessRule is not null && !accessRule.IsAcceptable(remoteEndPoint)) {
215217
Logger?.LogWarning("access refused: {RemoteEndPoint}", remoteEndPoint);
216218
return;
217219
}

tests/Smdn.Net.MuninNode/Smdn.Net.MuninNode.Tests.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,8 @@ SPDX-License-Identifier: MIT
1212
<TargetFrameworks Condition=" '$(EnableTargetFrameworkDotNet60)' == 'true' ">net6.0;$(TargetFrameworks)</TargetFrameworks>
1313
<TargetFrameworks Condition=" '$(EnableTargetFrameworkNetFx)' == 'true' ">$(TargetFrameworks)</TargetFrameworks>
1414
</PropertyGroup>
15+
16+
<ItemGroup>
17+
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.*" />
18+
</ItemGroup>
1519
</Project>
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// SPDX-FileCopyrightText: 2024 smdn <smdn@smdn.jp>
2+
// SPDX-License-Identifier: MIT
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Net;
6+
7+
using Microsoft.Extensions.DependencyInjection;
8+
9+
using NUnit.Framework;
10+
11+
namespace Smdn.Net.MuninNode;
12+
13+
[TestFixture]
14+
public class IAccessRuleServiceCollectionExtensionsTests {
15+
[Test]
16+
public void AddMuninNodeAccessRule_TryAddMultiple()
17+
{
18+
var services = new ServiceCollection();
19+
20+
services.AddMuninNodeAccessRule([IPAddress.Any]);
21+
22+
var firstAccessRule = services.BuildServiceProvider().GetRequiredService<IAccessRule>();
23+
24+
Assert.That(firstAccessRule, Is.Not.Null, nameof(firstAccessRule));
25+
26+
services.AddMuninNodeAccessRule([IPAddress.Any]);
27+
28+
var secondAccessRule = services.BuildServiceProvider().GetRequiredService<IAccessRule>();
29+
30+
Assert.That(secondAccessRule, Is.SameAs(firstAccessRule), nameof(secondAccessRule));
31+
}
32+
33+
[Test]
34+
public void AddMuninNodeAccessRule_IReadOnlyListOfIPAddress_ArgumentNull()
35+
{
36+
var services = new ServiceCollection();
37+
38+
Assert.Throws<ArgumentNullException>(
39+
() => services.AddMuninNodeAccessRule((IReadOnlyList<IPAddress>)null!)
40+
);
41+
}
42+
43+
[Test]
44+
public void AddMuninNodeAccessRule_IAccessRule_ArgumentNull()
45+
{
46+
var services = new ServiceCollection();
47+
48+
Assert.Throws<ArgumentNullException>(
49+
() => services.AddMuninNodeAccessRule((IAccessRule)null!)
50+
);
51+
}
52+
}

tests/Smdn.Net.MuninNode/Smdn.Net.MuninNode/NodeBase.cs

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,11 @@ public ReadOnlyCollectionPluginProvider(IReadOnlyCollection<IPlugin> plugins)
3131
public override string HostName => "test.munin-node.localhost";
3232

3333
public TestLocalNode(
34+
IAccessRule? accessRule,
3435
IReadOnlyList<IPlugin> plugins
3536
)
3637
: base(
38+
accessRule: accessRule,
3739
logger: null
3840
)
3941
{
@@ -53,10 +55,16 @@ protected override EndPoint GetLocalEndPointToBind()
5355
}
5456

5557
private static NodeBase CreateNode()
56-
=> CreateNode(plugins: Array.Empty<IPlugin>());
58+
=> CreateNode(accessRule: null, plugins: Array.Empty<IPlugin>());
59+
60+
private static NodeBase CreateNode(IAccessRule? accessRule)
61+
=> CreateNode(accessRule: accessRule, plugins: Array.Empty<IPlugin>());
5762

5863
private static NodeBase CreateNode(IReadOnlyList<IPlugin> plugins)
59-
=> new TestLocalNode(plugins);
64+
=> CreateNode(accessRule: null, plugins: plugins);
65+
66+
private static NodeBase CreateNode(IAccessRule? accessRule, IReadOnlyList<IPlugin> plugins)
67+
=> new TestLocalNode(accessRule, plugins);
6068

6169
private static TcpClient CreateClient(
6270
IPEndPoint endPoint,
@@ -79,14 +87,29 @@ out StreamReader reader
7987
private static Task StartSession(
8088
Func<NodeBase, TcpClient, StreamWriter, StreamReader, CancellationToken, Task> action
8189
)
82-
=> StartSession(plugins: Array.Empty<IPlugin>(), action: action);
90+
=> StartSession(
91+
accessRule: null,
92+
plugins: Array.Empty<IPlugin>(),
93+
action: action
94+
);
95+
96+
private static Task StartSession(
97+
IReadOnlyList<IPlugin> plugins,
98+
Func<NodeBase, TcpClient, StreamWriter, StreamReader, CancellationToken, Task> action
99+
)
100+
=> StartSession(
101+
accessRule: null,
102+
plugins: plugins,
103+
action: action
104+
);
83105

84106
private static async Task StartSession(
107+
IAccessRule? accessRule,
85108
IReadOnlyList<IPlugin> plugins,
86109
Func<NodeBase, TcpClient, StreamWriter, StreamReader, CancellationToken, Task> action
87110
)
88111
{
89-
await using var node = CreateNode(plugins);
112+
await using var node = CreateNode(accessRule, plugins);
90113

91114
node.Start();
92115

@@ -204,6 +227,66 @@ public async Task AcceptSingleSessionAsync_ClientDisconnected_WhileAwaitingComma
204227
Assert.DoesNotThrowAsync(async () => await taskAccept);
205228
}
206229

230+
private sealed class AcceptAllAccessRule : IAccessRule {
231+
public bool IsAcceptable(IPEndPoint remoteEndPoint) => true;
232+
}
233+
234+
[Test]
235+
public async Task AcceptSingleSessionAsync_IAccessRule_AccessGranted()
236+
{
237+
await StartSession(
238+
accessRule: new AcceptAllAccessRule(),
239+
plugins: Array.Empty<IPlugin>(),
240+
async static (node, client, writer, reader, cancellationToken
241+
) => {
242+
await writer.WriteLineAsync("command", cancellationToken);
243+
await writer.FlushAsync(cancellationToken);
244+
245+
Assert.That(
246+
await reader.ReadLineAsync(cancellationToken),
247+
Is.Not.Null,
248+
"line #1"
249+
);
250+
251+
var connected = !(
252+
client.Client.Poll(1 /*microsecs*/, SelectMode.SelectRead) &&
253+
client.Client.Available == 0
254+
);
255+
256+
Assert.That(connected, Is.True);
257+
});
258+
}
259+
260+
private sealed class RefuseAllAccessRule : IAccessRule {
261+
public bool IsAcceptable(IPEndPoint remoteEndPoint) => false;
262+
}
263+
264+
[Test]
265+
public async Task AcceptSingleSessionAsync_IAccessRule_AccessRefused()
266+
{
267+
await StartSession(
268+
accessRule: new RefuseAllAccessRule(),
269+
plugins: Array.Empty<IPlugin>(),
270+
async static (node, client, writer, reader, cancellationToken
271+
) => {
272+
await writer.WriteLineAsync(".", cancellationToken);
273+
await writer.FlushAsync(cancellationToken);
274+
275+
Assert.That(
276+
await reader.ReadLineAsync(cancellationToken),
277+
Is.Null,
278+
"line #1"
279+
);
280+
281+
var connected = !(
282+
client.Client.Poll(1 /*microsecs*/, SelectMode.SelectRead) &&
283+
client.Client.Available == 0
284+
);
285+
286+
Assert.That(connected, Is.False);
287+
});
288+
}
289+
207290
private class PseudoPluginWithSessionCallback : IPlugin, INodeSessionCallback {
208291
public string Name => throw new NotImplementedException();
209292
public PluginGraphAttributes GraphAttributes => throw new NotImplementedException();

0 commit comments

Comments
 (0)