437 lines
15 KiB
C#
437 lines
15 KiB
C#
namespace LibNftables.Tests;
|
|
|
|
public sealed class NftablesClientUnitTests
|
|
{
|
|
[Fact]
|
|
public void Apply_WithBothTextAndFile_ThrowsValidationException()
|
|
{
|
|
var client = CreateClient();
|
|
var request = new NftApplyRequest
|
|
{
|
|
RulesetText = "flush ruleset",
|
|
RulesetFilePath = "/tmp/does-not-matter.nft",
|
|
};
|
|
|
|
Assert.Throws<NftValidationException>(() => client.Apply(request));
|
|
}
|
|
|
|
[Fact]
|
|
public void Apply_WithMissingFile_ThrowsValidationException()
|
|
{
|
|
var client = CreateClient();
|
|
var request = NftApplyRequest.FromFile("/tmp/does-not-exist.nft");
|
|
|
|
Assert.Throws<NftValidationException>(() => client.Apply(request));
|
|
}
|
|
|
|
[Fact]
|
|
public void Apply_NullRequest_ThrowsValidationException()
|
|
{
|
|
var client = CreateClient();
|
|
|
|
Assert.Throws<NftValidationException>(() => client.Apply(null!));
|
|
}
|
|
|
|
[Fact]
|
|
public void Validate_WhenCommandThrowsValidation_ReturnsInvalidResult()
|
|
{
|
|
var context = new FakeContext
|
|
{
|
|
CommandException = new NftValidationException("invalid ruleset", 7, "syntax error"),
|
|
};
|
|
var client = CreateClient(() => context);
|
|
|
|
NftValidationResult result = client.Validate(NftApplyRequest.FromText("invalid"));
|
|
|
|
Assert.False(result.IsValid);
|
|
Assert.Equal("syntax error", result.Diagnostics);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateAsync_UsesSynchronousValidationFlow()
|
|
{
|
|
var context = new FakeContext
|
|
{
|
|
OutputBuffer = "ok",
|
|
ErrorBuffer = string.Empty,
|
|
};
|
|
var client = CreateClient(() => context);
|
|
|
|
NftValidationResult result = await client.ValidateAsync(NftApplyRequest.FromText("flush ruleset", dryRun: false));
|
|
|
|
Assert.True(result.IsValid);
|
|
Assert.True(context.DryRun);
|
|
Assert.Equal("ok", result.Output);
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateRuleset_RendersTypedRulesetAndUsesDryRun()
|
|
{
|
|
var context = new FakeContext
|
|
{
|
|
OutputBuffer = "ok",
|
|
ErrorBuffer = string.Empty,
|
|
};
|
|
var client = CreateClient(() => context);
|
|
|
|
NftValidationResult result = client.ValidateRuleset(CreateTypedRuleset());
|
|
|
|
Assert.True(result.IsValid);
|
|
Assert.True(context.DryRun);
|
|
Assert.Equal(
|
|
"add table inet filter" + Environment.NewLine +
|
|
"add set inet filter blocked_ipv4 { type ipv4_addr; elements = { 10.0.0.1, 10.0.0.2 }; }" + Environment.NewLine +
|
|
"add map inet filter service_policy { type inet_service : verdict; elements = { 80 : accept, 443 : drop }; }" + Environment.NewLine +
|
|
"add chain inet filter input { type filter hook input priority 0; policy drop; }" + Environment.NewLine +
|
|
"add rule inet filter input iifname \"eth0\" ip saddr @blocked_ipv4 tcp dport 22 accept" + Environment.NewLine,
|
|
context.LastCommandText);
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateAndRenderRuleset_ReturnsRenderedTextAndValidation()
|
|
{
|
|
var context = new FakeContext
|
|
{
|
|
OutputBuffer = "ok",
|
|
ErrorBuffer = string.Empty,
|
|
};
|
|
var client = CreateClient(() => context);
|
|
|
|
NftRenderedValidationResult result = client.ValidateAndRenderRuleset(CreateTypedRuleset());
|
|
|
|
Assert.True(result.ValidationResult.IsValid);
|
|
Assert.Equal(result.RenderedRulesetText, context.LastCommandText);
|
|
Assert.Equal(
|
|
"add table inet filter" + Environment.NewLine +
|
|
"add set inet filter blocked_ipv4 { type ipv4_addr; elements = { 10.0.0.1, 10.0.0.2 }; }" + Environment.NewLine +
|
|
"add map inet filter service_policy { type inet_service : verdict; elements = { 80 : accept, 443 : drop }; }" + Environment.NewLine +
|
|
"add chain inet filter input { type filter hook input priority 0; policy drop; }" + Environment.NewLine +
|
|
"add rule inet filter input iifname \"eth0\" ip saddr @blocked_ipv4 tcp dport 22 accept" + Environment.NewLine,
|
|
result.RenderedRulesetText);
|
|
}
|
|
|
|
[Fact]
|
|
public void ApplyRuleset_RendersTypedRulesetAndExecutesCommand()
|
|
{
|
|
var context = new FakeContext();
|
|
var client = CreateClient(() => context);
|
|
|
|
client.ApplyRuleset(CreateTypedRuleset());
|
|
|
|
Assert.False(context.DryRun);
|
|
Assert.Equal(
|
|
"add table inet filter" + Environment.NewLine +
|
|
"add set inet filter blocked_ipv4 { type ipv4_addr; elements = { 10.0.0.1, 10.0.0.2 }; }" + Environment.NewLine +
|
|
"add map inet filter service_policy { type inet_service : verdict; elements = { 80 : accept, 443 : drop }; }" + Environment.NewLine +
|
|
"add chain inet filter input { type filter hook input priority 0; policy drop; }" + Environment.NewLine +
|
|
"add rule inet filter input iifname \"eth0\" ip saddr @blocked_ipv4 tcp dport 22 accept" + Environment.NewLine,
|
|
context.LastCommandText);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApplyRulesetAsync_UsesTypedRulesetText()
|
|
{
|
|
var context = new FakeContext();
|
|
var client = CreateClient(() => context);
|
|
|
|
await client.ApplyRulesetAsync(CreateTypedRuleset());
|
|
|
|
Assert.Equal(
|
|
"add table inet filter" + Environment.NewLine +
|
|
"add set inet filter blocked_ipv4 { type ipv4_addr; elements = { 10.0.0.1, 10.0.0.2 }; }" + Environment.NewLine +
|
|
"add map inet filter service_policy { type inet_service : verdict; elements = { 80 : accept, 443 : drop }; }" + Environment.NewLine +
|
|
"add chain inet filter input { type filter hook input priority 0; policy drop; }" + Environment.NewLine +
|
|
"add rule inet filter input iifname \"eth0\" ip saddr @blocked_ipv4 tcp dport 22 accept" + Environment.NewLine,
|
|
context.LastCommandText);
|
|
}
|
|
|
|
[Fact]
|
|
public void Map_Add_DuplicateKey_ThrowsArgumentException()
|
|
{
|
|
var map = new NftMap
|
|
{
|
|
Name = "service_policy",
|
|
KeyType = NftMapType.InetService,
|
|
ValueType = NftMapType.Verdict,
|
|
};
|
|
|
|
map.Add(NftValue.Port(80), NftValue.Verdict(NftVerdict.Accept));
|
|
|
|
Assert.Throws<ArgumentException>(() => map.Add(NftValue.Port(80), NftValue.Verdict(NftVerdict.Drop)));
|
|
}
|
|
|
|
[Fact]
|
|
public void Map_Set_ReplacesValueWithoutChangingInsertionOrder()
|
|
{
|
|
var map = new NftMap
|
|
{
|
|
Name = "service_policy",
|
|
KeyType = NftMapType.InetService,
|
|
ValueType = NftMapType.Verdict,
|
|
};
|
|
|
|
map.Add(NftValue.Port(80), NftValue.Verdict(NftVerdict.Accept));
|
|
map.Add(NftValue.Port(443), NftValue.Verdict(NftVerdict.Drop));
|
|
map.Set(NftValue.Port(80), NftValue.Verdict(NftVerdict.Reject));
|
|
|
|
Assert.Equal(2, map.Count);
|
|
Assert.Equal("80", map.Entries[0].Key!.RenderedText);
|
|
Assert.Equal("reject", map.Entries[0].Value!.RenderedText);
|
|
Assert.Equal("443", map.Entries[1].Key!.RenderedText);
|
|
}
|
|
|
|
[Fact]
|
|
public void RenderRuleset_ReturnsTypedRulesetTextWithoutExecuting()
|
|
{
|
|
var context = new FakeContext();
|
|
var client = CreateClient(() => context);
|
|
|
|
string rendered = client.RenderRuleset(CreateTypedRuleset());
|
|
|
|
Assert.Equal(
|
|
"add table inet filter" + Environment.NewLine +
|
|
"add set inet filter blocked_ipv4 { type ipv4_addr; elements = { 10.0.0.1, 10.0.0.2 }; }" + Environment.NewLine +
|
|
"add map inet filter service_policy { type inet_service : verdict; elements = { 80 : accept, 443 : drop }; }" + Environment.NewLine +
|
|
"add chain inet filter input { type filter hook input priority 0; policy drop; }" + Environment.NewLine +
|
|
"add rule inet filter input iifname \"eth0\" ip saddr @blocked_ipv4 tcp dport 22 accept" + Environment.NewLine,
|
|
rendered);
|
|
Assert.Null(context.LastCommandText);
|
|
}
|
|
|
|
[Fact]
|
|
public void Apply_WithFileRequest_UsesFileExecutionPath()
|
|
{
|
|
string path = Path.GetTempFileName();
|
|
|
|
try
|
|
{
|
|
var context = new FakeContext();
|
|
var client = CreateClient(() => context);
|
|
|
|
client.Apply(NftApplyRequest.FromFile(path));
|
|
|
|
Assert.Equal(path, context.LastFilePath);
|
|
Assert.Null(context.LastCommandText);
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(path);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Apply_PropagatesOptionsAndRequestFlags()
|
|
{
|
|
var context = new FakeContext();
|
|
var options = new NftablesClientOptions
|
|
{
|
|
DefaultInputFlags = NftInputFlags.NoDns,
|
|
DefaultOutputFlags = NftOutputFlags.Json,
|
|
DefaultDebugFlags = NftDebugLevel.Parser,
|
|
DefaultOptimizeFlags = NftOptimizeFlags.Enabled,
|
|
};
|
|
var request = NftApplyRequest.FromText("flush ruleset", dryRun: true);
|
|
request.InputFlags = NftInputFlags.Json;
|
|
request.OutputFlags = NftOutputFlags.Echo;
|
|
request.DebugFlags = NftDebugLevel.Scanner;
|
|
|
|
var client = CreateClient(() => context, options);
|
|
|
|
client.Apply(request);
|
|
|
|
Assert.True(context.DryRun);
|
|
Assert.Equal(NftInputFlags.NoDns | NftInputFlags.Json, context.InputFlags);
|
|
Assert.Equal(NftOutputFlags.Json | NftOutputFlags.Echo, context.OutputFlags);
|
|
Assert.Equal(NftDebugLevel.Parser | NftDebugLevel.Scanner, context.DebugFlags);
|
|
Assert.Equal(NftOptimizeFlags.Enabled, context.OptimizeFlags);
|
|
Assert.True(context.BufferOutputCalled);
|
|
Assert.True(context.BufferErrorCalled);
|
|
}
|
|
|
|
[Fact]
|
|
public void Snapshot_WithOutput_ReturnsSnapshot()
|
|
{
|
|
var context = new FakeContext
|
|
{
|
|
OutputBuffer = "table inet filter { }",
|
|
ErrorBuffer = string.Empty,
|
|
};
|
|
var client = CreateClient(() => context);
|
|
|
|
NftSnapshot snapshot = client.Snapshot();
|
|
|
|
Assert.Equal("table inet filter { }", snapshot.RulesetText);
|
|
Assert.Equal("list ruleset", context.LastCommandText);
|
|
}
|
|
|
|
[Fact]
|
|
public void Snapshot_WithPermissionError_ThrowsPermissionException()
|
|
{
|
|
var context = new FakeContext
|
|
{
|
|
OutputBuffer = string.Empty,
|
|
ErrorBuffer = "Operation not permitted",
|
|
};
|
|
var client = CreateClient(() => context);
|
|
|
|
Assert.Throws<NftPermissionException>(() => client.Snapshot());
|
|
}
|
|
|
|
[Fact]
|
|
public void Snapshot_WithEmptyOutputAndNoError_ReturnsFlushRulesetFallback()
|
|
{
|
|
var context = new FakeContext
|
|
{
|
|
OutputBuffer = string.Empty,
|
|
ErrorBuffer = string.Empty,
|
|
};
|
|
var client = CreateClient(() => context);
|
|
|
|
NftSnapshot snapshot = client.Snapshot();
|
|
|
|
Assert.Equal("flush ruleset", snapshot.RulesetText);
|
|
}
|
|
|
|
[Fact]
|
|
public void Restore_WithNullSnapshot_ThrowsValidationException()
|
|
{
|
|
var client = CreateClient();
|
|
|
|
Assert.Throws<NftValidationException>(() => client.Restore(null!));
|
|
}
|
|
|
|
[Fact]
|
|
public void Restore_WithEmptyRuleset_ThrowsValidationException()
|
|
{
|
|
var client = CreateClient();
|
|
|
|
Assert.Throws<NftValidationException>(() => client.Restore(new NftSnapshot(" ", DateTimeOffset.UtcNow)));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RestoreAsync_UsesSnapshotRulesetText()
|
|
{
|
|
var context = new FakeContext();
|
|
var client = CreateClient(() => context);
|
|
var snapshot = new NftSnapshot("flush ruleset", DateTimeOffset.UtcNow);
|
|
|
|
await client.RestoreAsync(snapshot);
|
|
|
|
Assert.Equal("flush ruleset", context.LastCommandText);
|
|
}
|
|
|
|
private static NftablesClient CreateClient(
|
|
Func<INftContextHandle>? contextFactory = null,
|
|
NftablesClientOptions? options = null)
|
|
=> new(options, contextFactory ?? (() => new FakeContext()), skipRuntimeGuard: true);
|
|
|
|
private static NftRuleset CreateTypedRuleset()
|
|
{
|
|
var ruleset = new NftRuleset();
|
|
var table = new NftTable
|
|
{
|
|
Family = NftFamily.Inet,
|
|
Name = "filter",
|
|
};
|
|
|
|
var set = new NftSet
|
|
{
|
|
Name = "blocked_ipv4",
|
|
Type = NftSetType.Ipv4Address,
|
|
};
|
|
set.Elements.Add(NftValue.Address(System.Net.IPAddress.Parse("10.0.0.1")));
|
|
set.Elements.Add(NftValue.Address(System.Net.IPAddress.Parse("10.0.0.2")));
|
|
table.Sets.Add(set);
|
|
|
|
var map = new NftMap
|
|
{
|
|
Name = "service_policy",
|
|
KeyType = NftMapType.InetService,
|
|
ValueType = NftMapType.Verdict,
|
|
};
|
|
map.Add(NftValue.Port(80), NftValue.Verdict(NftVerdict.Accept));
|
|
map.Add(NftValue.Port(443), NftValue.Verdict(NftVerdict.Drop));
|
|
table.Maps.Add(map);
|
|
|
|
var chain = new NftChain
|
|
{
|
|
Name = "input",
|
|
Type = NftChainType.Filter,
|
|
Hook = NftHook.Input,
|
|
Priority = 0,
|
|
Policy = NftChainPolicy.Drop,
|
|
};
|
|
chain.Rules.Add(new NftRule
|
|
{
|
|
InputInterface = NftValue.Interface("eth0"),
|
|
SourceAddressSetName = "blocked_ipv4",
|
|
TransportProtocol = NftTransportProtocol.Tcp,
|
|
DestinationPort = NftValue.Port(22),
|
|
Verdict = NftVerdict.Accept,
|
|
});
|
|
table.Chains.Add(chain);
|
|
|
|
ruleset.Tables.Add(table);
|
|
return ruleset;
|
|
}
|
|
|
|
private sealed class FakeContext : INftContextHandle
|
|
{
|
|
public bool DryRun { get; set; }
|
|
|
|
public NftOptimizeFlags OptimizeFlags { get; set; }
|
|
|
|
public NftInputFlags InputFlags { get; set; }
|
|
|
|
public NftOutputFlags OutputFlags { get; set; }
|
|
|
|
public NftDebugLevel DebugFlags { get; set; }
|
|
|
|
public bool BufferOutputCalled { get; private set; }
|
|
|
|
public bool BufferErrorCalled { get; private set; }
|
|
|
|
public string? OutputBuffer { get; set; }
|
|
|
|
public string? ErrorBuffer { get; set; }
|
|
|
|
public string? LastCommandText { get; private set; }
|
|
|
|
public string? LastFilePath { get; private set; }
|
|
|
|
public Exception? CommandException { get; set; }
|
|
|
|
public void BufferOutput() => BufferOutputCalled = true;
|
|
|
|
public void BufferError() => BufferErrorCalled = true;
|
|
|
|
public string? GetOutputBuffer() => OutputBuffer;
|
|
|
|
public string? GetErrorBuffer() => ErrorBuffer;
|
|
|
|
public void RunCommand(string commandText)
|
|
{
|
|
LastCommandText = commandText;
|
|
ThrowIfNeeded();
|
|
}
|
|
|
|
public void RunCommandFromFile(string path)
|
|
{
|
|
LastFilePath = path;
|
|
ThrowIfNeeded();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
|
|
private void ThrowIfNeeded()
|
|
{
|
|
if (CommandException is not null)
|
|
{
|
|
throw CommandException;
|
|
}
|
|
}
|
|
}
|
|
}
|