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;
}