Add typed high-level ruleset and set API

This commit is contained in:
Vibe Myass
2026-03-16 03:51:49 +00:00
parent 458494221e
commit 1dfc6aebfd
12 changed files with 644 additions and 7 deletions

View File

@@ -1,14 +1,15 @@
# libnftables-dotnet
`libnftables-dotnet` is a command-centric .NET wrapper over system-installed `libnftables`, with low-level SWIG-generated bindings and a small managed API for common workflows.
`libnftables-dotnet` is a typed-first .NET wrapper over system-installed `libnftables`, with a high-level object model for common workflows and low-level SWIG-generated bindings for advanced control.
## Current Scope
This library is intentionally narrow.
- High-level managed API:
- `Validate`
- `Apply`
- typed `NftRuleset` / `NftTable` / `NftSet` authoring
- `ValidateRuleset`
- `ApplyRuleset`
- `Snapshot`
- `Restore`
- Low-level managed wrapper:
@@ -16,7 +17,7 @@ This library is intentionally narrow.
Non-goals for the current release:
- Typed .NET models for tables, chains, rules, sets, or maps
- Typed rule expressions, maps, and snapshot parsing back into object models
- Event monitoring or subscriptions
- Cross-platform support beyond Linux x64
@@ -67,13 +68,31 @@ using LibNftables;
INftablesClient client = new NftablesClient();
var validation = client.Validate(NftApplyRequest.FromText("add table inet my_table"));
var ruleset = new NftRuleset();
var table = new NftTable
{
Family = NftFamily.Inet,
Name = "filter",
};
var blocked = new NftSet
{
Name = "blocked_ipv4",
Type = NftSetType.Ipv4Address,
};
blocked.Elements.Add("10.0.0.1");
blocked.Elements.Add("10.0.0.2");
table.Sets.Add(blocked);
ruleset.Tables.Add(table);
var validation = client.ValidateRuleset(ruleset);
if (validation.IsValid)
{
client.Apply(NftApplyRequest.FromText("add table inet my_table"));
client.ApplyRuleset(ruleset);
}
```
Raw command text remains available through `NftApplyRequest` as a fallback for nft syntax not yet modeled by the typed API.
## Low-Level Example
```csharp
@@ -113,7 +132,7 @@ Some operations require elevated privileges or `CAP_NET_ADMIN`, especially when
### Validation failures
`Validate` returns `IsValid = false` for invalid nft syntax. `Apply` and `Restore` throw when the request shape is invalid or native parsing fails.
`ValidateRuleset` returns `IsValid = false` for invalid nft syntax after rendering the typed model. `ApplyRuleset` and `Restore` throw when the typed/request shape is invalid or native parsing fails.
## Bindings and Regeneration

View File

