From e89739a64f86bb00c4ce1bcdfcf9f33a7a3f758d Mon Sep 17 00:00:00 2001 From: Vibe Myass Date: Mon, 16 Mar 2026 04:07:08 +0000 Subject: [PATCH] Expand typed firewall and map API --- README.md | 29 +- src/LibNftables/INftablesClient.cs | 8 + src/LibNftables/NftChain.cs | 43 ++ src/LibNftables/NftChainPolicy.cs | 17 + src/LibNftables/NftChainType.cs | 22 + src/LibNftables/NftHook.cs | 37 + src/LibNftables/NftMap.cs | 49 ++ src/LibNftables/NftMapEntry.cs | 17 + src/LibNftables/NftMapType.cs | 42 ++ src/LibNftables/NftRule.cs | 72 ++ src/LibNftables/NftRulesetRenderer.cs | 693 ++++++++++++++++-- src/LibNftables/NftSet.cs | 4 +- src/LibNftables/NftTable.cs | 10 + src/LibNftables/NftTransportProtocol.cs | 17 + src/LibNftables/NftValue.cs | 127 ++++ src/LibNftables/NftValueKind.cs | 42 ++ src/LibNftables/NftVerdict.cs | 27 + src/LibNftables/NftablesClient.cs | 4 + .../NftRulesetRendererTests.cs | 147 +++- .../NftablesClientIntegrationTests.cs | 22 +- .../NftablesClientUnitTests.cs | 75 +- 21 files changed, 1373 insertions(+), 131 deletions(-) create mode 100644 src/LibNftables/NftChain.cs create mode 100644 src/LibNftables/NftChainPolicy.cs create mode 100644 src/LibNftables/NftChainType.cs create mode 100644 src/LibNftables/NftHook.cs create mode 100644 src/LibNftables/NftMap.cs create mode 100644 src/LibNftables/NftMapEntry.cs create mode 100644 src/LibNftables/NftMapType.cs create mode 100644 src/LibNftables/NftRule.cs create mode 100644 src/LibNftables/NftTransportProtocol.cs create mode 100644 src/LibNftables/NftValue.cs create mode 100644 src/LibNftables/NftValueKind.cs create mode 100644 src/LibNftables/NftVerdict.cs diff --git a/README.md b/README.md index 8f9d8e9..01430a4 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@ This library is intentionally narrow. - High-level managed API: - - typed `NftRuleset` / `NftTable` / `NftSet` authoring + - typed `NftRuleset` / `NftTable` / `NftSet` / `NftMap` / `NftChain` / `NftRule` authoring + - `RenderRuleset` - `ValidateRuleset` - `ApplyRuleset` - `Snapshot` @@ -17,7 +18,7 @@ This library is intentionally narrow. Non-goals for the current release: -- Typed rule expressions, maps, and snapshot parsing back into object models +- Full nft expression parity and snapshot parsing back into object models - Event monitoring or subscriptions - Cross-platform support beyond Linux x64 @@ -79,11 +80,31 @@ var blocked = new NftSet Name = "blocked_ipv4", Type = NftSetType.Ipv4Address, }; -blocked.Elements.Add("10.0.0.1"); -blocked.Elements.Add("10.0.0.2"); +blocked.Elements.Add(NftValue.Address(System.Net.IPAddress.Parse("10.0.0.1"))); +blocked.Elements.Add(NftValue.Address(System.Net.IPAddress.Parse("10.0.0.2"))); table.Sets.Add(blocked); + +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); +string preview = client.RenderRuleset(ruleset); var validation = client.ValidateRuleset(ruleset); if (validation.IsValid) { diff --git a/src/LibNftables/INftablesClient.cs b/src/LibNftables/INftablesClient.cs index c143859..df01c68 100644 --- a/src/LibNftables/INftablesClient.cs +++ b/src/LibNftables/INftablesClient.cs @@ -31,6 +31,14 @@ public interface INftablesClient /// Thrown when required native runtime components cannot be loaded. NftValidationResult ValidateRuleset(NftRuleset ruleset, System.Threading.CancellationToken ct = default); + /// + /// Renders a typed nftables ruleset to nft command text without executing it. + /// + /// The typed ruleset to render. + /// The rendered nft command text. + /// Thrown when the typed model itself is invalid. + string RenderRuleset(NftRuleset ruleset); + /// /// Asynchronously validates a ruleset request using dry-run mode. /// diff --git a/src/LibNftables/NftChain.cs b/src/LibNftables/NftChain.cs new file mode 100644 index 0000000..1dafd1b --- /dev/null +++ b/src/LibNftables/NftChain.cs @@ -0,0 +1,43 @@ +namespace LibNftables; + +/// +/// Represents a typed nftables chain definition. +/// +public sealed class NftChain +{ + /// + /// Gets or sets the chain name. + /// + public string? Name { get; set; } + + /// + /// Gets or sets the chain type for base chains. + /// + /// + /// Leave to model a regular chain instead of a base chain. + /// + public NftChainType? Type { get; set; } + + /// + /// Gets or sets the hook for base chains. + /// + /// + /// Leave to model a regular chain instead of a base chain. + /// + public NftHook? Hook { get; set; } + + /// + /// Gets or sets the priority for base chains. + /// + public int? Priority { get; set; } + + /// + /// Gets or sets the default policy for base chains. + /// + public NftChainPolicy? Policy { get; set; } + + /// + /// Gets the rules declared in this chain. + /// + public IList Rules { get; } = new List(); +} diff --git a/src/LibNftables/NftChainPolicy.cs b/src/LibNftables/NftChainPolicy.cs new file mode 100644 index 0000000..d69e915 --- /dev/null +++ b/src/LibNftables/NftChainPolicy.cs @@ -0,0 +1,17 @@ +namespace LibNftables; + +/// +/// Supported policies for typed base chains. +/// +public enum NftChainPolicy +{ + /// + /// Accept packets by default. + /// + Accept, + + /// + /// Drop packets by default. + /// + Drop, +} diff --git a/src/LibNftables/NftChainType.cs b/src/LibNftables/NftChainType.cs new file mode 100644 index 0000000..b1d8477 --- /dev/null +++ b/src/LibNftables/NftChainType.cs @@ -0,0 +1,22 @@ +namespace LibNftables; + +/// +/// Supported nftables chain types for the typed high-level model. +/// +public enum NftChainType +{ + /// + /// Filter chain. + /// + Filter, + + /// + /// NAT chain. + /// + Nat, + + /// + /// Route chain. + /// + Route, +} diff --git a/src/LibNftables/NftHook.cs b/src/LibNftables/NftHook.cs new file mode 100644 index 0000000..2200460 --- /dev/null +++ b/src/LibNftables/NftHook.cs @@ -0,0 +1,37 @@ +namespace LibNftables; + +/// +/// Supported nftables base-chain hooks for the typed high-level model. +/// +public enum NftHook +{ + /// + /// Input hook. + /// + Input, + + /// + /// Forward hook. + /// + Forward, + + /// + /// Output hook. + /// + Output, + + /// + /// Prerouting hook. + /// + Prerouting, + + /// + /// Postrouting hook. + /// + Postrouting, + + /// + /// Ingress hook. + /// + Ingress, +} diff --git a/src/LibNftables/NftMap.cs b/src/LibNftables/NftMap.cs new file mode 100644 index 0000000..f5209f6 --- /dev/null +++ b/src/LibNftables/NftMap.cs @@ -0,0 +1,49 @@ +namespace LibNftables; + +/// +/// Represents a typed nftables map definition backed by native C# collections. +/// +public sealed class NftMap +{ + /// + /// Gets or sets the map name. + /// + public string? Name { get; set; } + + /// + /// Gets or sets a common typed key type. + /// + /// + /// Set either or . + /// + public NftMapType? KeyType { get; set; } + + /// + /// Gets or sets a custom nftables key-type expression for the map. + /// + /// + /// Set either or . + /// + public string? CustomKeyTypeExpression { get; set; } + + /// + /// Gets or sets a common typed value type. + /// + /// + /// Set either or . + /// + public NftMapType? ValueType { get; set; } + + /// + /// Gets or sets a custom nftables value-type expression for the map. + /// + /// + /// Set either or . + /// + public string? CustomValueTypeExpression { get; set; } + + /// + /// Gets the entries declared inline with the map definition. + /// + public IList Entries { get; } = new List(); +} diff --git a/src/LibNftables/NftMapEntry.cs b/src/LibNftables/NftMapEntry.cs new file mode 100644 index 0000000..e30af70 --- /dev/null +++ b/src/LibNftables/NftMapEntry.cs @@ -0,0 +1,17 @@ +namespace LibNftables; + +/// +/// Represents a key/value entry in a typed nftables map. +/// +public sealed class NftMapEntry +{ + /// + /// Gets or sets the entry key. + /// + public NftValue? Key { get; set; } + + /// + /// Gets or sets the entry value. + /// + public NftValue? Value { get; set; } +} diff --git a/src/LibNftables/NftMapType.cs b/src/LibNftables/NftMapType.cs new file mode 100644 index 0000000..0103300 --- /dev/null +++ b/src/LibNftables/NftMapType.cs @@ -0,0 +1,42 @@ +namespace LibNftables; + +/// +/// Supported typed key/value data kinds for nftables maps. +/// +public enum NftMapType +{ + /// + /// IPv4 addresses (ipv4_addr). + /// + Ipv4Address, + + /// + /// IPv6 addresses (ipv6_addr). + /// + Ipv6Address, + + /// + /// Internet service/port values (inet_service). + /// + InetService, + + /// + /// Ethernet/MAC addresses (ether_addr). + /// + EtherAddress, + + /// + /// Interface names (ifname). + /// + InterfaceName, + + /// + /// Packet marks (mark). + /// + Mark, + + /// + /// Verdict values (verdict). + /// + Verdict, +} diff --git a/src/LibNftables/NftRule.cs b/src/LibNftables/NftRule.cs new file mode 100644 index 0000000..b969234 --- /dev/null +++ b/src/LibNftables/NftRule.cs @@ -0,0 +1,72 @@ +namespace LibNftables; + +/// +/// Represents a typed nftables firewall rule in the supported high-level subset. +/// +public sealed class NftRule +{ + /// + /// Gets or sets a source-address value match. + /// + public NftValue? SourceAddress { get; set; } + + /// + /// Gets or sets the name of a set to use for source-address membership. + /// + public string? SourceAddressSetName { get; set; } + + /// + /// Gets or sets a destination-address value match. + /// + public NftValue? DestinationAddress { get; set; } + + /// + /// Gets or sets the name of a set to use for destination-address membership. + /// + public string? DestinationAddressSetName { get; set; } + + /// + /// Gets or sets the transport protocol used for port matches. + /// + public NftTransportProtocol? TransportProtocol { get; set; } + + /// + /// Gets or sets a source-port value match. + /// + public NftValue? SourcePort { get; set; } + + /// + /// Gets or sets the name of a set to use for source-port membership. + /// + public string? SourcePortSetName { get; set; } + + /// + /// Gets or sets a destination-port value match. + /// + public NftValue? DestinationPort { get; set; } + + /// + /// Gets or sets the name of a set to use for destination-port membership. + /// + public string? DestinationPortSetName { get; set; } + + /// + /// Gets or sets an input-interface match. + /// + public NftValue? InputInterface { get; set; } + + /// + /// Gets or sets an output-interface match. + /// + public NftValue? OutputInterface { get; set; } + + /// + /// Gets or sets the verdict action. + /// + public NftVerdict Verdict { get; set; } = NftVerdict.Accept; + + /// + /// Gets or sets the jump target chain name when is . + /// + public string? JumpTarget { get; set; } +} diff --git a/src/LibNftables/NftRulesetRenderer.cs b/src/LibNftables/NftRulesetRenderer.cs index 643b5a2..61c4632 100644 --- a/src/LibNftables/NftRulesetRenderer.cs +++ b/src/LibNftables/NftRulesetRenderer.cs @@ -1,3 +1,4 @@ +using System.Net.Sockets; using System.Text; namespace LibNftables; @@ -32,60 +33,302 @@ internal static class NftRulesetRenderer null); } - builder.Append("add table ") - .Append(ToKeyword(table.Family)) - .Append(' ') - .Append(table.Name) - .AppendLine(); - - var setNames = new HashSet(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(); - } + RenderTable(builder, table); } return builder.ToString(); } + private static void RenderTable(StringBuilder builder, NftTable table) + { + string family = ToKeyword(table.Family); + + builder.Append("add table ") + .Append(family) + .Append(' ') + .Append(table.Name) + .AppendLine(); + + var setNames = new HashSet(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); + } + + RenderSet(builder, table, set, family); + } + + var mapNames = new HashSet(StringComparer.Ordinal); + foreach (NftMap map in table.Maps) + { + ValidateMap(map, table); + + if (!mapNames.Add(map.Name!)) + { + throw new NftValidationException($"Table '{table.Name}' contains duplicate map '{map.Name}'.", 0, null); + } + + RenderMap(builder, table, map, family); + } + + var chainsByName = new Dictionary(StringComparer.Ordinal); + foreach (NftChain chain in table.Chains) + { + ValidateChain(chain, table); + + if (!chainsByName.TryAdd(chain.Name!, chain)) + { + throw new NftValidationException($"Table '{table.Name}' contains duplicate chain '{chain.Name}'.", 0, null); + } + + RenderChain(builder, table, chain, family); + } + + foreach (NftChain chain in table.Chains) + { + for (int i = 0; i < chain.Rules.Count; i++) + { + RenderRule(builder, table, chain, chain.Rules[i], i, family, chainsByName); + } + } + } + + private static void RenderSet(StringBuilder builder, NftTable table, NftSet set, string family) + { + builder.Append("add set ") + .Append(family) + .Append(' ') + .Append(table.Name) + .Append(' ') + .Append(set.Name) + .Append(" { type ") + .Append(GetSetTypeExpression(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(RenderValue(set.Elements[i], $"Set '{set.Name}' element")); + } + + builder.Append(" };"); + } + + builder.Append(" }") + .AppendLine(); + } + + private static void RenderMap(StringBuilder builder, NftTable table, NftMap map, string family) + { + builder.Append("add map ") + .Append(family) + .Append(' ') + .Append(table.Name) + .Append(' ') + .Append(map.Name) + .Append(" { type ") + .Append(GetMapTypeExpression(map, isKey: true)) + .Append(" : ") + .Append(GetMapTypeExpression(map, isKey: false)) + .Append(';'); + + if (map.Entries.Count > 0) + { + builder.Append(" elements = { "); + + for (int i = 0; i < map.Entries.Count; i++) + { + if (i > 0) + { + builder.Append(", "); + } + + NftMapEntry entry = map.Entries[i]; + builder.Append(RenderValue(entry.Key!, $"Map '{map.Name}' key")) + .Append(" : ") + .Append(RenderValue(entry.Value!, $"Map '{map.Name}' value")); + } + + builder.Append(" };"); + } + + builder.Append(" }") + .AppendLine(); + } + + private static void RenderChain(StringBuilder builder, NftTable table, NftChain chain, string family) + { + builder.Append("add chain ") + .Append(family) + .Append(' ') + .Append(table.Name) + .Append(' ') + .Append(chain.Name); + + if (IsBaseChain(chain)) + { + builder.Append(" { type ") + .Append(ToKeyword(chain.Type!.Value)) + .Append(" hook ") + .Append(ToKeyword(chain.Hook!.Value)) + .Append(" priority ") + .Append(chain.Priority!.Value) + .Append(';'); + + if (chain.Policy.HasValue) + { + builder.Append(" policy ") + .Append(ToKeyword(chain.Policy.Value)) + .Append(';'); + } + + builder.Append(" }"); + } + + builder.AppendLine(); + } + + private static void RenderRule( + StringBuilder builder, + NftTable table, + NftChain chain, + NftRule rule, + int index, + string family, + IReadOnlyDictionary chainsByName) + { + ValidateRule(rule, table, chain, index, chainsByName); + + var tokens = new List(); + + if (rule.InputInterface is not null) + { + tokens.Add("iifname " + RenderTypedField(rule.InputInterface, NftValueKind.Interface, "InputInterface")); + } + + if (rule.OutputInterface is not null) + { + tokens.Add("oifname " + RenderTypedField(rule.OutputInterface, NftValueKind.Interface, "OutputInterface")); + } + + AppendAddressMatch(tokens, rule.SourceAddress, rule.SourceAddressSetName, table, "saddr"); + AppendAddressMatch(tokens, rule.DestinationAddress, rule.DestinationAddressSetName, table, "daddr"); + + bool hasPortMatch = rule.SourcePort is not null || + rule.DestinationPort is not null || + !string.IsNullOrWhiteSpace(rule.SourcePortSetName) || + !string.IsNullOrWhiteSpace(rule.DestinationPortSetName); + + if (hasPortMatch) + { + tokens.Add(ToKeyword(rule.TransportProtocol!.Value)); + AppendPortMatch(tokens, rule.SourcePort, rule.SourcePortSetName, table, "sport"); + AppendPortMatch(tokens, rule.DestinationPort, rule.DestinationPortSetName, table, "dport"); + } + else if (rule.TransportProtocol.HasValue) + { + tokens.Add("meta l4proto " + ToKeyword(rule.TransportProtocol.Value)); + } + + tokens.Add(RenderVerdict(rule)); + + builder.Append("add rule ") + .Append(family) + .Append(' ') + .Append(table.Name) + .Append(' ') + .Append(chain.Name) + .Append(' ') + .Append(string.Join(" ", tokens)) + .AppendLine(); + } + + private static void AppendAddressMatch(List tokens, NftValue? value, string? setName, NftTable table, string direction) + { + if (value is null && string.IsNullOrWhiteSpace(setName)) + { + return; + } + + if (value is not null) + { + ValidateValueKind(value, "address match", NftValueKind.Address, NftValueKind.Network); + string prefix = value.AddressFamily switch + { + AddressFamily.InterNetwork => "ip", + AddressFamily.InterNetworkV6 => "ip6", + _ => throw new NftValidationException("Address match must be IPv4 or IPv6.", 0, null), + }; + + tokens.Add(prefix + " " + direction + " " + RenderValue(value, "address match")); + return; + } + + string resolvedSetName = setName!; + NftSet set = FindSet(table, resolvedSetName); + string prefixFromSet = GetAddressQualifierForSet(set); + tokens.Add(prefixFromSet + " " + direction + " @" + resolvedSetName); + } + + private static void AppendPortMatch(List tokens, NftValue? value, string? setName, NftTable table, string direction) + { + if (value is null && string.IsNullOrWhiteSpace(setName)) + { + return; + } + + if (value is not null) + { + ValidateValueKind(value, "port match", NftValueKind.Port, NftValueKind.Raw); + tokens.Add(direction + " " + RenderValue(value, "port match")); + return; + } + + string resolvedSetName = setName!; + NftSet set = FindSet(table, resolvedSetName); + EnsureSetType(set, NftSetType.InetService, $"Set '{set.Name}' must use inet_service to be referenced as a port set."); + tokens.Add(direction + " @" + resolvedSetName); + } + + private static string RenderVerdict(NftRule rule) + => rule.Verdict switch + { + NftVerdict.Accept => "accept", + NftVerdict.Drop => "drop", + NftVerdict.Reject => "reject", + NftVerdict.Jump => "jump " + rule.JumpTarget, + _ => throw new NftValidationException($"Unsupported verdict '{rule.Verdict}'.", 0, null), + }; + + private static string RenderTypedField(NftValue value, NftValueKind expectedKind, string fieldName) + { + ValidateValueKind(value, fieldName, expectedKind, NftValueKind.Raw); + return RenderValue(value, fieldName); + } + + private static string RenderValue(NftValue value, string fieldName) + { + if (value is null) + { + throw new NftValidationException($"{fieldName} must not be null.", 0, null); + } + + ValidateToken(value.RenderedText, fieldName); + return value.RenderedText; + } + private static void ValidateTable(NftTable table) { if (table is null) @@ -100,42 +343,236 @@ internal static class NftRulesetRenderer { if (set is null) { - throw new NftValidationException( - $"Table '{table.Name}' contains a null set definition.", - 0, - null); + throw new NftValidationException($"Table '{table.Name}' contains a null set definition.", 0, null); } ValidateIdentifier(set.Name, "Set name"); + ValidateExclusiveTypeSources( + set.Type.HasValue, + !string.IsNullOrWhiteSpace(set.CustomTypeExpression), + $"Set '{set.Name}' must specify exactly one type source: Type or CustomTypeExpression."); - 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) + if (!string.IsNullOrWhiteSpace(set.CustomTypeExpression)) { 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)) + if (set.Elements[i] is null) + { + throw new NftValidationException($"Set '{set.Name}' contains a null element at index {i}.", 0, null); + } + + _ = RenderValue(set.Elements[i], $"Set '{set.Name}' element"); + } + } + + private static void ValidateMap(NftMap map, NftTable table) + { + if (map is null) + { + throw new NftValidationException($"Table '{table.Name}' contains a null map definition.", 0, null); + } + + ValidateIdentifier(map.Name, "Map name"); + ValidateExclusiveTypeSources( + map.KeyType.HasValue, + !string.IsNullOrWhiteSpace(map.CustomKeyTypeExpression), + $"Map '{map.Name}' must specify exactly one key type source: KeyType or CustomKeyTypeExpression."); + ValidateExclusiveTypeSources( + map.ValueType.HasValue, + !string.IsNullOrWhiteSpace(map.CustomValueTypeExpression), + $"Map '{map.Name}' must specify exactly one value type source: ValueType or CustomValueTypeExpression."); + + if (!string.IsNullOrWhiteSpace(map.CustomKeyTypeExpression)) + { + ValidateToken(map.CustomKeyTypeExpression!, $"Map '{map.Name}' custom key type"); + } + + if (!string.IsNullOrWhiteSpace(map.CustomValueTypeExpression)) + { + ValidateToken(map.CustomValueTypeExpression!, $"Map '{map.Name}' custom value type"); + } + + var keys = new HashSet(StringComparer.Ordinal); + for (int i = 0; i < map.Entries.Count; i++) + { + NftMapEntry? entry = map.Entries[i]; + if (entry is null) + { + 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"); + + if (!keys.Add(renderedKey)) + { + throw new NftValidationException($"Map '{map.Name}' contains duplicate key '{renderedKey}'.", 0, null); + } + } + } + + private static void ValidateChain(NftChain chain, NftTable table) + { + if (chain is null) + { + throw new NftValidationException($"Table '{table.Name}' contains a null chain definition.", 0, null); + } + + ValidateIdentifier(chain.Name, "Chain name"); + + bool hasBaseType = chain.Type.HasValue; + bool hasHook = chain.Hook.HasValue; + bool hasPriority = chain.Priority.HasValue; + bool hasAnyBaseConfig = hasBaseType || hasHook || hasPriority || chain.Policy.HasValue; + + if (hasAnyBaseConfig && !(hasBaseType && hasHook && hasPriority)) + { + throw new NftValidationException( + $"Chain '{chain.Name}' must set Type, Hook, and Priority together when configured as a base chain.", + 0, + null); + } + + if (!hasAnyBaseConfig && chain.Policy.HasValue) + { + throw new NftValidationException( + $"Chain '{chain.Name}' policy is only valid on base chains.", + 0, + null); + } + } + + private static void ValidateRule( + NftRule rule, + NftTable table, + NftChain chain, + int index, + IReadOnlyDictionary chainsByName) + { + if (rule is null) + { + throw new NftValidationException( + $"Chain '{chain.Name}' contains a null rule at index {index}.", + 0, + null); + } + + ValidateExclusiveRuleCriterion(rule.SourceAddress, rule.SourceAddressSetName, "source address"); + ValidateExclusiveRuleCriterion(rule.DestinationAddress, rule.DestinationAddressSetName, "destination address"); + ValidateExclusiveRuleCriterion(rule.SourcePort, rule.SourcePortSetName, "source port"); + ValidateExclusiveRuleCriterion(rule.DestinationPort, rule.DestinationPortSetName, "destination port"); + + if (rule.InputInterface is not null) + { + ValidateValueKind(rule.InputInterface, "InputInterface", NftValueKind.Interface, NftValueKind.Raw); + } + + if (rule.OutputInterface is not null) + { + ValidateValueKind(rule.OutputInterface, "OutputInterface", NftValueKind.Interface, NftValueKind.Raw); + } + + if (rule.SourceAddress is not null) + { + ValidateValueKind(rule.SourceAddress, "SourceAddress", NftValueKind.Address, NftValueKind.Network); + } + + if (rule.DestinationAddress is not null) + { + ValidateValueKind(rule.DestinationAddress, "DestinationAddress", NftValueKind.Address, NftValueKind.Network); + } + + if (rule.SourcePort is not null) + { + ValidateValueKind(rule.SourcePort, "SourcePort", NftValueKind.Port, NftValueKind.Raw); + } + + if (rule.DestinationPort is not null) + { + ValidateValueKind(rule.DestinationPort, "DestinationPort", NftValueKind.Port, NftValueKind.Raw); + } + + bool hasPortMatch = rule.SourcePort is not null || + rule.DestinationPort is not null || + !string.IsNullOrWhiteSpace(rule.SourcePortSetName) || + !string.IsNullOrWhiteSpace(rule.DestinationPortSetName); + + if (hasPortMatch && !rule.TransportProtocol.HasValue) + { + throw new NftValidationException( + $"Chain '{chain.Name}' rule at index {index} must specify TransportProtocol when matching ports.", + 0, + null); + } + + ValidateReferencedSet(table, rule.SourceAddressSetName, index, chain.Name!, NftSetType.Ipv4Address, NftSetType.Ipv6Address); + ValidateReferencedSet(table, rule.DestinationAddressSetName, index, chain.Name!, NftSetType.Ipv4Address, NftSetType.Ipv6Address); + ValidateReferencedSet(table, rule.SourcePortSetName, index, chain.Name!, NftSetType.InetService); + ValidateReferencedSet(table, rule.DestinationPortSetName, index, chain.Name!, NftSetType.InetService); + + if (rule.Verdict == NftVerdict.Jump) + { + ValidateIdentifier(rule.JumpTarget, "Jump target"); + if (!chainsByName.ContainsKey(rule.JumpTarget!)) { throw new NftValidationException( - $"Set '{set.Name}' contains an empty element at index {i}.", + $"Chain '{chain.Name}' rule at index {index} jumps to unknown chain '{rule.JumpTarget}'.", 0, null); } + } + else if (!string.IsNullOrWhiteSpace(rule.JumpTarget)) + { + throw new NftValidationException( + $"Chain '{chain.Name}' rule at index {index} cannot set JumpTarget unless Verdict is Jump.", + 0, + null); + } + } - ValidateToken(element, $"Set '{set.Name}' element"); + private static void ValidateExclusiveTypeSources(bool hasTypedType, bool hasCustomType, string message) + { + if (hasTypedType == hasCustomType) + { + throw new NftValidationException(message, 0, null); + } + } + + private static void ValidateExclusiveRuleCriterion(NftValue? value, string? setName, string fieldName) + { + bool hasValue = value is not null; + bool hasSet = !string.IsNullOrWhiteSpace(setName); + if (hasValue && hasSet) + { + throw new NftValidationException( + $"Rule must not specify both a direct value and a set reference for {fieldName}.", + 0, + null); + } + } + + private static void ValidateReferencedSet( + NftTable table, + string? setName, + int index, + string chainName, + params NftSetType[] allowedTypes) + { + if (string.IsNullOrWhiteSpace(setName)) + { + return; + } + + NftSet set = FindSet(table, setName); + if (!set.Type.HasValue || Array.IndexOf(allowedTypes, set.Type.Value) < 0) + { + throw new NftValidationException( + $"Chain '{chainName}' rule at index {index} references set '{setName}' with an unsupported type for this match.", + 0, + null); } } @@ -150,10 +587,7 @@ internal static class NftRulesetRenderer { if (char.IsWhiteSpace(ch) || ch is '{' or '}' or ';' or '"' or '\'') { - throw new NftValidationException( - $"{fieldName} contains unsupported character '{ch}'.", - 0, - null); + throw new NftValidationException($"{fieldName} contains unsupported character '{ch}'.", 0, null); } } } @@ -164,15 +598,63 @@ internal static class NftRulesetRenderer { if (ch is '\r' or '\n' or ';') { - throw new NftValidationException( - $"{fieldName} contains unsupported character '{ch}'.", - 0, - null); + throw new NftValidationException($"{fieldName} contains unsupported character '{ch}'.", 0, null); } } } - private static string GetTypeExpression(NftSet set) + private static void ValidateValueKind(NftValue value, string fieldName, params NftValueKind[] allowedKinds) + { + if (value is null) + { + throw new NftValidationException($"{fieldName} must not be null.", 0, null); + } + + if (Array.IndexOf(allowedKinds, value.Kind) >= 0) + { + return; + } + + throw new NftValidationException( + $"{fieldName} does not support value kind '{value.Kind}'.", + 0, + null); + } + + private static NftSet FindSet(NftTable table, string setName) + { + NftSet? set = table.Sets.FirstOrDefault(x => string.Equals(x.Name, setName, StringComparison.Ordinal)); + if (set is null) + { + throw new NftValidationException($"Table '{table.Name}' does not contain a set named '{setName}'.", 0, null); + } + + return set; + } + + private static void EnsureSetType(NftSet set, NftSetType expectedType, string message) + { + if (set.Type != expectedType) + { + throw new NftValidationException(message, 0, null); + } + } + + private static string GetAddressQualifierForSet(NftSet set) + => set.Type switch + { + NftSetType.Ipv4Address => "ip", + NftSetType.Ipv6Address => "ip6", + _ => throw new NftValidationException( + $"Set '{set.Name}' cannot be used as an address set because its type is not an IP address type.", + 0, + null), + }; + + private static bool IsBaseChain(NftChain chain) + => chain.Type.HasValue || chain.Hook.HasValue || chain.Priority.HasValue || chain.Policy.HasValue; + + private static string GetSetTypeExpression(NftSet set) => set.Type switch { NftSetType.Ipv4Address => "ipv4_addr", @@ -184,6 +666,24 @@ internal static class NftRulesetRenderer _ => set.CustomTypeExpression!, }; + private static string GetMapTypeExpression(NftMap map, bool isKey) + { + NftMapType? typedType = isKey ? map.KeyType : map.ValueType; + string? customType = isKey ? map.CustomKeyTypeExpression : map.CustomValueTypeExpression; + + return typedType switch + { + NftMapType.Ipv4Address => "ipv4_addr", + NftMapType.Ipv6Address => "ipv6_addr", + NftMapType.InetService => "inet_service", + NftMapType.EtherAddress => "ether_addr", + NftMapType.InterfaceName => "ifname", + NftMapType.Mark => "mark", + NftMapType.Verdict => "verdict", + _ => customType!, + }; + } + private static string ToKeyword(NftFamily family) => family switch { @@ -195,4 +695,41 @@ internal static class NftRulesetRenderer NftFamily.Netdev => "netdev", _ => throw new NftValidationException($"Unsupported nft family '{family}'.", 0, null), }; + + private static string ToKeyword(NftChainType chainType) + => chainType switch + { + NftChainType.Filter => "filter", + NftChainType.Nat => "nat", + NftChainType.Route => "route", + _ => throw new NftValidationException($"Unsupported chain type '{chainType}'.", 0, null), + }; + + private static string ToKeyword(NftHook hook) + => hook switch + { + NftHook.Input => "input", + NftHook.Forward => "forward", + NftHook.Output => "output", + NftHook.Prerouting => "prerouting", + NftHook.Postrouting => "postrouting", + NftHook.Ingress => "ingress", + _ => throw new NftValidationException($"Unsupported hook '{hook}'.", 0, null), + }; + + private static string ToKeyword(NftChainPolicy policy) + => policy switch + { + NftChainPolicy.Accept => "accept", + NftChainPolicy.Drop => "drop", + _ => throw new NftValidationException($"Unsupported chain policy '{policy}'.", 0, null), + }; + + private static string ToKeyword(NftTransportProtocol protocol) + => protocol switch + { + NftTransportProtocol.Tcp => "tcp", + NftTransportProtocol.Udp => "udp", + _ => throw new NftValidationException($"Unsupported transport protocol '{protocol}'.", 0, null), + }; } diff --git a/src/LibNftables/NftSet.cs b/src/LibNftables/NftSet.cs index 6cc1f6f..db0efbe 100644 --- a/src/LibNftables/NftSet.cs +++ b/src/LibNftables/NftSet.cs @@ -31,7 +31,7 @@ public sealed class NftSet /// Gets the set elements to declare inline with the set definition. /// /// - /// Values are rendered as nftables literals and should already be valid for the selected type. + /// Values are strongly typed where possible and rendered to nftables literals by the high-level renderer. /// - public IList Elements { get; } = new List(); + public IList Elements { get; } = new List(); } diff --git a/src/LibNftables/NftTable.cs b/src/LibNftables/NftTable.cs index 8928e93..efa5fcd 100644 --- a/src/LibNftables/NftTable.cs +++ b/src/LibNftables/NftTable.cs @@ -19,4 +19,14 @@ public sealed class NftTable /// Gets the sets declared in this table. /// public IList Sets { get; } = new List(); + + /// + /// Gets the maps declared in this table. + /// + public IList Maps { get; } = new List(); + + /// + /// Gets the chains declared in this table. + /// + public IList Chains { get; } = new List(); } diff --git a/src/LibNftables/NftTransportProtocol.cs b/src/LibNftables/NftTransportProtocol.cs new file mode 100644 index 0000000..cd1a737 --- /dev/null +++ b/src/LibNftables/NftTransportProtocol.cs @@ -0,0 +1,17 @@ +namespace LibNftables; + +/// +/// Supported L4 transport protocols for the typed rule model. +/// +public enum NftTransportProtocol +{ + /// + /// TCP. + /// + Tcp, + + /// + /// UDP. + /// + Udp, +} diff --git a/src/LibNftables/NftValue.cs b/src/LibNftables/NftValue.cs new file mode 100644 index 0000000..b17c94c --- /dev/null +++ b/src/LibNftables/NftValue.cs @@ -0,0 +1,127 @@ +using System.Globalization; +using System.Net; +using System.Net.Sockets; + +namespace LibNftables; + +/// +/// Represents a strongly typed value that can be rendered into nftables literal syntax. +/// +public sealed class NftValue +{ + private NftValue(NftValueKind kind, string renderedText, AddressFamily? addressFamily = null) + { + Kind = kind; + RenderedText = renderedText; + AddressFamily = addressFamily; + } + + /// + /// Gets the value kind. + /// + public NftValueKind Kind { get; } + + /// + /// Gets the rendered nftables literal text. + /// + public string RenderedText { get; } + + internal AddressFamily? AddressFamily { get; } + + /// + /// Creates a raw nft literal token. + /// + /// The literal token text. + /// A typed value that renders exactly as the provided token. + public static NftValue Raw(string token) + { + ArgumentException.ThrowIfNullOrWhiteSpace(token); + return new NftValue(NftValueKind.Raw, token); + } + + /// + /// Creates a single IP address value. + /// + /// The IP address. + /// A typed value for the address. + public static NftValue Address(IPAddress address) + { + ArgumentNullException.ThrowIfNull(address); + return new NftValue(NftValueKind.Address, address.ToString(), address.AddressFamily); + } + + /// + /// Creates an IP network/CIDR value. + /// + /// The network base address. + /// The network prefix length. + /// A typed value for the network. + public static NftValue Network(IPAddress baseAddress, int prefixLength) + { + ArgumentNullException.ThrowIfNull(baseAddress); + + int maxPrefix = baseAddress.AddressFamily switch + { + System.Net.Sockets.AddressFamily.InterNetwork => 32, + System.Net.Sockets.AddressFamily.InterNetworkV6 => 128, + _ => throw new ArgumentException($"Unsupported address family '{baseAddress.AddressFamily}'.", nameof(baseAddress)), + }; + + if (prefixLength < 0 || prefixLength > maxPrefix) + { + throw new ArgumentOutOfRangeException(nameof(prefixLength), $"Prefix length must be between 0 and {maxPrefix}."); + } + + return new NftValue( + NftValueKind.Network, + baseAddress + "/" + prefixLength.ToString(CultureInfo.InvariantCulture), + baseAddress.AddressFamily); + } + + /// + /// Creates a transport port value. + /// + /// The TCP/UDP port. + /// A typed value for the port. + public static NftValue Port(ushort port) + => new(NftValueKind.Port, port.ToString(CultureInfo.InvariantCulture)); + + /// + /// Creates an interface-name value. + /// + /// The interface name. + /// A typed value for the interface. + public static NftValue Interface(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + string escaped = name.Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("\"", "\\\"", StringComparison.Ordinal); + return new NftValue(NftValueKind.Interface, $"\"{escaped}\""); + } + + /// + /// Creates a packet mark value. + /// + /// The mark value. + /// A typed value for the mark. + public static NftValue Mark(uint mark) + => new(NftValueKind.Mark, mark.ToString(CultureInfo.InvariantCulture)); + + /// + /// Creates a verdict value. + /// + /// The verdict. + /// A typed value for the verdict token. + public static NftValue Verdict(NftVerdict verdict) + => new(NftValueKind.Verdict, verdict switch + { + NftVerdict.Accept => "accept", + NftVerdict.Drop => "drop", + NftVerdict.Reject => "reject", + NftVerdict.Jump => throw new ArgumentException("Jump verdicts require a chain target and are not valid as standalone typed values.", nameof(verdict)), + _ => throw new ArgumentOutOfRangeException(nameof(verdict), verdict, "Unsupported verdict value."), + }); + + /// + public override string ToString() => RenderedText; +} diff --git a/src/LibNftables/NftValueKind.cs b/src/LibNftables/NftValueKind.cs new file mode 100644 index 0000000..28ec892 --- /dev/null +++ b/src/LibNftables/NftValueKind.cs @@ -0,0 +1,42 @@ +namespace LibNftables; + +/// +/// Describes the kind of typed nftables value represented by . +/// +public enum NftValueKind +{ + /// + /// A raw nft literal token. + /// + Raw, + + /// + /// A single IP address. + /// + Address, + + /// + /// An IP network/CIDR value. + /// + Network, + + /// + /// A transport port/service value. + /// + Port, + + /// + /// An interface name. + /// + Interface, + + /// + /// A packet mark value. + /// + Mark, + + /// + /// A verdict token. + /// + Verdict, +} diff --git a/src/LibNftables/NftVerdict.cs b/src/LibNftables/NftVerdict.cs new file mode 100644 index 0000000..9e3a108 --- /dev/null +++ b/src/LibNftables/NftVerdict.cs @@ -0,0 +1,27 @@ +namespace LibNftables; + +/// +/// Supported verdict actions for the typed high-level rule model. +/// +public enum NftVerdict +{ + /// + /// Accept the packet. + /// + Accept, + + /// + /// Drop the packet. + /// + Drop, + + /// + /// Reject the packet. + /// + Reject, + + /// + /// Jump to another chain. + /// + Jump, +} diff --git a/src/LibNftables/NftablesClient.cs b/src/LibNftables/NftablesClient.cs index a7027bf..32983c9 100644 --- a/src/LibNftables/NftablesClient.cs +++ b/src/LibNftables/NftablesClient.cs @@ -63,6 +63,10 @@ public sealed class NftablesClient : INftablesClient return Validate(CreateTypedRequest(ruleset), ct); } + /// + public string RenderRuleset(NftRuleset ruleset) + => NftRulesetRenderer.Render(ruleset); + /// public Task ValidateAsync(NftApplyRequest request, CancellationToken ct = default) => Task.FromResult(Validate(request, ct)); diff --git a/tests/LibNftables.Tests/NftRulesetRendererTests.cs b/tests/LibNftables.Tests/NftRulesetRendererTests.cs index 3a0d889..a3de865 100644 --- a/tests/LibNftables.Tests/NftRulesetRendererTests.cs +++ b/tests/LibNftables.Tests/NftRulesetRendererTests.cs @@ -3,53 +3,30 @@ namespace LibNftables.Tests; public sealed class NftRulesetRendererTests { [Fact] - public void Render_WithTypedSet_RendersExpectedCommands() + public void Render_WithTypedRuleAndMap_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); + NftRuleset ruleset = CreateTypedRuleset(); 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, + "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); } [Fact] - public void Render_WithCustomTypeExpression_UsesCustomKeyword() + public void Render_WithRenderHelperReadyRuleset_RendersMapValueVerdicts() { - 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); + NftRuleset ruleset = CreateTypedRuleset(); string rendered = NftRulesetRenderer.Render(ruleset); - Assert.Contains("type inet_service;", rendered, StringComparison.Ordinal); + Assert.Contains("80 : accept", rendered, StringComparison.Ordinal); + Assert.Contains("443 : drop", rendered, StringComparison.Ordinal); } [Fact] @@ -58,6 +35,58 @@ public sealed class NftRulesetRendererTests Assert.Throws(() => NftRulesetRenderer.Render(new NftRuleset())); } + [Fact] + public void Render_WithPortMatchWithoutProtocol_ThrowsValidationException() + { + var ruleset = new NftRuleset(); + var table = new NftTable + { + Name = "filter", + }; + var chain = new NftChain + { + Name = "input", + Type = NftChainType.Filter, + Hook = NftHook.Input, + Priority = 0, + }; + chain.Rules.Add(new NftRule + { + DestinationPort = NftValue.Port(22), + Verdict = NftVerdict.Accept, + }); + table.Chains.Add(chain); + ruleset.Tables.Add(table); + + Assert.Throws(() => NftRulesetRenderer.Render(ruleset)); + } + + [Fact] + public void Render_WithUnknownJumpTarget_ThrowsValidationException() + { + var ruleset = new NftRuleset(); + var table = new NftTable + { + Name = "filter", + }; + var chain = new NftChain + { + Name = "input", + Type = NftChainType.Filter, + Hook = NftHook.Input, + Priority = 0, + }; + chain.Rules.Add(new NftRule + { + Verdict = NftVerdict.Jump, + JumpTarget = "missing", + }); + table.Chains.Add(chain); + ruleset.Tables.Add(table); + + Assert.Throws(() => NftRulesetRenderer.Render(ruleset)); + } + [Fact] public void Render_WithConflictingSetTypeSources_ThrowsValidationException() { @@ -77,23 +106,61 @@ public sealed class NftRulesetRendererTests Assert.Throws(() => NftRulesetRenderer.Render(ruleset)); } - [Fact] - public void Render_WithInvalidElement_ThrowsValidationException() + private static NftRuleset CreateTypedRuleset() { var ruleset = new NftRuleset(); var table = new NftTable { + Family = NftFamily.Inet, Name = "filter", }; + var set = new NftSet { - Name = "bad", - Type = NftSetType.Mark, + Name = "blocked_ipv4", + Type = NftSetType.Ipv4Address, }; - set.Elements.Add("1; drop"); + 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); - ruleset.Tables.Add(table); - Assert.Throws(() => NftRulesetRenderer.Render(ruleset)); + var map = new NftMap + { + Name = "service_policy", + 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), + }); + 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; } } diff --git a/tests/LibNftables.Tests/NftablesClientIntegrationTests.cs b/tests/LibNftables.Tests/NftablesClientIntegrationTests.cs index 4b1196b..1d88987 100644 --- a/tests/LibNftables.Tests/NftablesClientIntegrationTests.cs +++ b/tests/LibNftables.Tests/NftablesClientIntegrationTests.cs @@ -91,9 +91,27 @@ public sealed class NftablesClientIntegrationTests Name = "blocked_ipv4", Type = NftSetType.Ipv4Address, }; - set.Elements.Add("10.0.0.1"); - set.Elements.Add("10.0.0.2"); + 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 chain = new NftChain + { + Name = "input", + Type = NftChainType.Filter, + Hook = NftHook.Input, + Priority = 0, + Policy = NftChainPolicy.Drop, + }; + 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); NftValidationResult result = client.ValidateRuleset(ruleset); diff --git a/tests/LibNftables.Tests/NftablesClientUnitTests.cs b/tests/LibNftables.Tests/NftablesClientUnitTests.cs index 8ded37e..630d62d 100644 --- a/tests/LibNftables.Tests/NftablesClientUnitTests.cs +++ b/tests/LibNftables.Tests/NftablesClientUnitTests.cs @@ -80,7 +80,10 @@ public sealed class NftablesClientUnitTests 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 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); } @@ -95,7 +98,10 @@ public sealed class NftablesClientUnitTests 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 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); } @@ -109,10 +115,31 @@ public sealed class NftablesClientUnitTests 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 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 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() { @@ -248,14 +275,52 @@ public sealed class NftablesClientUnitTests 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"); + 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.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), + }); + 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; }