Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
8f2f2d7
test: implement testing gap 01 - binary-process-failures
Oct 11, 2026
b999000
test: implement testing gap 02 - restore-commit-process
Oct 11, 2026
231923b
test: implement testing gap 03 - restore-transaction-lifecycle
Oct 11, 2026
d495feb
test: implement testing gap 04 - restore-output-integrity
Oct 11, 2026
c6c8f0b
test: implement testing gap 05 - ruleset-sync-retries
Oct 11, 2026
3dc511d
test: implement testing gap 06 - rule-sync-protection
Oct 11, 2026
ab0713a
test: implement testing gap 07 - ipset-process-transactions
Oct 11, 2026
90dc245
test: implement testing gap 08 - ipset-sync-modes
Oct 11, 2026
511322e
test: implement testing gap 09 - ipset-entry-identity
Oct 11, 2026
e81c6de
test: implement testing gap 10 - ipset-parser-fidelity
Oct 11, 2026
8bcb942
test: implement testing gap 11 - save-parser-errors
Oct 11, 2026
46301df
test: implement testing gap 12 - rule-parser-invalid-input
Oct 11, 2026
3727274
test: implement testing gap 13 - managed-ipv6
Oct 11, 2026
22e3c1c
test: implement testing gap 14 - native-ipv6
Oct 11, 2026
25d47ea
test: implement testing gap 15 - native-handle-lifecycle
Oct 11, 2026
24dc38d
test: implement testing gap 16 - conntrack-behavior
Oct 11, 2026
1ebf227
test: implement testing gap 17 - nfacct-client
Oct 11, 2026
c5335a0
test: implement testing gap 18 - route-rule-controllers
Oct 11, 2026
5c6901e
test: implement testing gap 19 - ip-object-sync
Oct 11, 2026
12651d4
test: implement testing gap 20 - address-port-boundaries
Oct 11, 2026
a0edb35
test: implement testing gap 21 - rule-generator-boundaries
Oct 11, 2026
3ca2de7
test: implement testing gap 22 - ruleset-clone-isolation
Oct 11, 2026
5293aeb
test: implement testing gap 23 - capability-detection
Oct 11, 2026
3bd4433
test: implement testing gap 24 - test-runner-contracts
Oct 11, 2026
b9e10e6
remove plans
Oct 11, 2026
eb5aa5c
fix: preserve missing-object accounting lookup behavior
Oct 11, 2026
cc2eb2f
fix: retain IPv6 scope when rebasing CIDR addresses
Oct 11, 2026
fa64a5d
fix: synchronize explicit ipset initialization seeds
Oct 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 43 additions & 34 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ name: CI
on:
push:
pull_request:
workflow_dispatch:
inputs:
run_unstable:
description: Run opt-in conntrack/kernel tests on a disposable runner
type: boolean
default: false

env:
NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
Expand Down Expand Up @@ -40,46 +46,49 @@ jobs:
with:
dotnet-version: ${{ env.DOTNET_VERSION }}

- name: Install dependencies
run: dotnet restore

- name: Build
id: build
run: dotnet build --configuration Release --no-restore

- name: Install system dependencies
uses: awalsh128/cache-apt-pkgs-action@latest
with:
packages: build-essential libxtables-dev libip6tc-dev libip4tc-dev libiptc-dev libnl-3-dev libnl-3-200 libnetfilter-conntrack3 libnfnetlink-dev libnfnetlink0 libpcap0.8 libpcap0.8-dev sudo
version: 1.0
- name: Runner contract tests
run: |
./scripts/tests/test-runner.sh
python3 scripts/tests/test-native-results.py

- name: Install dependencies
run: dotnet restore
- name: Managed tests
run: ./test.sh --fast

- name: Modprobe IP6 tables
- name: Stable native and system tests
run: |
sudo modprobe ip6_tables
sudo modprobe ip6table_filter
sudo modprobe ip6table_mangle