@@ -18,6 +18,19 @@ public interface INftablesClient
/// <exception cref="NftNativeLoadException">Thrown when required native runtime components cannot be loaded.</exception>
NftValidationResult Validate(NftApplyRequest request, System.Threading.CancellationToken ct = default);
/// <summary>
/// Validates a typed nftables ruleset by rendering it to nft command text in dry-run mode.
/// </summary>
/// <param name="ruleset">The typed ruleset to validate.</param>
/// <param name="ct">A cancellation token.</param>
/// <returns>
/// A validation result. Invalid nft syntax or schema errors return <see cref="NftValidationResult.IsValid"/> = <see langword="false"/>.
/// </returns>
/// <exception cref="NftValidationException">Thrown when the typed model itself is invalid before native validation begins.</exception>
/// <exception cref="NftUnsupportedException">Thrown when runtime/platform is unsupported.</exception>
/// <exception cref="NftNativeLoadException">Thrown when required native runtime components cannot be loaded.</exception>
NftValidationResult ValidateRuleset(NftRuleset ruleset, System.Threading.CancellationToken ct = default);
/// <summary>
/// Asynchronously validates a ruleset request using dry-run mode.
/// </summary>
@@ -29,6 +42,17 @@ public interface INftablesClient
/// <exception cref="NftNativeLoadException">Thrown when required native runtime components cannot be loaded.</exception>
System.Threading.Tasks.Task<NftValidationResult> ValidateAsync(NftApplyRequest request, System.Threading.CancellationToken ct = default);
/// <summary>
/// Asynchronously validates a typed nftables ruleset by rendering it to nft command text in dry-run mode.
/// </summary>
/// <param name="ruleset">The typed ruleset to validate.</param>
/// <param name="ct">A cancellation token.</param>
/// <returns>A task that resolves to a validation result.</returns>
/// <exception cref="NftValidationException">Thrown when the typed model itself is invalid before native validation begins.</exception>
/// <exception cref="NftUnsupportedException">Thrown when runtime/platform is unsupported.</exception>
/// <exception cref="NftNativeLoadException">Thrown when required native runtime components cannot be loaded.</exception>
System.Threading.Tasks.Task<NftValidationResult> ValidateRulesetAsync(NftRuleset ruleset, System.Threading.CancellationToken ct = default);
/// <summary>
/// Applies a ruleset request.
/// </summary>
@@ -41,6 +65,18 @@ public interface INftablesClient
/// <exception cref="NftException">Thrown for other native execution failures.</exception>
void Apply(NftApplyRequest request, System.Threading.CancellationToken ct = default);
/// <summary>
/// Applies a typed nftables ruleset by rendering it to nft command text.
/// </summary>
/// <param name="ruleset">The typed ruleset to apply.</param>
/// <param name="ct">A cancellation token.</param>
/// <exception cref="NftValidationException">Thrown when the typed model itself is invalid or the rendered ruleset cannot be parsed/validated.</exception>
/// <exception cref="NftPermissionException">Thrown when insufficient privileges are available for runtime operation.</exception>
/// <exception cref="NftUnsupportedException">Thrown when runtime/platform is unsupported.</exception>
/// <exception cref="NftNativeLoadException">Thrown when required native runtime components cannot be loaded.</exception>
/// <exception cref="NftException">Thrown for other native execution failures.</exception>
void ApplyRuleset(NftRuleset ruleset, System.Threading.CancellationToken ct = default);
/// <summary>
/// Asynchronously applies a ruleset request.
/// </summary>
@@ -54,6 +90,19 @@ public interface INftablesClient
/// <exception cref="NftException">Thrown for other native execution failures.</exception>
System.Threading.Tasks.Task ApplyAsync(NftApplyRequest request, System.Threading.CancellationToken ct = default);
/// <summary>
/// Asynchronously applies a typed nftables ruleset by rendering it to nft command text.
/// </summary>
/// <param name="ruleset">The typed ruleset to apply.</param>
/// <param name="ct">A cancellation token.</param>
/// <returns>A completed task when apply succeeds.</returns>
/// <exception cref="NftValidationException">Thrown when the typed model itself is invalid or the rendered ruleset cannot be parsed/validated.</exception>
/// <exception cref="NftPermissionException">Thrown when insufficient privileges are available for runtime operation.</exception>
/// <exception cref="NftUnsupportedException">Thrown when runtime/platform is unsupported.</exception>
/// <exception cref="NftNativeLoadException">Thrown when required native runtime components cannot be loaded.</exception>
/// <exception cref="NftException">Thrown for other native execution failures.</exception>
System.Threading.Tasks.Task ApplyRulesetAsync(NftRuleset ruleset, System.Threading.CancellationToken ct = default);
/// <summary>
/// Captures the current nftables ruleset from the system.
/// </summary>

View File

@@ -0,0 +1,37 @@
namespace LibNftables;
/// <summary>
/// Supported nftables address families for typed ruleset authoring.
/// </summary>
public enum NftFamily
{
/// <summary>
/// The <c>inet</c> family.
/// </summary>
Inet,
/// <summary>
/// The <c>ip</c> family.
/// </summary>
Ip,
/// <summary>
/// The <c>ip6</c> family.
/// </summary>
Ip6,
/// <summary>
/// The <c>arp</c> family.
/// </summary>
Arp,
/// <summary>
/// The <c>bridge</c> family.
/// </summary>
Bridge,
/// <summary>
/// The <c>netdev</c> family.
/// </summary>
Netdev,
}

View File

@@ -0,0 +1,12 @@
namespace LibNftables;
/// <summary>
/// Represents a typed nftables ruleset authored through the high-level API.
/// </summary>
public sealed class NftRuleset
{
/// <summary>
/// Gets the tables included in this ruleset.
/// </summary>
public IList<NftTable> Tables { get; } = new List<NftTable>();
}

View File

