diff --git a/README.md b/README.md index 01430a4..1edc361 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ This library is intentionally narrow. - High-level managed API: - typed `NftRuleset` / `NftTable` / `NftSet` / `NftMap` / `NftChain` / `NftRule` authoring - `RenderRuleset` + - `ValidateAndRenderRuleset` - `ValidateRuleset` - `ApplyRuleset` - `Snapshot` @@ -114,6 +115,55 @@ if (validation.IsValid) Raw command text remains available through `NftApplyRequest` as a fallback for nft syntax not yet modeled by the typed API. +## Supported Typed Subset + +The typed API currently supports this subset directly: + +- Sets with typed values for IPv4/IPv6 addresses and CIDRs, ports/services, interface names, marks, and raw literals. +- Maps declared and populated inline, authored through dictionary-style helpers on `NftMap`. +- Chains with typed base-chain metadata: type, hook, priority, and policy. +- Common firewall rules: + - source/destination address matches + - source/destination port matches + - input/output interface matches + - set membership matches + - verdicts: accept, drop, reject, jump + +Use `NftApplyRequest` as the fallback when you need: + +- nft features outside the current typed subset +- live map mutation commands +- custom map key/value type expressions with non-raw typed values +- snapshot parsing back into typed object models +- full nft expression parity + +## Map Authoring Example + +```csharp +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)); +``` + +Entries are rendered in insertion order. Duplicate keys are rejected by `NftMap.Add`. Use `NftMap.Set` to replace an existing key without changing its order. + +## Validate-And-Render Example + +```csharp +NftRenderedValidationResult preview = client.ValidateAndRenderRuleset(ruleset); + +if (preview.ValidationResult.IsValid) +{ + client.ApplyRuleset(ruleset); +} +``` + ## Low-Level Example ```csharp @@ -153,7 +203,7 @@ Some operations require elevated privileges or `CAP_NET_ADMIN`, especially when ### Validation failures -`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. +`ValidateRuleset` and `ValidateAndRenderRuleset` return `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 diff --git a/src/LibNftables/INftablesClient.cs b/src/LibNftables/INftablesClient.cs index df01c68..4ba2314 100644 --- a/src/LibNftables/INftablesClient.cs +++ b/src/LibNftables/INftablesClient.cs @@ -39,6 +39,17 @@ public interface INftablesClient /// Thrown when the typed model itself is invalid. string RenderRuleset(NftRuleset ruleset); + /// + /// Renders and validates a typed nftables ruleset in one flow. + /// + /// The typed ruleset to render and validate. + /// A cancellation token. + /// The rendered text and validation outcome. + /// Thrown when the typed model itself is invalid before native validation begins. + /// Thrown when runtime/platform is unsupported. + /// Thrown when required native runtime components cannot be loaded. + NftRenderedValidationResult ValidateAndRenderRuleset(NftRuleset ruleset, System.Threading.CancellationToken ct = default); + /// /// Asynchronously validates a ruleset request using dry-run mode. /// @@ -61,6 +72,17 @@ public interface INftablesClient /// Thrown when required native runtime components cannot be loaded. System.Threading.Tasks.Task ValidateRulesetAsync(NftRuleset ruleset, System.Threading.CancellationToken ct = default); + /// + /// Asynchronously renders and validates a typed nftables ruleset in one flow. + /// + /// The typed ruleset to render and validate. + /// A cancellation token. + /// A task that resolves to the rendered text and validation outcome. + /// Thrown when the typed model itself is invalid before native validation begins. + /// Thrown when runtime/platform is unsupported. + /// Thrown when required native runtime components cannot be loaded. + System.Threading.Tasks.Task ValidateAndRenderRulesetAsync(NftRuleset ruleset, System.Threading.CancellationToken ct = default); + /// /// Applies a ruleset request. /// diff --git a/src/LibNftables/NftMap.cs b/src/LibNftables/NftMap.cs index f5209f6..6628464 100644 --- a/src/LibNftables/NftMap.cs +++ b/src/LibNftables/NftMap.cs @@ -5,6 +5,8 @@ namespace LibNftables; /// public sealed class NftMap { + private readonly List _entries = new(); + /// /// Gets or sets the map name. /// @@ -43,7 +45,133 @@ public sealed class NftMap public string? CustomValueTypeExpression { get; set; } /// - /// Gets the entries declared inline with the map definition. + /// Gets the number of entries currently stored in the map. /// - public IList Entries { get; } = new List(); + public int Count => _entries.Count; + + /// + /// Gets the entries declared inline with the map definition in insertion order. + /// + public IReadOnlyList Entries => _entries; + + /// + /// Adds a new entry to the map. + /// + /// The entry key. + /// The entry value. + /// Thrown when or is null. + /// Thrown when an entry with the same rendered key already exists. + public void Add(NftValue key, NftValue value) + { + ArgumentNullException.ThrowIfNull(key); + ArgumentNullException.ThrowIfNull(value); + + if (ContainsKey(key)) + { + throw new ArgumentException($"Map already contains an entry for key '{key.RenderedText}'.", nameof(key)); + } + + _entries.Add(new NftMapEntry + { + Key = key, + Value = value, + }); + } + + /// + /// Adds or replaces an entry in the map while preserving insertion order for existing keys. + /// + /// The entry key. + /// The entry value. + /// Thrown when or is null. + public void Set(NftValue key, NftValue value) + { + ArgumentNullException.ThrowIfNull(key); + ArgumentNullException.ThrowIfNull(value); + + int existingIndex = FindIndex(key); + if (existingIndex >= 0) + { + _entries[existingIndex].Value = value; + return; + } + + Add(key, value); + } + + /// + /// Determines whether the map contains an entry for the specified key. + /// + /// The entry key. + /// when the key exists; otherwise . + public bool ContainsKey(NftValue key) + { + ArgumentNullException.ThrowIfNull(key); + return FindIndex(key) >= 0; + } + + /// + /// Attempts to retrieve the value for the specified key. + /// + /// The entry key. + /// The matching value when found. + /// when the key exists; otherwise . + public bool TryGetValue(NftValue key, out NftValue value) + { + ArgumentNullException.ThrowIfNull(key); + + int index = FindIndex(key); + if (index >= 0) + { + value = _entries[index].Value!; + return true; + } + + value = null!; + return false; + } + + /// + /// Removes the entry for the specified key when present. + /// + /// The entry key. + /// when an entry was removed; otherwise . + public bool Remove(NftValue key) + { + ArgumentNullException.ThrowIfNull(key); + + int index = FindIndex(key); + if (index < 0) + { + return false; + } + + _entries.RemoveAt(index); + return true; + } + + /// + /// Removes all entries from the map. + /// + public void Clear() => _entries.Clear(); + + private int FindIndex(NftValue key) + { + for (int i = 0; i < _entries.Count; i++) + { + NftMapEntry entry = _entries[i]; + if (entry.Key is null) + { + continue; + } + + if (entry.Key.Kind == key.Kind && + string.Equals(entry.Key.RenderedText, key.RenderedText, StringComparison.Ordinal)) + { + return i; + } + } + + return -1; + } } diff --git a/src/LibNftables/NftRenderedValidationResult.cs b/src/LibNftables/NftRenderedValidationResult.cs new file mode 100644 index 0000000..ce6d82d --- /dev/null +++ b/src/LibNftables/NftRenderedValidationResult.cs @@ -0,0 +1,8 @@ +namespace LibNftables; + +/// +/// Represents the rendered nft command text and validation outcome for a typed ruleset preview. +/// +/// The rendered nft command text. +/// The validation outcome for the rendered ruleset. +public sealed record NftRenderedValidationResult(string RenderedRulesetText, NftValidationResult ValidationResult); diff --git a/src/LibNftables/NftRulesetRenderer.cs b/src/LibNftables/NftRulesetRenderer.cs index 61c4632..2e31bcd 100644 --- a/src/LibNftables/NftRulesetRenderer.cs +++ b/src/LibNftables/NftRulesetRenderer.cs @@ -404,8 +404,14 @@ internal static class NftRulesetRenderer throw new NftValidationException($"Map '{map.Name}' contains a null entry at index {i}.", 0, null); } - string renderedKey = RenderValue(entry.Key!, $"Map '{map.Name}' key"); - _ = RenderValue(entry.Value!, $"Map '{map.Name}' value"); + NftValue key = entry.Key ?? throw new NftValidationException($"Map '{map.Name}' contains a null key at index {i}.", 0, null); + NftValue value = entry.Value ?? throw new NftValidationException($"Map '{map.Name}' contains a null value at index {i}.", 0, null); + + string renderedKey = RenderValue(key, $"Map '{map.Name}' key"); + _ = RenderValue(value, $"Map '{map.Name}' value"); + + ValidateMapEntryValueCompatibility(map, key, isKey: true); + ValidateMapEntryValueCompatibility(map, value, isKey: false); if (!keys.Add(renderedKey)) { @@ -414,6 +420,84 @@ internal static class NftRulesetRenderer } } + private static void ValidateMapEntryValueCompatibility(NftMap map, NftValue value, bool isKey) + { + string role = isKey ? "key" : "value"; + NftMapType? typedType = isKey ? map.KeyType : map.ValueType; + string? customType = isKey ? map.CustomKeyTypeExpression : map.CustomValueTypeExpression; + + if (!typedType.HasValue) + { + if (value.Kind != NftValueKind.Raw) + { + throw new NftValidationException( + $"Map '{map.Name}' uses custom {role} type '{customType}', so only raw values are supported for that {role}.", + 0, + null); + } + + return; + } + + switch (typedType.Value) + { + case NftMapType.Ipv4Address: + ValidateTypedMapAddressValue(map, value, role, AddressFamily.InterNetwork); + return; + case NftMapType.Ipv6Address: + ValidateTypedMapAddressValue(map, value, role, AddressFamily.InterNetworkV6); + return; + case NftMapType.InetService: + ValidateTypedMapValueKind(map, value, role, NftValueKind.Port, NftValueKind.Raw); + return; + case NftMapType.InterfaceName: + ValidateTypedMapValueKind(map, value, role, NftValueKind.Interface, NftValueKind.Raw); + return; + case NftMapType.Mark: + ValidateTypedMapValueKind(map, value, role, NftValueKind.Mark, NftValueKind.Raw); + return; + case NftMapType.Verdict: + ValidateTypedMapValueKind(map, value, role, NftValueKind.Verdict, NftValueKind.Raw); + return; + case NftMapType.EtherAddress: + ValidateTypedMapValueKind(map, value, role, NftValueKind.Raw); + return; + default: + throw new NftValidationException($"Map '{map.Name}' uses unsupported typed {role} type '{typedType}'.", 0, null); + } + } + + private static void ValidateTypedMapAddressValue(NftMap map, NftValue value, string role, AddressFamily family) + { + if (value.Kind == NftValueKind.Raw) + { + return; + } + + ValidateTypedMapValueKind(map, value, role, NftValueKind.Address, NftValueKind.Network); + if (value.AddressFamily != family) + { + string familyName = family == AddressFamily.InterNetwork ? "IPv4" : "IPv6"; + throw new NftValidationException( + $"Map '{map.Name}' {role} '{value.RenderedText}' is not compatible with the declared {familyName} map type.", + 0, + null); + } + } + + private static void ValidateTypedMapValueKind(NftMap map, NftValue value, string role, params NftValueKind[] allowedKinds) + { + if (Array.IndexOf(allowedKinds, value.Kind) >= 0) + { + return; + } + + throw new NftValidationException( + $"Map '{map.Name}' {role} '{value.RenderedText}' is not compatible with the declared map type.", + 0, + null); + } + private static void ValidateChain(NftChain chain, NftTable table) { if (chain is null) diff --git a/src/LibNftables/NftablesClient.cs b/src/LibNftables/NftablesClient.cs index 32983c9..b1618b2 100644 --- a/src/LibNftables/NftablesClient.cs +++ b/src/LibNftables/NftablesClient.cs @@ -60,13 +60,23 @@ public sealed class NftablesClient : INftablesClient public NftValidationResult ValidateRuleset(NftRuleset ruleset, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - return Validate(CreateTypedRequest(ruleset), ct); + return ValidateAndRenderRuleset(ruleset, ct).ValidationResult; } /// public string RenderRuleset(NftRuleset ruleset) => NftRulesetRenderer.Render(ruleset); + /// + public NftRenderedValidationResult ValidateAndRenderRuleset(NftRuleset ruleset, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + string rendered = RenderRuleset(ruleset); + NftValidationResult validation = Validate(NftApplyRequest.FromText(rendered), ct); + return new NftRenderedValidationResult(rendered, validation); + } + /// public Task ValidateAsync(NftApplyRequest request, CancellationToken ct = default) => Task.FromResult(Validate(request, ct)); @@ -75,6 +85,10 @@ public sealed class NftablesClient : INftablesClient public Task ValidateRulesetAsync(NftRuleset ruleset, CancellationToken ct = default) => Task.FromResult(ValidateRuleset(ruleset, ct)); + /// + public Task ValidateAndRenderRulesetAsync(NftRuleset ruleset, CancellationToken ct = default) + => Task.FromResult(ValidateAndRenderRuleset(ruleset, ct)); + /// public void Apply(NftApplyRequest request, CancellationToken ct = default) { diff --git a/tests/LibNftables.Tests/NftRulesetRendererTests.cs b/tests/LibNftables.Tests/NftRulesetRendererTests.cs index a3de865..a4454e7 100644 --- a/tests/LibNftables.Tests/NftRulesetRendererTests.cs +++ b/tests/LibNftables.Tests/NftRulesetRendererTests.cs @@ -29,6 +29,27 @@ public sealed class NftRulesetRendererTests Assert.Contains("443 : drop", rendered, StringComparison.Ordinal); } + [Fact] + public void Render_WithIncompatibleTypedMapKey_ThrowsValidationException() + { + var ruleset = new NftRuleset(); + var table = new NftTable + { + Name = "filter", + }; + var map = new NftMap + { + Name = "bad_map", + KeyType = NftMapType.InetService, + ValueType = NftMapType.Verdict, + }; + map.Add(NftValue.Interface("eth0"), NftValue.Verdict(NftVerdict.Accept)); + table.Maps.Add(map); + ruleset.Tables.Add(table); + + Assert.Throws(() => NftRulesetRenderer.Render(ruleset)); + } + [Fact] public void Render_WithNoTables_ThrowsValidationException() { @@ -130,16 +151,8 @@ public sealed class NftRulesetRendererTests KeyType = NftMapType.InetService, ValueType = NftMapType.Verdict, }; - map.Entries.Add(new NftMapEntry - { - Key = NftValue.Port(80), - Value = NftValue.Verdict(NftVerdict.Accept), - }); - map.Entries.Add(new NftMapEntry - { - Key = NftValue.Port(443), - Value = NftValue.Verdict(NftVerdict.Drop), - }); + 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 diff --git a/tests/LibNftables.Tests/NftablesClientIntegrationTests.cs b/tests/LibNftables.Tests/NftablesClientIntegrationTests.cs index 1d88987..5b8827c 100644 --- a/tests/LibNftables.Tests/NftablesClientIntegrationTests.cs +++ b/tests/LibNftables.Tests/NftablesClientIntegrationTests.cs @@ -119,6 +119,61 @@ public sealed class NftablesClientIntegrationTests Assert.True(result.IsValid); } + [Fact] + public void ValidateAndRenderRuleset_WithTypedMapAndRule_ReturnsValidResult() + { + if (!CanCreateClient()) + { + return; + } + + var client = new NftablesClient(); + var ruleset = new NftRuleset(); + var table = new NftTable + { + Family = NftFamily.Inet, + Name = "typed_preview", + }; + var set = new NftSet + { + Name = "blocked_ipv4", + Type = NftSetType.Ipv4Address, + }; + set.Elements.Add(NftValue.Address(System.Net.IPAddress.Parse("10.0.0.1"))); + 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)); + table.Maps.Add(map); + + var chain = new NftChain + { + Name = "input", + Type = NftChainType.Filter, + Hook = NftHook.Input, + Priority = 0, + }; + chain.Rules.Add(new NftRule + { + SourceAddressSetName = "blocked_ipv4", + TransportProtocol = NftTransportProtocol.Tcp, + DestinationPort = NftValue.Port(22), + Verdict = NftVerdict.Accept, + }); + table.Chains.Add(chain); + ruleset.Tables.Add(table); + + NftRenderedValidationResult result = client.ValidateAndRenderRuleset(ruleset); + + Assert.True(result.ValidationResult.IsValid); + Assert.Contains("add map inet typed_preview service_policy", result.RenderedRulesetText, StringComparison.Ordinal); + } + private static bool CanCreateClient() { try diff --git a/tests/LibNftables.Tests/NftablesClientUnitTests.cs b/tests/LibNftables.Tests/NftablesClientUnitTests.cs index 630d62d..1e20c86 100644 --- a/tests/LibNftables.Tests/NftablesClientUnitTests.cs +++ b/tests/LibNftables.Tests/NftablesClientUnitTests.cs @@ -87,6 +87,29 @@ public sealed class NftablesClientUnitTests 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() { @@ -122,6 +145,41 @@ public sealed class NftablesClientUnitTests 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(() => 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() { @@ -291,16 +349,8 @@ public sealed class NftablesClientUnitTests KeyType = NftMapType.InetService, ValueType = NftMapType.Verdict, }; - map.Entries.Add(new NftMapEntry - { - Key = NftValue.Port(80), - Value = NftValue.Verdict(NftVerdict.Accept), - }); - map.Entries.Add(new NftMapEntry - { - Key = NftValue.Port(443), - Value = NftValue.Verdict(NftVerdict.Drop), - }); + 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