if [[ "${{ matrix.os }}" == "ubuntu-latest" ]]; then
sudo update-alternatives --set iptables /usr/sbin/iptables-legacy
sudo update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy
fi
mkdir -p artifacts
./test.sh --full --iptables-backend legacy --logger "trx;LogFileName=$PWD/artifacts/stable.trx"

- name: NUnit Tests
run: SKIP_SYSTEM_TESTS=1 dotnet test IPTables.Net.Tests
- name: Require native coverage and report skips
if: always()
run: python3 scripts/check-native-results.py artifacts/stable.trx

- name: Install ipthelper dependency
run: |
cd ipthelper
CONFIG="Release" sudo -E bash install.sh
CONFIG="Debug" sudo -E bash install.sh
sudo ldconfig
cd ..
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: stable-test-results
path: artifacts/*.trx

- name: All Tests
run: sudo dotnet test
unstable:
if: github.event_name == 'workflow_dispatch' && inputs.run_unstable
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v3
- uses: actions/setup-dotnet@v3
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Opt-in kernel tests
run: |
mkdir -p artifacts
RUN_UNSTABLE_SYSTEM_TESTS=1 timeout 10m ./test.sh --full --filter 'Category=NotWorkingOnTravis' --logger "trx;LogFileName=$PWD/artifacts/unstable.trx"
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: unstable-test-results
path: artifacts/*.trx

upload:
name: upload
Expand Down
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ This repository contains a .NET 10 solution for working with Linux iptables from

Both scripts will bootstrap a usable .NET SDK if `dotnet` is missing. By default they infer the needed SDK channel from the highest `TargetFramework` declared in the repo's `.csproj` files. On Linux they also build `libipthelper` and install missing native build dependencies through a supported package manager when needed.

- Run `./scripts/tests/test-runner.sh` and `python3 scripts/tests/test-native-results.py` when changing runner behavior. These use stubs and require no privileges.
- CI uses the same fast/full entry points. Stable CI requires all tests in the native lifecycle/family and system test classes to pass; skipped native cases fail the coverage check. Conntrack/kernel tests run only through the explicit `run_unstable` workflow input.

## Native Helper Notes

- `ipthelper/` builds `libipthelper.so`, which is required for `IPTablesLibAdapter`, `IptcInterface`, and the conntrack/native tests.
Expand Down
9 changes: 6 additions & 3 deletions IPTables.Net.TestFramework/MockIptablesSystemProcess.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System;
using System;
using System.Diagnostics;
using System.Globalization;
using System.IO;
Expand All @@ -14,8 +14,10 @@
private Thread _outputReader;
private Thread _errorReader;

public MockIptablesSystemProcess(StreamReader output = null, StreamReader error = null)
public MockIptablesSystemProcess(StreamReader output = null, StreamReader error = null, int exitCode = 0, StreamWriter input = null)
{
ExitCode = exitCode;
StandardInput = input;
StandardOutput = output;
if (output != null)
{
Expand Down Expand Up @@ -149,13 +151,14 @@
public StreamReader StandardError { get; private set; }
public int WorkingSet { get; private set; }
public long WorkingSet64 { get; private set; }
public event EventHandler Disposed;

Check warning on line 154 in IPTables.Net.TestFramework/MockIptablesSystemProcess.cs

View workflow job for this annotation

GitHub Actions / build-ubuntu-latest

The event 'MockIptablesSystemProcess.Disposed' is never used

Check warning on line 154 in IPTables.Net.TestFramework/MockIptablesSystemProcess.cs

View workflow job for this annotation

GitHub Actions / build-ubuntu-latest

The event 'MockIptablesSystemProcess.Disposed' is never used
public event DataReceivedEventHandler OutputDataReceived;
public event DataReceivedEventHandler ErrorDataReceived;
public event EventHandler Exited;

Check warning on line 157 in IPTables.Net.TestFramework/MockIptablesSystemProcess.cs

View workflow job for this annotation

GitHub Actions / build-ubuntu-latest

The event 'MockIptablesSystemProcess.Exited' is never used

Check warning on line 157 in IPTables.Net.TestFramework/MockIptablesSystemProcess.cs

View workflow job for this annotation

GitHub Actions / build-ubuntu-latest

The event 'MockIptablesSystemProcess.Exited' is never used
public bool IsDisposed { get; private set; }
public void Dispose()
{

IsDisposed = true;
}
}
}
76 changes: 76 additions & 0 deletions IPTables.Net.Tests/AddressPortBoundaryTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
using System;
using System.Net;
using System.Numerics;
using IPTables.Net.Exceptions;
using IPTables.Net.Iptables.DataTypes;
namespace IPTables.Net.Tests;
public class AddressPortBoundaryTests
{
[Theory]
[InlineData("192.0.2.129/24", "192.0.2.0", "192.0.2.255", "192.0.3.0", 256)]
[InlineData("2001:db8::81/120", "2001:db8::", "2001:db8::ff", "2001:db8::100", 256)]
public void NetworkBoundariesAndRebase(string text, string start, string end, string outside, int count)
{
var cidr = IpCidr.Parse(text); Assert.Equal(new BigInteger(count), cidr.Addresses);
Assert.True(cidr.Contains(IPAddress.Parse(start))); Assert.True(cidr.Contains(IPAddress.Parse(end))); Assert.False(cidr.Contains(IPAddress.Parse(outside)));
Assert.True(cidr.Contains(IpCidr.Parse(end))); Assert.False(IpCidr.Parse(end).Contains(cidr));
Assert.Equal(IPAddress.Parse(start), IpCidr.NewRebase(cidr.Address, cidr.Prefix).Address);
Assert.True(IpCidr.Parse(start).CompareTo(IpCidr.Parse(end)) < 0);
}
[Theory]
[InlineData("0.0.0.0/0", 32)] [InlineData("::/0", 128)] [InlineData("0.0.0.0/32", 0)] [InlineData("::1/128", 0)]
public void ZeroAndHostPrefixesPreserveCounts(string text, int power)
{ var value = IpCidr.Parse(text); Assert.Equal(BigInteger.Pow(2, power), value.Addresses); Assert.Equal(value, IpCidr.Parse(value.ToString())); }
[Theory]
[InlineData("0.0.0.0/33")] [InlineData("1.2.3.4/-1")] [InlineData("::/129")] [InlineData("::/4294967296")]
[InlineData("1.2.3.4/24/1")] [InlineData("1.2.3.4/")]
public void InvalidPrefixesFail(string text) => Assert.Throws<IpTablesNetException>(() => IpCidr.Parse(text));
[Fact]
public void FamiliesNeverContainOneAnotherAndRebaseValidatesPrefix()
{
Assert.False(IpCidr.Parse("0.0.0.0/0").Contains(IPAddress.IPv6Loopback));
Assert.False(IpCidr.Parse("::/0").Contains(IpCidr.Any));
Assert.Throws<ArgumentOutOfRangeException>(() => IpCidr.NewRebase(IPAddress.Any, 33));
Assert.Equal(24u, IpCidr.Parse("0.0.0.0/24").Prefix);
}
[Theory]
[InlineData("0")] [InlineData("65535")] [InlineData("65536")] [InlineData("4294967295")] [InlineData("0:65536")]
public void GenericNumericRangesAreNotLimitedToPorts(string text) => Assert.Equal(text, PortOrRange.Parse(text, ':').ToString());
[Theory]
[InlineData("2:1")] [InlineData(":1")] [InlineData("1:")] [InlineData("1:2:3")] [InlineData("4294967296")]
public void InvalidNumericRangesFail(string text) => Assert.ThrowsAny<Exception>(() => PortOrRange.Parse(text, ':'));
[Theory]
[InlineData("192.0.2.1:65535")] [InlineData("192.0.2.1-192.0.2.2:80-90")]
[InlineData("[2001:db8::1]:80")] [InlineData("[2001:db8::1]-[2001:db8::2]:80-90")]
[InlineData("2001:db8::1")] [InlineData("192.0.2.1")]
public void AddressRangesRoundTrip(string text)
{ var parsed = IPPortOrRange.Parse(text); Assert.Equal(text, parsed.ToString()); Assert.Equal(parsed.Port, IPPortOrRange.Parse(parsed.ToString()).Port); }
[Theory]
[InlineData("192.0.2.1:65536")] [InlineData("192.0.2.2-192.0.2.1")]
[InlineData("[::1]:90-80")] [InlineData("999.1.1.1")]
public void InvalidAddressRangesFail(string text) => Assert.ThrowsAny<Exception>(() => IPPortOrRange.Parse(text));
[Fact]
public void LegacySingleEndpointFallbackAndIpv6Rendering()
{
foreach (var invalid in new[] { "bad", "192.0.2.1:65536", "192.0.2.1:abc", "192.0.2.1:1:2" }) Assert.Equal(IpPort.Any, IpPort.Parse(invalid));
Assert.Equal("[::1]:80", IpPort.Parse("[::1]:80").ToString());
Assert.Equal("192.0.2.1:0", IpPort.Parse("192.0.2.1").ToString());
}
[Theory]
[InlineData("fe80::1234%7", 128u, "fe80::1234%7")]
[InlineData("fe80::1234%7", 64u, "fe80::%7")]
[InlineData("2001:db8::1234", 64u, "2001:db8::")]
[InlineData("192.0.2.129", 32u, "192.0.2.129")]
[InlineData("192.0.2.129", 24u, "192.0.2.0")]
public void RebasePreservesScopeAndAddressFamily(string original, uint prefix, string expected)
{
var address = IPAddress.Parse(original);
var result = IpCidr.NewRebase(address, prefix);
Assert.Equal(IPAddress.Parse(expected), result.Address);
Assert.Equal(prefix, result.Prefix);
Assert.Equal(address.AddressFamily, result.Address.AddressFamily);
Assert.Equal(original, address.ToString());
if (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6)
Assert.Equal(address.ScopeId, result.Address.ScopeId);
}
}
19 changes: 19 additions & 0 deletions IPTables.Net.Tests/ArgumentHelperTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using System;
using IPTables.Net.Supporting;
namespace IPTables.Net.Tests;
public class ArgumentHelperTests
{
[Theory]
[InlineData("a\tb c", "a", "b", "c")]
[InlineData("'' \"\" x", "", "", "x")]
[InlineData("'two words' \"O'Brien\" path\\name", "two words", "O'Brien", "path\\name")]
[InlineData("'it\\'s' \"say \\\"hi\\\"\" a\\ b", "it's", "say \"hi\"", "a b")]
public void TokenizationHasIndependentExpectedValues(string input, string a, string b, string c)
{ Assert.Equal(new[] { a, b, c }, ArgumentHelper.SplitArguments(input)); }
[Fact]
public void EmptyAndUnterminatedInput()
{
Assert.Empty(ArgumentHelper.SplitArguments("")); Assert.Empty(ArgumentHelper.SplitArguments(" \t\r\n"));
Assert.Throws<FormatException>(() => ArgumentHelper.SplitArguments("'open"));
}
}
55 changes: 55 additions & 0 deletions IPTables.Net.Tests/BinaryAdapterFailureTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using System;
using System.Linq;
using IPTables.Net.Exceptions;
using IPTables.Net.Iptables;
using IPTables.Net.Iptables.Adapter;

namespace IPTables.Net.Tests;
public class BinaryAdapterFailureTests
{
[Theory]
[InlineData(0)] [InlineData(1)] [InlineData(2)] [InlineData(17)]
public void MutationsReportFailuresAndDispose(int exit)
{
foreach (var operation in new[] { "add", "replace", "delete", "flush" })
{
var os = new ScriptedSystem { Respond = (_, _) => new("", "detail", exit) };
var system = new IpTablesSystem(os, new IPTablesBinaryAdapter());
using var client = system.GetTableAdapter(4);
var rule = IpTablesRule.Parse("-A INPUT -j ACCEPT", system, new IpTablesChainSet(4), 4);
rule.Chain.AddRule(rule);
Action action = operation switch {
"add" => () => client.AddRule(rule), "replace" => () => client.ReplaceRule(rule),
"delete" => () => client.DeleteRule(rule), _ => () => client.DeleteChain("filter", "custom", true)
};
if (exit == 0) action();
else Assert.Contains("detail", Assert.Throws<IpTablesNetException>(action).Message);
Assert.All(os.Calls, call => Assert.True(call.Process.IsDisposed));
if (operation == "flush")
Assert.Equal(exit == 0 ? new[] { "-t filter -F custom", "-t filter -X custom" } : new[] { "-t filter -F custom" }, os.Calls.Select(c => c.Arguments));
}
}
[Theory]
[InlineData("", "denied", 1)] [InlineData("*filter\nCOMMIT", "denied", 1)]
[InlineData("", "denied", 0)] [InlineData("", "", 2)]
public void FailedListingsAreNeverAccepted(string output, string error, int exit)
{
var os = new ScriptedSystem { Respond = (_, _) => new(output, error, exit) };
using var client = new IpTablesSystem(os, new IPTablesBinaryAdapter()).GetTableAdapter(4);
Assert.Throws<IpTablesNetException>(() => client.ListRules("filter"));
Assert.True(Assert.Single(os.Calls).Process.IsDisposed);
}
[Fact]
public void HasChainOnlySuppressesMissingChain()
{
var os = new ScriptedSystem();
using var client = new IpTablesSystem(os, new IPTablesBinaryAdapter()).GetTableAdapter(4);
Assert.True(client.HasChain("filter", "custom"));
os.Respond = (_, _) => new("", "No chain/target/match by that name.", 1);
Assert.False(client.HasChain("filter", "custom"));
os.Respond = (_, _) => new("", "Permission denied", 1);
Assert.Throws<IpTablesNetException>(() => client.HasChain("filter", "custom"));
os.Respond = (_, _) => throw new InvalidOperationException("start failed");
Assert.Throws<InvalidOperationException>(() => client.AddChain("filter", "custom"));
}
}
41 changes: 41 additions & 0 deletions IPTables.Net.Tests/CapabilityDetectionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System;
using IPTables.Net.Exceptions;
using IPTables.Net.Iptables.Adapter.Client;
using IPTables.Net.Iptables.Helpers;
namespace IPTables.Net.Tests;
public class CapabilityDetectionTests
{
private static IPTablesBinaryAdapterClient Client(ScriptedSystem fake) => new(4, new IpTablesSystem(fake, new RecordingAdapter()), "iptables");
[Theory]
[InlineData("iptables v1.4.20", false)] [InlineData("iptables v1.4.21", true)] [InlineData("iptables v1.4.22", true)]
[InlineData("iptables v1.8.11 (legacy)\n", true)] [InlineData(" \tip6tables v1.8.10 (nf_tables)\n", true)]
public void IptablesVersionsAndSynproxyBoundary(string output, bool supported)
{
var fake = new ScriptedSystem { Respond = (_, _) => new(output) }; using var client = Client(fake);
Assert.Equal(supported, SynProxyHelper.IptablesSupported(client));
Assert.Equal("-V", Assert.Single(fake.Calls).Arguments); Assert.True(fake.Calls[0].Process.IsDisposed);
}
[Theory]
[InlineData("")] [InlineData("iptables version unknown")] [InlineData("error iptables v1.8.0")]
[InlineData("iptables v1.8.0junk")] [InlineData("iptables v9999999999999.8.0")]
public void MalformedBinaryVersionsAreErrors(string output)
{ using var client = Client(new ScriptedSystem { Respond = (_, _) => new(output) }); Assert.Throws<IpTablesNetException>(() => client.GetIptablesVersion()); }
[Theory]
[InlineData("3.11.99", false)] [InlineData("3.12", true)] [InlineData("3.12.0", true)]
[InlineData("3.12.0-1-generic", true)] [InlineData("6.12.4-custom+debug\n", true)]
[InlineData("6.12.4+", true)] [InlineData("3.11.0-999", false)] [InlineData("6.8.0-1018-azure", true)]
[InlineData("invalid", false)] [InlineData("version 6.12.0", false)] [InlineData("99999999999999.1.0", false)]
public void KernelVersionEligibilitySupportsDistroAndCustomReleases(string release, bool expected)
{
var fake = new ScriptedSystem { Respond = (_, _) => new(release) }; Assert.Equal(expected, SynProxyHelper.KernelSupported(fake));
Assert.Equal("uname", Assert.Single(fake.Calls).Binary); Assert.Equal("-r", fake.Calls[0].Arguments);
}
[Fact]
public void NonzeroExitIsNeverInterpretedAsSupported()
{
var fake = new ScriptedSystem { Respond = (_, _) => new("iptables v1.8.0", "permission denied", 2) };
using var client = Client(fake); Assert.Throws<IpTablesNetException>(() => client.GetIptablesVersion());
Assert.Contains("permission denied", Assert.Throws<IpTablesNetException>(() => SynProxyHelper.KernelSupported(fake)).Message);
Assert.All(fake.Calls, x => Assert.True(x.Process.IsDisposed));
}
}
30 changes: 30 additions & 0 deletions IPTables.Net.Tests/CommandParserErrorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using IPTables.Net.Exceptions;
using IPTables.Net.Iptables;
namespace IPTables.Net.Tests;
public class CommandParserErrorTests
{
[Theory]
[InlineData("")] [InlineData(" \t ")] [InlineData("-A")] [InlineData("-A INPUT -m")]
[InlineData("-A INPUT -j")] [InlineData("-A INPUT -t")] [InlineData("-A INPUT -m tcp --dport")]
[InlineData("-A INPUT --unknown")]
[InlineData("-A INPUT ! ! -p tcp")] [InlineData("-A INPUT !")]
[InlineData("-A INPUT -m tcp --dport nope")] [InlineData("-A INPUT -m comment --comment 'bad")]
[InlineData("-R INPUT")] [InlineData("-D INPUT")]
[InlineData("-R INPUT 0 -j ACCEPT")] [InlineData("-R INPUT -1 -j ACCEPT")]
[InlineData("-R INPUT 4294967295 -j ACCEPT")]
public void InvalidCommandsHaveContextAndDoNotMutateChains(string input)
{
var chains = new IpTablesChainSet(4);
Assert.Contains(input, Assert.Throws<IpTablesParserException>(() => IpTablesCommand.Parse(input, null, chains)).Message);
Assert.Empty(chains.Chains);
}
[Fact]
public void UnknownModulesRemainSupportedThroughPolyfill()
{ RuleParseAssert.RoundTrips("-A INPUT -m missing"); }

[Theory]
[InlineData("-D INPUT 1", 0)] [InlineData("-R INPUT 2 -j ACCEPT", 1)]
[InlineData("-I INPUT -j ACCEPT", -1)] [InlineData("-I INPUT", -1)]
public void ValidOffsets(string input, int offset)
{ Assert.Equal(offset, IpTablesCommand.Parse(input, null, new IpTablesChainSet(4)).Offset); }
}
Loading
Loading