@@ -0,0 +1,198 @@
using System.Text;
namespace LibNftables;
internal static class NftRulesetRenderer
{
internal static string Render(NftRuleset ruleset)
{
if (ruleset is null)
{
throw new NftValidationException("Ruleset must not be null.", 0, null);
}
if (ruleset.Tables.Count == 0)
{
throw new NftValidationException("Ruleset must contain at least one table.", 0, null);
}
var tableNames = new HashSet<string>(StringComparer.Ordinal);
var builder = new StringBuilder();
foreach (NftTable table in ruleset.Tables)
{
ValidateTable(table);
string tableKey = $"{table.Family}:{table.Name}";
if (!tableNames.Add(tableKey))
{
throw new NftValidationException(
$"Ruleset contains duplicate table '{table.Name}' in family '{ToKeyword(table.Family)}'.",
0,
null);
}
builder.Append("add table ")
.Append(ToKeyword(table.Family))
.Append(' ')
.Append(table.Name)
.AppendLine();
var setNames = new HashSet<string>(StringComparer.Ordinal);
foreach (NftSet set in table.Sets)
{
ValidateSet(set, table);
if (!setNames.Add(set.Name!))
{
throw new NftValidationException(
$"Table '{table.Name}' contains duplicate set '{set.Name}'.",
0,
null);
}
builder.Append("add set ")
.Append(ToKeyword(table.Family))
.Append(' ')
.Append(table.Name)
.Append(' ')
.Append(set.Name)
.Append(" { type ")
.Append(GetTypeExpression(set))
.Append(';');
if (set.Elements.Count > 0)
{
builder.Append(" elements = { ");
for (int i = 0; i < set.Elements.Count; i++)
{
if (i > 0)
{
builder.Append(", ");
}
builder.Append(set.Elements[i]);
}
builder.Append(" };");
}
builder.Append(" }")
.AppendLine();
}
}
return builder.ToString();
}
private static void ValidateTable(NftTable table)
{
if (table is null)
{
throw new NftValidationException("Ruleset tables must not contain null entries.", 0, null);
}
ValidateIdentifier(table.Name, "Table name");
}
private static void ValidateSet(NftSet set, NftTable table)
{
if (set is null)
{
throw new NftValidationException(
$"Table '{table.Name}' contains a null set definition.",
0,
null);
}
ValidateIdentifier(set.Name, "Set name");
bool hasTypedType = set.Type.HasValue;
bool hasCustomType = !string.IsNullOrWhiteSpace(set.CustomTypeExpression);
if (hasTypedType == hasCustomType)
{
throw new NftValidationException(
$"Set '{set.Name}' must specify exactly one type source: Type or CustomTypeExpression.",
0,
null);
}
if (hasCustomType)
{
ValidateToken(set.CustomTypeExpression!, $"Set '{set.Name}' custom type");
}
for (int i = 0; i < set.Elements.Count; i++)
{
string? element = set.Elements[i];
if (string.IsNullOrWhiteSpace(element))
{
throw new NftValidationException(
$"Set '{set.Name}' contains an empty element at index {i}.",
0,
null);
}
ValidateToken(element, $"Set '{set.Name}' element");
}
}
private static void ValidateIdentifier(string? value, string fieldName)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new NftValidationException($"{fieldName} must not be null, empty, or whitespace.", 0, null);
}
foreach (char ch in value)
{
if (char.IsWhiteSpace(ch) || ch is '{' or '}' or ';' or '"' or '\'')
{
throw new NftValidationException(
$"{fieldName} contains unsupported character '{ch}'.",
0,
null);
}
}
}
private static void ValidateToken(string value, string fieldName)
{
foreach (char ch in value)
{
if (ch is '\r' or '\n' or ';')
{
throw new NftValidationException(
$"{fieldName} contains unsupported character '{ch}'.",
0,
null);
}
}
}
private static string GetTypeExpression(NftSet set)
=> set.Type switch
{
NftSetType.Ipv4Address => "ipv4_addr",
NftSetType.Ipv6Address => "ipv6_addr",
NftSetType.InetService => "inet_service",
NftSetType.EtherAddress => "ether_addr",
NftSetType.InterfaceName => "ifname",
NftSetType.Mark => "mark",
_ => set.CustomTypeExpression!,
};
private static string ToKeyword(NftFamily family)
=> family switch
{
NftFamily.Inet => "inet",
NftFamily.Ip => "ip",
NftFamily.Ip6 => "ip6",
NftFamily.Arp => "arp",
NftFamily.Bridge => "bridge",
NftFamily.Netdev => "netdev",
_ => throw new NftValidationException($"Unsupported nft family '{family}'.", 0, null),
};
}

37
src/LibNftables/NftSet.cs Normal file
View File

@@ -0,0 +1,37 @@
namespace LibNftables;
/// <summary>
/// Represents a typed nftables set definition backed by native C# collections.
/// </summary>
public sealed class NftSet
{
/// <summary>
/// Gets or sets the set name.
/// </summary>
public string? Name { get; set; }
/// <summary>
/// Gets or sets a common typed set element type.
/// </summary>
/// <remarks>
/// Set either <see cref="Type"/> or <see cref="CustomTypeExpression"/>.
/// </remarks>
public NftSetType? Type { get; set; }
/// <summary>
/// Gets or sets a custom nftables type expression for the set.
/// </summary>
/// <remarks>
/// This exists as a fallback for types not yet modeled by <see cref="NftSetType"/>.
/// Set either <see cref="Type"/> or <see cref="CustomTypeExpression"/>.
/// </remarks>
public string? CustomTypeExpression { get; set; }
/// <summary>
/// Gets the set elements to declare inline with the set definition.
/// </summary>
/// <remarks>
/// Values are rendered as nftables literals and should already be valid for the selected type.
/// </remarks>
public IList<string> Elements { get; } = new List<string>();
}

View File

@@ -0,0 +1,37 @@
namespace LibNftables;
/// <summary>
/// Common typed nftables set element types supported by the high-level API.
/// </summary>
public enum NftSetType
{
/// <summary>
/// IPv4 addresses (<c>ipv4_addr</c>).
/// </summary>
Ipv4Address,
/// <summary>
/// IPv6 addresses (<c>ipv6_addr</c>).
/// </summary>
Ipv6Address,
/// <summary>
/// Internet service/port values (<c>inet_service</c>).
/// </summary>
InetService,
/// <summary>
/// Ethernet/MAC addresses (<c>ether_addr</c>).
/// </summary>
EtherAddress,
/// <summary>
/// Interface names (<c>ifname</c>).
/// </summary>
InterfaceName,
/// <summary>
/// Packet marks (<c>mark</c>).
/// </summary>
Mark,
}

View File

@@ -0,0 +1,22 @@
namespace LibNftables;
/// <summary>
/// Represents a typed nftables table definition.
/// </summary>
public sealed class NftTable
{
/// <summary>
/// Gets or sets the nftables family for the table.
/// </summary>
public NftFamily Family { get; set; } = NftFamily.Inet;
/// <summary>
/// Gets or sets the table name.
/// </summary>
public string? Name { get; set; }
/// <summary>
/// Gets the sets declared in this table.
/// </summary>
public IList<NftSet> Sets { get; } = new List<NftSet>();
}

View File

@@ -56,10 +56,21 @@ public sealed class NftablesClient : INftablesClient
}
}
/// <inheritdoc />
public NftValidationResult ValidateRuleset(NftRuleset ruleset, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
return Validate(CreateTypedRequest(ruleset), ct);
}
/// <inheritdoc />
public Task<NftValidationResult> ValidateAsync(NftApplyRequest request, CancellationToken ct = default)
=> Task.FromResult(Validate(request, ct));
/// <inheritdoc />
public Task<NftValidationResult> ValidateRulesetAsync(NftRuleset ruleset, CancellationToken ct = default)
=> Task.FromResult(ValidateRuleset(ruleset, ct));
/// <inheritdoc />
public void Apply(NftApplyRequest request, CancellationToken ct = default)
{
@@ -68,6 +79,13 @@ public sealed class NftablesClient : INftablesClient
_ = Execute(request, forceDryRun: null, ct);
}
/// <inheritdoc />
public void ApplyRuleset(NftRuleset ruleset, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
Apply(CreateTypedRequest(ruleset), ct);
}
/// <inheritdoc />
public Task ApplyAsync(NftApplyRequest request, CancellationToken ct = default)
{
@@ -75,6 +93,13 @@ public sealed class NftablesClient : INftablesClient
return Task.CompletedTask;
}
/// <inheritdoc />
public Task ApplyRulesetAsync(NftRuleset ruleset, CancellationToken ct = default)
{
ApplyRuleset(ruleset, ct);
return Task.CompletedTask;
}
/// <inheritdoc />
public NftSnapshot Snapshot(CancellationToken ct = default)
{
@@ -210,5 +235,8 @@ public sealed class NftablesClient : INftablesClient
return false;
}
private static NftApplyRequest CreateTypedRequest(NftRuleset ruleset)
=> NftApplyRequest.FromText(NftRulesetRenderer.Render(ruleset), dryRun: false);
private sealed record CommandExecutionResult(string? Output, string? Error);
}

View File

@@ -0,0 +1,99 @@
namespace LibNftables.Tests;
public sealed class NftRulesetRendererTests
{
[Fact]
public void Render_WithTypedSet_RendersExpectedCommands()
{
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("10.0.0.1");
set.Elements.Add("10.0.0.2");
table.Sets.Add(set);
ruleset.Tables.Add(table);
string rendered = NftRulesetRenderer.Render(ruleset);
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,
rendered);
}
[Fact]
public void Render_WithCustomTypeExpression_UsesCustomKeyword()
{
var ruleset = new NftRuleset();
var table = new NftTable
{
Family = NftFamily.Ip,
Name = "custom",
};
var set = new NftSet
{
Name = "ports",
CustomTypeExpression = "inet_service",
};
set.Elements.Add("80");
table.Sets.Add(set);
ruleset.Tables.Add(table);
string rendered = NftRulesetRenderer.Render(ruleset);
Assert.Contains("type inet_service;", rendered, StringComparison.Ordinal);
}
[Fact]
public void Render_WithNoTables_ThrowsValidationException()
{
Assert.Throws<NftValidationException>(() => NftRulesetRenderer.Render(new NftRuleset()));
}
[Fact]
public void Render_WithConflictingSetTypeSources_ThrowsValidationException()
{
var ruleset = new NftRuleset();
var table = new NftTable
{
Name = "filter",
};
table.Sets.Add(new NftSet
{
Name = "conflict",
Type = NftSetType.Ipv4Address,
CustomTypeExpression = "ipv4_addr",
});
ruleset.Tables.Add(table);
Assert.Throws<NftValidationException>(() => NftRulesetRenderer.Render(ruleset));
}
[Fact]
public void Render_WithInvalidElement_ThrowsValidationException()
{
var ruleset = new NftRuleset();
var table = new NftTable
{
Name = "filter",
};
var set = new NftSet
{
Name = "bad",
Type = NftSetType.Mark,
};
set.Elements.Add("1; drop");
table.Sets.Add(set);
ruleset.Tables.Add(table);
Assert.Throws<NftValidationException>(() => NftRulesetRenderer.Render(ruleset));
}
}

View File

@@ -71,6 +71,36 @@ public sealed class NftablesClientIntegrationTests
}
}
[Fact]
public void ValidateRuleset_WithTypedSetDefinition_ReturnsValidResult()
{
if (!CanCreateClient())
{
return;
}
var client = new NftablesClient();
var ruleset = new NftRuleset();
var table = new NftTable
{
Family = NftFamily.Inet,
Name = "typed_validation",
};
var set = new NftSet
{
Name = "blocked_ipv4",
Type = NftSetType.Ipv4Address,
};
set.Elements.Add("10.0.0.1");
set.Elements.Add("10.0.0.2");
table.Sets.Add(set);
ruleset.Tables.Add(table);
NftValidationResult result = client.ValidateRuleset(ruleset);
Assert.True(result.IsValid);
}
private static bool CanCreateClient()
{
try

View File

@@ -64,6 +64,55 @@ public sealed class NftablesClientUnitTests
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,
context.LastCommandText);
}
[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,
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,
context.LastCommandText);
}
[Fact]
public void Apply_WithFileRequest_UsesFileExecutionPath()
{
@@ -191,6 +240,26 @@ public sealed class NftablesClientUnitTests
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("10.0.0.1");
set.Elements.Add("10.0.0.2");
table.Sets.Add(set);
ruleset.Tables.Add(table);
return ruleset;
}
private sealed class FakeContext : INftContextHandle
{
public bool DryRun { get; set; }