diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 783622c..07b596b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -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 @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 2e2568e..535efb8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/IPTables.Net.TestFramework/MockIptablesSystemProcess.cs b/IPTables.Net.TestFramework/MockIptablesSystemProcess.cs index 33b0e94..f35d834 100644 --- a/IPTables.Net.TestFramework/MockIptablesSystemProcess.cs +++ b/IPTables.Net.TestFramework/MockIptablesSystemProcess.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Diagnostics; using System.Globalization; using System.IO; @@ -14,8 +14,10 @@ public class MockIptablesSystemProcess : ISystemProcess 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) { @@ -153,9 +155,10 @@ public ProcessStartInfo StartInfo public event DataReceivedEventHandler OutputDataReceived; public event DataReceivedEventHandler ErrorDataReceived; public event EventHandler Exited; + public bool IsDisposed { get; private set; } public void Dispose() { - + IsDisposed = true; } } } diff --git a/IPTables.Net.Tests/AddressPortBoundaryTests.cs b/IPTables.Net.Tests/AddressPortBoundaryTests.cs new file mode 100644 index 0000000..5e596dc --- /dev/null +++ b/IPTables.Net.Tests/AddressPortBoundaryTests.cs @@ -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(() => 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(() => 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(() => 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(() => 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); + } +} diff --git a/IPTables.Net.Tests/ArgumentHelperTests.cs b/IPTables.Net.Tests/ArgumentHelperTests.cs new file mode 100644 index 0000000..72e2ab0 --- /dev/null +++ b/IPTables.Net.Tests/ArgumentHelperTests.cs @@ -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(() => ArgumentHelper.SplitArguments("'open")); + } +} diff --git a/IPTables.Net.Tests/BinaryAdapterFailureTests.cs b/IPTables.Net.Tests/BinaryAdapterFailureTests.cs new file mode 100644 index 0000000..2c286d7 --- /dev/null +++ b/IPTables.Net.Tests/BinaryAdapterFailureTests.cs @@ -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(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(() => 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(() => client.HasChain("filter", "custom")); + os.Respond = (_, _) => throw new InvalidOperationException("start failed"); + Assert.Throws(() => client.AddChain("filter", "custom")); + } +} diff --git a/IPTables.Net.Tests/CapabilityDetectionTests.cs b/IPTables.Net.Tests/CapabilityDetectionTests.cs new file mode 100644 index 0000000..bd17bfd --- /dev/null +++ b/IPTables.Net.Tests/CapabilityDetectionTests.cs @@ -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(() => 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(() => client.GetIptablesVersion()); + Assert.Contains("permission denied", Assert.Throws(() => SynProxyHelper.KernelSupported(fake)).Message); + Assert.All(fake.Calls, x => Assert.True(x.Process.IsDisposed)); + } +} diff --git a/IPTables.Net.Tests/CommandParserErrorTests.cs b/IPTables.Net.Tests/CommandParserErrorTests.cs new file mode 100644 index 0000000..5b39007 --- /dev/null +++ b/IPTables.Net.Tests/CommandParserErrorTests.cs @@ -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(() => 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); } +} diff --git a/IPTables.Net.Tests/ConntrackBehaviorTests.cs b/IPTables.Net.Tests/ConntrackBehaviorTests.cs new file mode 100644 index 0000000..ed12307 --- /dev/null +++ b/IPTables.Net.Tests/ConntrackBehaviorTests.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using IPTables.Net.Conntrack; +using IPTables.Net.Exceptions; +namespace IPTables.Net.Tests; +public class ConntrackBehaviorTests +{ + private sealed class Native : ConntrackApi + { + public int Result, Family, Frees, Clears, Constants, MarkClears; + public bool Expectations, Found = true; + public uint MarkValue, Mask; + private readonly List nodes = new(); + internal override int Constant(string key) { Constants++; return key == "known" ? 123 : -1; } + internal override void Filter(int family, ConntrackQueryFilter[] filters) { Family = family; } + internal override void ClearFilter() { Clears++; } + internal override int Dump(bool expectations, ref ConntrackHelper.CrImg image) + { + Expectations = expectations; + for (int i = 0; i < 2; i++) + { + var ptr = Marshal.AllocHGlobal(IntPtr.Size + 20); + nodes.Add(ptr); + Marshal.WriteIntPtr(ptr, image.CrNode); + var bytes = new byte[20]; bytes[0] = 20; bytes[19] = (byte)i; + Marshal.Copy(bytes, 0, IntPtr.Add(ptr, IntPtr.Size), 20); + image.CrNode = ptr; + } + return Result; + } + internal override int Length(IntPtr node) => IntPtr.Size + 20; + internal override void Free(ref ConntrackHelper.CrImg image) + { foreach (var node in nodes) Marshal.FreeHGlobal(node); nodes.Clear(); image.CrNode = IntPtr.Zero; Frees++; } + internal override void Mark(uint mark, uint mask) { MarkValue = mark; Mask = mask; } + internal override void ClearMark() { MarkClears++; } + internal override int Restore(bool expectations, byte[] data) { Expectations = expectations; return Result; } + internal override bool Extract(ConntrackQueryFilter[] filters, byte[] data, IntPtr output, int size) + { Marshal.WriteInt32(output, 123); return Found; } + } + [Theory] + [InlineData(AddressFamily.Unspecified, 0)] + [InlineData(AddressFamily.InterNetwork, 2)] + [InlineData(AddressFamily.InterNetworkV6, 10)] + public void DumpOwnsEachBufferAndMapsLinuxFamily(AddressFamily family, int expected) + { + var native = new Native(); var system = new ConntrackSystem(native); var records = new List(); + system.Dump(true, records.Add, addressFamily: family); + Assert.Equal(expected, native.Family); Assert.True(native.Expectations); + Assert.Equal(new byte[] { 1, 0 }, records.ConvertAll(x => x[19])); + Assert.NotSame(records[0], records[1]); Assert.Equal(1, native.Frees); Assert.Equal(1, native.Clears); + } + [Fact] + public void CallbackAndNativeFailuresReleaseStateAndAllowRetry() + { + var native = new Native(); var system = new ConntrackSystem(native); + Assert.Throws(() => system.Dump(false, _ => throw new InvalidOperationException())); + native.Result = -22; + Assert.Throws(() => system.Dump(false, _ => Assert.Fail("Unexpected callback"))); + native.Result = 0; system.Dump(false, _ => { }); + Assert.Equal(3, native.Frees); Assert.Equal(3, native.Clears); + } + [Theory] + [InlineData(0)] [InlineData(3)] [InlineData(-22)] + public void RestoreAlwaysClearsOverride(int result) + { + var native = new Native { Result = result }; var system = new ConntrackSystem(native); + if (result < 0) Assert.Throws(() => system.Restore(true, Array.Empty(), 7, 255)); + else Assert.Equal(result, system.Restore(true, Array.Empty(), 7, 255)); + Assert.True(native.Expectations); Assert.Equal(7u, native.MarkValue); Assert.Equal(255u, native.Mask); Assert.Equal(1, native.MarkClears); + } + [Fact] + public void ConstantsAndExtractionHaveExplicitMissingContracts() + { + var native = new Native(); var system = new ConntrackSystem(native); + Assert.Equal(123, system.GetConstant("known")); Assert.Equal(123, system.GetConstant("known")); Assert.Equal(1, native.Constants); + Assert.Throws(() => system.GetConstant("unknown")); + var data = new byte[20]; data[0] = 20; + Assert.True(system.ExtractField(Array.Empty(), data, out var value)); Assert.Equal(123, value); + native.Found = false; + Assert.False(system.ExtractField(Array.Empty(), data, out value)); Assert.Equal(0, value); + Assert.Throws(() => system.ExtractField(Array.Empty(), new byte[4], out _)); + data[0] = 21; + Assert.Throws(() => system.ExtractField(Array.Empty(), data, out _)); + } +} diff --git a/IPTables.Net.Tests/ConntrackLibraryTests.cs b/IPTables.Net.Tests/ConntrackLibraryTests.cs index 8ad5418..d2d93fa 100644 --- a/IPTables.Net.Tests/ConntrackLibraryTests.cs +++ b/IPTables.Net.Tests/ConntrackLibraryTests.cs @@ -1,8 +1,9 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Net; +using System.Net.Sockets; using System.Runtime.InteropServices; using System.Text; using IPTables.Net.Conntrack; @@ -11,6 +12,7 @@ namespace IPTables.Net.Tests { + [Collection(SystemIptablesCollectionDefinition.Name)] [Trait("Category", "NotWorkingOnTravis")] public class ConntrackLibraryTests { @@ -28,6 +30,48 @@ public void TestDump() ConntrackSystem cts = new ConntrackSystem(); List list = new List(); cts.Dump(false, list.Add); + Assert.All(list, record => Assert.Equal(record.Length, (int)BitConverter.ToUInt32(record, 0))); + Assert.Equal(list.Count, list.Distinct().Count()); + } + + [Fact] + public void SeededUdpFlowIsReturnedByMatchingFilter() + { + NativeFamilyTests.RequireFamily(4); + using var receiver = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + using var sender = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + var destination = (IPEndPoint)receiver.Client.LocalEndPoint; + var binary = IptablesSystemTestSupport.GetBinary(4); + var rule = $"OUTPUT -p udp --dport {destination.Port} -m conntrack --ctstate NEW -j ACCEPT"; + IptablesSystemTestSupport.RequireSuccess(binary, "-A " + rule); + try + { + sender.Send(new byte[] { 7 }, 1, destination); + var system = new ConntrackSystem(); + var address = GCHandle.Alloc(IPAddress.Loopback.GetAddressBytes(), GCHandleType.Pinned); + try + { + var filters = new[] + { + new ConntrackQueryFilter { Key = system.GetConstant("CTA_TUPLE_ORIG"), Max = system.GetConstant("CTA_TUPLE_MAX") }, + new ConntrackQueryFilter { Key = system.GetConstant("CTA_TUPLE_IP"), Max = system.GetConstant("CTA_IP_MAX") }, + new ConntrackQueryFilter { Key = system.GetConstant("CTA_IP_V4_DST"), CompareLength = 4, Compare = address.AddrOfPinnedObject() } + }; + var records = new List(); + system.Dump(false, records.Add, filters, AddressFamily.InterNetwork); + Assert.NotEmpty(records); + Assert.All(records, record => + { + Assert.True(system.ExtractField(filters, record, out var value)); + Assert.Equal(BitConverter.ToUInt32(IPAddress.Loopback.GetAddressBytes()), value); + }); + Marshal.WriteInt32(address.AddrOfPinnedObject(), BitConverter.ToInt32(IPAddress.Parse("192.0.2.254").GetAddressBytes())); + records.Clear(); system.Dump(false, records.Add, filters, AddressFamily.InterNetwork); + Assert.Empty(records); + } + finally { address.Free(); } + } + finally { IptablesSystemTestSupport.Execute(binary, "-D " + rule, false); } } [Fact] @@ -57,6 +101,11 @@ public void TestDumpFiltered() List list = new List(); cts.Dump(false, list.Add, qf); + Assert.All(list, record => + { + Assert.True(cts.ExtractField(qf, record, out var destination)); + Assert.Equal(addr32, destination); + }); } finally { diff --git a/IPTables.Net.Tests/DefaultIpuSyncTests.cs b/IPTables.Net.Tests/DefaultIpuSyncTests.cs new file mode 100644 index 0000000..e9be1bf --- /dev/null +++ b/IPTables.Net.Tests/DefaultIpuSyncTests.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using IPTables.Net.IpUtils.Sync; +using IPTables.Net.IpUtils.Utils; +namespace IPTables.Net.Tests; +public class DefaultIpuSyncTests +{ + private static IpObject Item(string key) => new() { Pairs = new() { ["to"] = key } }; + private sealed class Controller : IpController + { + public List State = new(); public List Log = new(); public string Fail; + public Controller() : base("route", null) { } + public override void Add(IpObject item) { Log.Add("add " + item.Pairs["to"]); if (Fail == "add") throw new InvalidOperationException("add"); State.Add(item); } + public override void Delete(IpObject item) { Log.Add("del " + item.Pairs["to"]); if (Fail == "del") throw new InvalidOperationException("del"); State.RemoveAll(x => x.Equals(item)); } + } + [Theory] + [InlineData("", "", "")] [InlineData("a", "a", "")] + [InlineData("a", "b", "del a,add b")] [InlineData("a,b", "b,c", "del a,add c")] + [InlineData("a,a,b", "a,a", "del b")] [InlineData("", "a,a", "add a")] + public void SetDifferencesAndRepeatAreIdempotent(string current, string desired, string expected) + { + static List Parse(string input) => input.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(Item).ToList(); + var controller = new Controller { State = Parse(current) }; var sync = new DefaultIpuSync(controller, () => controller.State); + sync.Sync(Parse(desired)); Assert.Equal(expected, string.Join(",", controller.Log)); + controller.Log.Clear(); sync.Sync(Parse(desired)); Assert.Empty(controller.Log); + Assert.True(new HashSet(Parse(desired)).SetEquals(controller.State)); + } + [Theory] + [InlineData("get", "", "a")] [InlineData("del", "del a", "a")] [InlineData("add", "del a,add b", "")] + public void FailuresPropagateWithoutPretendingToRollback(string fail, string expected, string state) + { + var controller = new Controller { State = new() { Item("a") }, Fail = fail }; + var sync = new DefaultIpuSync(controller, () => fail == "get" ? throw new InvalidOperationException("get") : controller.State); + Assert.Equal(fail, Assert.Throws(() => sync.Sync(new[] { Item("b") })).Message); + Assert.Equal(expected, string.Join(",", controller.Log)); Assert.Equal(state, string.Join(",", controller.State.Select(x => x.Pairs["to"]))); + } +} diff --git a/IPTables.Net.Tests/IPv6ManagedTests.cs b/IPTables.Net.Tests/IPv6ManagedTests.cs new file mode 100644 index 0000000..83fc318 --- /dev/null +++ b/IPTables.Net.Tests/IPv6ManagedTests.cs @@ -0,0 +1,56 @@ +using System.Linq; +using System.Net; +using IPTables.Net.Exceptions; +using IPTables.Net.IpSet; +using IPTables.Net.Iptables; +using IPTables.Net.Iptables.Adapter; +using IPTables.Net.Iptables.Adapter.Client.Helper; +using IPTables.Net.Iptables.DataTypes; +using IPTables.Net.Iptables.Modules.Core; +namespace IPTables.Net.Tests; +public class IPv6ManagedTests +{ + [Theory] + [InlineData("::/0")] [InlineData("::1")] [InlineData("2001:db8::/64")] [InlineData("2001:db8::1")] + public void CoreTcpAndUdpRoundTripWithVersionAndFields(string address) + { + foreach (var protocol in new[] { "tcp", "udp" }) + { + var text = $"-A INPUT -p {protocol} -s {address} -j ACCEPT -m {protocol} --dport 443"; + var rule = RuleParseAssert.RoundTrips(text, version: 6); + Assert.Equal(6, rule.IpVersion); Assert.Equal(IpCidr.Parse(address), rule.GetModule("core").Source.Value); + var saved = IPTablesSaveParser.GetRulesFromOutput(null, "*filter\n:INPUT ACCEPT [0:0]\n" + text + "\nCOMMIT", "filter", 6); + Assert.Equal(text, Assert.Single(saved.GetChain("INPUT", "filter").Rules).GetActionCommand()); + } + } + [Theory] + [InlineData("[2001:db8::1]:80-90")] [InlineData("[2001:db8::1]-[2001:db8::2]:443")] + [InlineData("2001:db8::1")] + public void NatRangesAndAddressFields(string range) + { + RuleParseAssert.RoundTrips("-A PREROUTING -t nat -j DNAT --to-destination " + range, version: 6); + var parsed = IPPortOrRange.Parse(range); Assert.Equal(IPAddress.Parse("2001:db8::1"), parsed.LowerAddress); + Assert.Equal(range, parsed.ToString()); + } + [Fact] + public void FamiliesAreValidatedAndFactoriesSelectIpv6Executables() + { + Assert.Throws(() => IpTablesRule.Parse("-A INPUT -s 192.0.2.1", null, new IpTablesChainSet(6), 6)); + var os = new ScriptedSystem { Respond = (_, _) => new("*filter\nCOMMIT") }; + using var binary = new IpTablesSystem(os, new IPTablesBinaryAdapter()).GetTableAdapter(6); + binary.AddRule("-A INPUT -j ACCEPT"); binary.ListRules("filter"); + using var restore = new IpTablesSystem(os, new IPTablesRestoreAdapter()).GetTableAdapter(6); + restore.ListRules("filter"); restore.StartTransaction(); restore.AddRule("-A INPUT -j ACCEPT"); restore.EndTransactionCommit(); + Assert.Equal(new[] { "ip6tables", "ip6tables-save", "ip6tables-save", "ip6tables-restore" }, os.Calls.Select(c => c.Binary)); + } + [Fact] + public void Ipv6SetTuplesConvergeWithoutCommands() + { + const string saved = "create demo hash:ip,port,ip family inet6\nadd demo 2001:db8::1,tcp:443,2001:db8::2\n"; + var os = new ScriptedSystem { Respond = (_, _) => new(saved) }; + var system = new IpTablesSystem(os, null); var sets = new IpSetSets(saved.Split('\n').Where(x => x.Length != 0), system); + Assert.Equal("inet6", sets.Sets.Single().Family); + Assert.Equal(IPAddress.Parse("2001:db8::2"), sets.Sets.Single().Entries.Single().Cidr2.Address); + sets.Sync(); Assert.Single(os.Calls); Assert.Equal("save", os.Calls[0].Arguments); + } +} diff --git a/IPTables.Net.Tests/IpControllerBoundaryTests.cs b/IPTables.Net.Tests/IpControllerBoundaryTests.cs new file mode 100644 index 0000000..6cc3fc2 --- /dev/null +++ b/IPTables.Net.Tests/IpControllerBoundaryTests.cs @@ -0,0 +1,48 @@ +using System; +using IPTables.Net.Exceptions; +using IPTables.Net.IpUtils.Utils; +namespace IPTables.Net.Tests; +public class IpControllerBoundaryTests +{ + [Theory] + [InlineData(null, false)] [InlineData("blue", true)] [InlineData("default", false)] [InlineData("all", false)] + public void ListingAddsOnlyAnExplicitTableWithoutOverwritingOutput(string table, bool added) + { + var fake = new ScriptedSystem { Respond = (_, _) => new("default via 192.0.2.1 dev eth0\n192.0.2.0/24 dev eth1 table green\n") }; + var routes = new IpRouteController(fake).GetAll(table); + Assert.Equal(2, routes.Count); Assert.Equal(added, routes[0].Pairs.ContainsKey("table")); + if (added) Assert.Equal(table, routes[0].Pairs["table"]); + Assert.Equal("green", routes[1].Pairs["table"]); + Assert.Equal(table == null ? "route show" : "route show table " + table, Assert.Single(fake.Calls).Arguments); + } + [Fact] + public void EmptyListingsAndMulticastParsing() + { + var routes = new IpRouteController(new ScriptedSystem()); Assert.Empty(routes.GetAll()); + Assert.Empty(new IpRuleController(new ScriptedSystem()).GetAll()); + var route = routes.ParseObject("multicast 224.0.0.0/4 dev eth0 onlink"); + Assert.Equal("224.0.0.0/4", route.Pairs["multicast"]); Assert.Contains("onlink", route.Singles); + Assert.Equal("multicast 224.0.0.0/4 dev eth0 onlink", string.Join(" ", routes.ExportObject(route))); + } + [Theory] + [InlineData(false, "default dev")] [InlineData(false, "onlink")] + [InlineData(false, "default dev eth0 dev eth1")] [InlineData(true, "bad: from all")] + [InlineData(true, "100: from")] [InlineData(true, "not")] + public void MalformedListingIncludesOffendingLine(bool rule, string line) + { + var fake = new ScriptedSystem { Respond = (_, _) => new(line) }; + var error = Assert.Throws(() => { if (rule) new IpRuleController(fake).GetAll(); else new IpRouteController(fake).GetAll(); }); + Assert.Contains(line, error.Message); Assert.NotNull(error.InnerException); + } + [Theory] + [InlineData(false, "", "")] [InlineData(true, "", "denied")] [InlineData(false, "failed", "")] + public void AllOperationsRejectNonzeroExit(bool rule, string output, string error) + { + var fake = new ScriptedSystem { Respond = (_, _) => new(output, error, 2) }; + IpController controller = rule ? new IpRuleController(fake) : new IpRouteController(fake); + Assert.Contains("exited with 2", Assert.Throws(() => controller.Add("default")).Message); + Assert.Throws(() => controller.Delete("default")); + Assert.Throws(() => { if (rule) ((IpRuleController)controller).GetAll(); else ((IpRouteController)controller).GetAll(); }); + Assert.All(fake.Calls, call => Assert.True(call.Process.IsDisposed)); + } +} diff --git a/IPTables.Net.Tests/IpObjectTests.cs b/IPTables.Net.Tests/IpObjectTests.cs new file mode 100644 index 0000000..f6ccc1a --- /dev/null +++ b/IPTables.Net.Tests/IpObjectTests.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using IPTables.Net.IpUtils.Utils; +namespace IPTables.Net.Tests; +public class IpObjectTests +{ + [Fact] + public void IdentityIgnoresInsertionOrderAndIncludesValuesAndFlags() + { + var a = new IpObject { Pairs = new() { ["to"] = "default", ["dev"] = "eth0" }, Singles = new() { "onlink", "linkdown" } }; + var b = new IpObject { Pairs = new() { ["dev"] = "eth0", ["to"] = "default" }, Singles = new() { "linkdown", "onlink" } }; + Assert.Equal(a, b); Assert.Equal(a.GetHashCode(), b.GetHashCode()); Assert.Contains(b, new HashSet { a }); + b.Pairs["dev"] = "eth1"; Assert.NotEqual(a, b); + b.Pairs["dev"] = "eth0"; b.Singles.Remove("onlink"); Assert.NotEqual(a, b); Assert.False(a.Equals(null)); + } + [Fact] + public void CloneOwnsBothCollections() + { + var source = new IpObject { Pairs = new() { ["dev"] = "eth0" }, Singles = new() { "onlink" } }; var clone = source.Clone(); + Assert.Equal(source, clone); Assert.NotSame(source.Pairs, clone.Pairs); Assert.NotSame(source.Singles, clone.Singles); + clone.Pairs["dev"] = "eth1"; clone.Singles.Clear(); + Assert.Equal("eth0", source.Pairs["dev"]); Assert.Contains("onlink", source.Singles); + } +} diff --git a/IPTables.Net.Tests/IpSetAdapterTests.cs b/IPTables.Net.Tests/IpSetAdapterTests.cs new file mode 100644 index 0000000..0a36a81 --- /dev/null +++ b/IPTables.Net.Tests/IpSetAdapterTests.cs @@ -0,0 +1,51 @@ +using System; +using System.Linq; +using IPTables.Net.Exceptions; +using IPTables.Net.IpSet; +using IPTables.Net.IpSet.Adapter; +namespace IPTables.Net.Tests; +public class IpSetAdapterTests +{ + [Theory] + [InlineData(0, "", true)] [InlineData(1, "", false)] [InlineData(2, "denied", false)] + public void RestoreSuccessMeansZeroExitAndIncludesFullCommands(int exit, string error, bool success) + { + var os = new ScriptedSystem { Respond = (_, _) => new("", error, exit) }; + var adapter = new IpSetBinaryAdapter(os); + var sets = new IpSetSets(new[] { "create demo hash:ip", "add demo 192.0.2.1 timeout 12" }, null); + if (error.Length == 0) Assert.Equal(success, adapter.RestoreSets(sets.Sets)); + else Assert.Throws(() => adapter.RestoreSets(sets.Sets)); + var call = Assert.Single(os.Calls); + Assert.StartsWith("create demo hash:ip", call.Text); + Assert.Contains("add demo 192.0.2.1 timeout 12", call.Text); + Assert.False(call.Input.CanWrite); Assert.True(call.Process.IsDisposed); + } + [Fact] + public void TransactionLifecycleAndFailureRecovery() + { + var os = new ScriptedSystem(); var adapter = new IpSetBinaryAdapter(os); + adapter.StartTransaction(); Assert.Throws(() => adapter.StartTransaction()); + Assert.True(adapter.EndTransactionCommit()); Assert.Empty(os.Calls); + adapter.StartTransaction(); adapter.DestroySet("old"); adapter.EndTransactionRollback(); + adapter.StartTransaction(); adapter.DestroySet("new"); adapter.EndTransactionCommit(); + Assert.Equal("destroy new\n", os.Calls.Last().Text.Replace("\r", "")); + os.Respond = (_, _) => new("", "failure", 1); + adapter.StartTransaction(); adapter.DestroySet("bad"); + Assert.Throws(() => adapter.EndTransactionCommit()); Assert.False(adapter.InTransaction); + os.Respond = (_, _) => new(); + adapter.StartTransaction(); adapter.DestroySet("last"); adapter.EndTransactionCommit(); + Assert.DoesNotContain("bad", os.Calls.Last().Text); + } + [Fact] + public void ImmediateFailuresAndFailedSavesAreReported() + { + var os = new ScriptedSystem { Respond = (_, _) => new("create partial hash:ip", "failure", 1) }; + var adapter = new IpSetBinaryAdapter(os); + var set = new IpSetSets(new[] { "create demo hash:ip", "add demo 192.0.2.1" }, null).Sets.Single(); + foreach (Action call in new Action[] { () => adapter.CreateSet(set), () => adapter.DestroySet("demo"), + () => adapter.AddEntry(set.Entries.Single()), () => adapter.DeleteEntry(set.Entries.Single()), () => adapter.SwapSet("a", "b") }) + Assert.Throws(call); + var target = new IpSetSets(null); Assert.Throws(() => adapter.SaveSets(target)); Assert.Empty(target.Sets); + Assert.All(os.Calls, c => Assert.True(c.Process.IsDisposed)); + } +} diff --git a/IPTables.Net.Tests/IpSetEntryIdentityTests.cs b/IPTables.Net.Tests/IpSetEntryIdentityTests.cs new file mode 100644 index 0000000..c8d1b17 --- /dev/null +++ b/IPTables.Net.Tests/IpSetEntryIdentityTests.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using System.Linq; +using IPTables.Net.IpSet; +using IPTables.Net.Iptables.DataTypes; +namespace IPTables.Net.Tests; +public class IpSetEntryIdentityTests +{ + [Fact] + public void TimeoutDoesNotChangeKeyMembership() + { + var set = new IpSetSets(new[] { "create demo hash:ip", "add demo 192.0.2.1 timeout 10", "add demo 192.0.2.1 timeout 20" }, null).Sets.Single(); + var entry = Assert.Single(set.Entries); + var equivalent = IpSetEntry.ParseFromParts(set, "192.0.2.1"); equivalent.Timeout = 99; + var comparer = IpSetEntryKeyComparer.Instance; + Assert.True(comparer.Equals(entry, equivalent)); Assert.Equal(comparer.GetHashCode(entry), comparer.GetHashCode(equivalent)); + Assert.True(set.Entries.Contains(equivalent)); + var map = new Dictionary(comparer) { [entry] = 7 }; Assert.Equal(7, map[equivalent]); + entry.Timeout = 30; Assert.True(set.Entries.Remove(equivalent)); Assert.Empty(set.Entries); + } + [Theory] + [InlineData("192.0.2.1,tcp:80,198.51.100.2")] + [InlineData("192.0.2.1,udp:80,198.51.100.1")] + [InlineData("192.0.2.1,tcp:81,198.51.100.1")] + [InlineData("192.0.2.2,tcp:80,198.51.100.1")] + public void EveryTupleComponentParticipatesInIdentity(string other) + { + var set = new IpSetSets(new[] { "create demo hash:ip,port,ip" }, null).Sets.Single(); + var first = IpSetEntry.ParseFromParts(set, "192.0.2.1,tcp:80,198.51.100.1"); + var second = IpSetEntry.ParseFromParts(set, other); + Assert.False(IpSetEntryKeyComparer.Instance.Equals(first, second)); + var entries = new HashSet(IpSetEntryKeyComparer.Instance) { first, second }; Assert.Equal(2, entries.Count); + } + [Fact] + public void ParsedAndConstructedCollectionsUseTheSameKeyRules() + { + var parsed = new IpSetSets(new[] { "create demo hash:ip", "add demo 192.0.2.1" }, null).Sets.Single(); + var constructed = new IpSetSet(parsed.Type, parsed.Name, 0, "inet", null, IpSetSyncMode.SetAndEntries); + var one = new IpSetEntry(constructed, IpCidr.Parse("192.0.2.1")); + constructed.Entries.Add(one); + Assert.True(parsed.Entries.Contains(one)); + Assert.False(constructed.Entries.Add(IpSetEntry.ParseFromParts(constructed, "192.0.2.1"))); + var duplicate = IpSetEntry.ParseFromParts(constructed, "192.0.2.1"); + Assert.Equal(one, duplicate); Assert.Equal(one.GetHashCode(), duplicate.GetHashCode()); + } +} diff --git a/IPTables.Net.Tests/IpSetModeTests.cs b/IPTables.Net.Tests/IpSetModeTests.cs new file mode 100644 index 0000000..51c4e2a --- /dev/null +++ b/IPTables.Net.Tests/IpSetModeTests.cs @@ -0,0 +1,94 @@ +using System; +using System.Linq; +using IPTables.Net.Exceptions; +using IPTables.Net.IpSet; +namespace IPTables.Net.Tests; +public class IpSetModeTests +{ + [Theory] + [InlineData(0u, 0u, true)] + [InlineData(16u, 0u, true)] + [InlineData(16u, 16u, true)] + [InlineData(0u, 32u, false)] + [InlineData(16u, 32u, false)] + public void SetComparisonHonorsOnlyExplicitDesiredSeeds(uint currentSeed, uint desiredSeed, bool equal) + { + var current = IpSetSet.Parse($"demo hash:ip initval {currentSeed}", null); + var desired = IpSetSet.Parse($"demo hash:ip initval {desiredSeed}", null); + Assert.Equal(equal, current.SetEquals(desired)); + Assert.Equal(equal, current.SetEquals(desired, size: false)); + } + + [Theory] + [InlineData(0u, 0u, false)] + [InlineData(16u, 0u, false)] + [InlineData(16u, 16u, false)] + [InlineData(0u, 32u, true)] + [InlineData(16u, 32u, true)] + public void SeedChangesReplaceExistingSetsAndConverge(uint currentSeed, uint desiredSeed, bool replace) + { + var saved = $"create demo hash:ip initval {currentSeed}\nadd demo 192.0.2.1\n"; + var os = new ScriptedSystem { Respond = (_, args) => new(args == "save" ? saved : "") }; + var system = new IpTablesSystem(os, null); + var desired = new IpSetSets(new[] { $"create demo hash:ip initval {desiredSeed}", "add demo 192.0.2.1" }, system); + + desired.Sync(); + + if (replace) + { + var restore = Assert.Single(os.Calls, call => call.Arguments == "restore"); + Assert.Equal($"create demo_S hash:ip family inet hashsize 1024 maxelem 65536 initval {desiredSeed}\n" + + "swap demo_S demo\ndestroy demo_S\nadd demo 192.0.2.1\n", restore.Text.Replace("\r", "")); + saved = $"create demo hash:ip initval {desiredSeed}\nadd demo 192.0.2.1\n"; + } + else + Assert.Equal("save", Assert.Single(os.Calls).Arguments); + + os.Calls.Clear(); + desired.Sync(); + Assert.Equal("save", Assert.Single(os.Calls).Arguments); + Assert.False(system.SetAdapter.InTransaction); + } + + [Theory] + [InlineData(IpSetSyncMode.SetOnly, false, false)] [InlineData(IpSetSyncMode.SetOnly, true, false)] + [InlineData(IpSetSyncMode.SetAndEntries, false, true)] [InlineData(IpSetSyncMode.SetAndEntries, true, true)] + [InlineData(IpSetSyncMode.SetAndEntriesOnCreate, false, true)] [InlineData(IpSetSyncMode.SetAndEntriesOnCreate, true, false)] + public void ModesControlEntryPopulation(IpSetSyncMode mode, bool exists, bool populate) + { + var os = new ScriptedSystem { Respond = (_, a) => new(a == "save" && exists ? "create demo hash:ip\n" : "") }; + var system = new IpTablesSystem(os, null); + var desired = new IpSetSets(new[] { "create demo hash:ip", "add demo 192.0.2.1" }, system); + desired.Sets.Single().SyncMode = mode; desired.Sync(); + var text = string.Join("", os.Calls.Select(c => c.Text)); + Assert.Equal(populate, text.Contains("add demo 192.0.2.1")); + Assert.Equal(!exists, text.Contains("create demo")); + Assert.False(system.SetAdapter.InTransaction); + } + [Theory] + [InlineData(0)] [InlineData(1)] [InlineData(2)] + public void DeletionRequiresExplicitPredicate(int policy) + { + var os = new ScriptedSystem { Respond = (_, a) => new(a == "save" ? "create old hash:ip\ncreate foreign hash:ip\n" : "") }; + var system = new IpTablesSystem(os, null); + new IpSetSets(system).Sync(policy == 0 ? null : s => policy == 2 && s.Name == "old"); + var text = string.Join("", os.Calls.Select(c => c.Text)); + Assert.Equal(policy == 2, text.Contains("destroy old")); Assert.DoesNotContain("destroy foreign", text); + } + [Fact] + public void ReplacementPreservesSetOnlyEntriesAndRejectsCollisionsAndFamilyChanges() + { + var saved = "create demo hash:ip\nadd demo 192.0.2.1 timeout 10\n"; + var os = new ScriptedSystem { Respond = (_, a) => new(a == "save" ? saved : "") }; + var system = new IpTablesSystem(os, null); + var desired = new IpSetSets(new[] { "create demo hash:ip hashsize 2048" }, system); + desired.Sets.Single().SyncMode = IpSetSyncMode.SetOnly; + desired.Sync(); + var text = os.Calls.Last().Text; + Assert.True(text.IndexOf("add demo_S 192.0.2.1 timeout 10", StringComparison.Ordinal) < text.IndexOf("swap demo_S demo", StringComparison.Ordinal)); + saved += "create demo_S hash:ip\n"; + Assert.Throws(() => desired.Sync()); Assert.False(system.SetAdapter.InTransaction); + saved = "create demo hash:ip\n"; desired.Sets.Single().Family = "inet6"; + Assert.Throws(() => desired.Sync()); Assert.False(system.SetAdapter.InTransaction); + } +} diff --git a/IPTables.Net.Tests/IpSetParserFidelityTests.cs b/IPTables.Net.Tests/IpSetParserFidelityTests.cs new file mode 100644 index 0000000..b5893eb --- /dev/null +++ b/IPTables.Net.Tests/IpSetParserFidelityTests.cs @@ -0,0 +1,35 @@ +using System; +using System.Linq; +using IPTables.Net.Exceptions; +using IPTables.Net.IpSet; +namespace IPTables.Net.Tests; +public class IpSetParserFidelityTests +{ + [Fact] + public void SetAndEntryMetadataSurviveRoundTrip() + { + var set = IpSetSet.Parse("demo hash:ip,port,ip bucketsize 8 initval 0x10 timeout 60 counters", null); + var copy = IpSetSet.Parse(set.GetCommand(), null); + Assert.Equal(8, copy.BucketSize); Assert.Equal(16u, copy.InitVal); Assert.Equal(60, copy.Timeout); Assert.Contains("counters", copy.CreateOptions); + var sets = new IpSetSets(null); sets.AddSet(copy); + var entry = IpSetEntry.Parse("demo 192.0.2.1,TCP:80,198.51.100.1 timeout 10 packets 7 bytes 900", sets); + Assert.Equal("192.0.2.1,tcp:80,198.51.100.1", entry.GetKeyCommand()); + Assert.Equal("add demo 192.0.2.1,tcp:80,198.51.100.1 timeout 10", Assert.Single(copy.GetEntryCommands())); + } + [Theory] + [InlineData("demo")] [InlineData("unknown 192.0.2.1,tcp:80")] + [InlineData("demo 192.0.2.1")] [InlineData("demo 192.0.2.1,tcp:80,extra")] + [InlineData("demo 192.0.2.1,tcp:99999")] [InlineData("demo 192.0.2.1,tcp:80 timeout")] + [InlineData("demo 192.0.2.1,tcp:80 packets bad")] [InlineData("demo 192.0.2.1,tcp:80 unexpected")] + public void InvalidEntriesNeverLeavePartialMembers(string command) + { + var sets = new IpSetSets(new[] { "create demo hash:ip,port" }, null); + Assert.Throws(() => IpSetEntry.Parse(command, sets)); + Assert.Empty(sets.Sets.Single().Entries); + } + [Theory] + [InlineData("demo")] [InlineData("demo hash")] + [InlineData("demo hash:ip timeout")] [InlineData("demo hash:ip,mac")] + public void IncompleteAndUnsupportedSetTypesAreRejected(string command) + { Assert.Throws(() => IpSetSet.Parse(command, null)); } +} diff --git a/IPTables.Net.Tests/IpUtilsRouteTests.cs b/IPTables.Net.Tests/IpUtilsRouteTests.cs index aea0921..0a72ce8 100644 --- a/IPTables.Net.Tests/IpUtilsRouteTests.cs +++ b/IPTables.Net.Tests/IpUtilsRouteTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; @@ -20,6 +20,9 @@ public void TestParseRule() var ipUtils = new IpRouteController(systemFactory); var one = ipUtils.ParseObjectInternal("default via 199.19.225.1 dev eth0", "to"); var two = ipUtils.ParseObjectInternal("10.128.1.0/24 dev tap0 proto kernel scope link src 10.128.1.201", "to"); + Assert.Equal("default", one.Pairs["to"]); Assert.Equal("199.19.225.1", one.Pairs["via"]); + Assert.Equal("eth0", one.Pairs["dev"]); Assert.Equal("10.128.1.0/24", two.Pairs["to"]); + Assert.Equal("tap0", two.Pairs["dev"]); Assert.Equal("10.128.1.201", two.Pairs["src"]); } [Fact] @@ -28,7 +31,7 @@ public void TestParseRuleLocal() var systemFactory = new MockIptablesSystemFactory(); var ipUtils = new IpRouteController(systemFactory); var one = ipUtils.ParseObjectInternal("local default dev lo table 100 scope host", "to"); - ipUtils.ExportObject(one); + Assert.Equal("local default dev lo table 100 scope host", string.Join(" ", ipUtils.ExportObject(one))); } [Fact] public void TestParseRuleAnycastV6() @@ -36,7 +39,7 @@ public void TestParseRuleAnycastV6() var systemFactory = new MockIptablesSystemFactory(); var ipUtils = new IpRouteController(systemFactory); var one = ipUtils.ParseObjectInternal("anycast fe80:: dev tap0 table local proto kernel metric 0 pref medium", "to"); - ipUtils.ExportObject(one); + Assert.Equal("fe80:: dev tap0 table local proto kernel metric 0 pref medium anycast", string.Join(" ", ipUtils.ExportObject(one))); } } } diff --git a/IPTables.Net.Tests/NativeFamilyTests.cs b/IPTables.Net.Tests/NativeFamilyTests.cs new file mode 100644 index 0000000..c161912 --- /dev/null +++ b/IPTables.Net.Tests/NativeFamilyTests.cs @@ -0,0 +1,91 @@ +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; +using IPTables.Net.Iptables.NativeLibrary; +namespace IPTables.Net.Tests; +[Collection(SystemIptablesCollectionDefinition.Name)] +public class NativeFamilyTests +{ + [Theory] + [InlineData(4)] [InlineData(6)] + public void EachFamilyCommitsEditsAndCountersWithIndependentReadback(int version) + { + RequireFamily(version); + var binary = IptablesSystemTestSupport.GetBinary(version); + var name = "gap" + Guid.NewGuid().ToString("N").Substring(0, 12); + var address = version == 4 ? "192.0.2.1/32" : "2001:db8::1/128"; + try + { + using (var table = new IptcInterface("filter", version)) + { + Assert.True(table.AddChain(name)); + Assert.Equal(1, table.ExecuteCommand($"{binary} -A {name} -s {address} -p tcp -m tcp --dport 80 -c 7 900 -j ACCEPT")); + Assert.Equal(1, table.ExecuteCommand($"{binary} -I {name} 1 -j DROP")); + Assert.Equal(1, table.ExecuteCommand($"{binary} -R {name} 1 -j RETURN")); + Assert.Equal(1, table.ExecuteCommand($"{binary} -D {name} 1")); + Assert.Single(table.GetRules(name)); Assert.True(table.Commit(), table.GetErrorString()); + } + using (var read = new IptcInterface("filter", version)) + { + Assert.Contains(name, read.GetChains()); + var rule = read.GetRuleString(name, Assert.Single(read.GetRules(name))); + Assert.Contains(address, rule); Assert.Contains("--dport 80", rule); + } + var saved = Save(binary); + Assert.Contains($"[7:900] -A {name}", saved); Assert.Contains(address, saved); + Assert.Equal(0, IptcInterface.RefCount); + } + finally { IptablesSystemTestSupport.Execute(binary, "-F " + name, false); IptablesSystemTestSupport.Execute(binary, "-X " + name, false); } + } + [Fact] + public void Ipv6IncompatibleRevisionReportsRuleAndDoesNotInstallIt() + { + RequireFamily(6); + var binary = IptablesSystemTestSupport.GetBinary(6); + var name = "gap" + Guid.NewGuid().ToString("N").Substring(0, 12); + IptablesSystemTestSupport.RequireSuccess(binary, "-N " + name); + try + { + using var table = new IptcInterface("filter", 6); + table.ExecuteCommand($"{binary} -A {name} -p tcp -m tcp --dport 80 -j ACCEPT"); + var match = IntPtr.Add(Assert.Single(table.GetRules(name)), Marshal.SizeOf()); + Assert.Equal("tcp", Marshal.PtrToStringAnsi(IntPtr.Add(match, 2))); + Marshal.WriteByte(match, 31, byte.MaxValue); + Assert.False(table.Commit()); Assert.NotEqual(0, table.GetLastError()); + Assert.Contains("revision 255", table.GetErrorString()); Assert.Contains(name, table.GetErrorString()); + Assert.DoesNotContain("-A " + name, Save(binary)); + } + finally { IptablesSystemTestSupport.Execute(binary, "-F " + name, false); IptablesSystemTestSupport.Execute(binary, "-X " + name, false); } + } + internal static void RequireFamily(int version) + { + TestEnvironment.RequireLinuxSystemTests(); + if (IptablesSystemTestSupport.Execute(IptablesSystemTestSupport.GetBinary(version), "-t filter -L", false) != 0) + Assert.Skip($"IPv{version} filter table unavailable: requires kernel modules and network administration privileges."); + } + internal static string Save(string binary) + { + using var process = Process.Start(new ProcessStartInfo(binary + "-save", "-c -t filter") { RedirectStandardOutput = true, RedirectStandardError = true }); + var output = process.StandardOutput.ReadToEnd(); var error = process.StandardError.ReadToEnd(); + process.WaitForExit(); Assert.True(process.ExitCode == 0, error); return output; + } + [StructLayout(LayoutKind.Sequential)] + private struct NativeIp6 + { + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 4)] public uint[] Source; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 4)] public uint[] Destination; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 4)] public uint[] SourceMask; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 4)] public uint[] DestinationMask; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)] public byte[] Input; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)] public byte[] Output; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)] public byte[] InputMask; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)] public byte[] OutputMask; + public ushort Protocol; public byte Tos, Flags, InverseFlags; + } + [StructLayout(LayoutKind.Sequential)] + private struct NativeEntry6 + { + public NativeIp6 Ip; public uint Cache; public ushort TargetOffset, NextOffset; + public uint ComeFrom; public ulong Packets, Bytes; + } +} diff --git a/IPTables.Net.Tests/NativeLifecycleTests.cs b/IPTables.Net.Tests/NativeLifecycleTests.cs new file mode 100644 index 0000000..61c6504 --- /dev/null +++ b/IPTables.Net.Tests/NativeLifecycleTests.cs @@ -0,0 +1,51 @@ +using System; +using IPTables.Net.Exceptions; +using IPTables.Net.Iptables; +using IPTables.Net.Iptables.Adapter; +using IPTables.Net.Iptables.Adapter.Client; +using IPTables.Net.Iptables.NativeLibrary; +namespace IPTables.Net.Tests; +[Collection(SystemIptablesCollectionDefinition.Name)] +public class NativeLifecycleTests +{ + [Fact] + public void FailedConstructionAndRejectedFamilyDoNotLeakReferences() + { + NativeFamilyTests.RequireFamily(4); + Assert.Equal(0, IptcInterface.RefCount); + Assert.Throws(() => new IptcInterface("not_a_table", 4)); + Assert.Equal(0, IptcInterface.RefCount); + using (var first = new IptcInterface("filter", 4)) + { + using (var second = new IptcInterface("filter", 4)) Assert.Equal(2, IptcInterface.RefCount); + Assert.Throws(() => new IptcInterface("filter", 6)); + GC.Collect(); GC.WaitForPendingFinalizers(); Assert.Equal(1, IptcInterface.RefCount); + first.Dispose(); first.Dispose(); Assert.Equal(0, IptcInterface.RefCount); + Assert.Throws(() => first.GetChains()); + } + using var valid = new IptcInterface("filter", 4); Assert.NotEmpty(valid.GetChains()); + } + [Fact] + public void RollbackAndOrderedCommitReleaseEveryOpenedTable() + { + NativeFamilyTests.RequireFamily(4); + var system = new IpTablesSystem(null, new IPTablesLibAdapter()); + using var client = (IPTablesLibAdapterClient)system.GetTableAdapter(4); + client.StartTransaction(); client.ListRules("filter"); client.EndTransactionRollback(); Assert.Equal(0, IptcInterface.RefCount); + client.StartTransaction(); client.ListRules("filter"); client.ListRules("mangle"); + client.EndTransactionCommit(new[] { "missing", "filter", "filter" }); Assert.Equal(0, IptcInterface.RefCount); + client.StartTransaction(); client.ListRules("filter"); client.EndTransactionCommit(); Assert.Equal(0, IptcInterface.RefCount); + } + [Theory] + [InlineData("RAW", "tcp", 4096, true)] [InlineData("invalid", "tcp", 4096, false)] + [InlineData("RAW", "not valid (", 4096, false)] [InlineData("RAW", "tcp", 1, false)] + [InlineData("RAW", "tcp", 0, false)] [InlineData("RAW", "tcp", -1, false)] + public void BpfCompilerHandlesErrorsAndSmallBuffers(string link, string code, int size, bool valid) + { + TestEnvironment.RequireLinuxSystemTests(); + var result = IptcInterface.BpfCompile(link, code, size); + if (valid) { Assert.NotNull(result); Assert.Contains(",", result); } + else Assert.Null(result); + Assert.NotNull(IptcInterface.BpfCompile("RAW", "udp", 4096)); + } +} diff --git a/IPTables.Net.Tests/NfAcctTests.cs b/IPTables.Net.Tests/NfAcctTests.cs new file mode 100644 index 0000000..01fa2c2 --- /dev/null +++ b/IPTables.Net.Tests/NfAcctTests.cs @@ -0,0 +1,88 @@ +using System; +using System.Linq; +using IPTables.Net.Exceptions; +namespace IPTables.Net.Tests; +public class NfAcctTests +{ + private const string Xml = "alpha7900beta1844674407370955161510"; + [Fact] + public void GetAndListAgreeOnCountersAndMissingNames() + { + var fake = new ScriptedSystem { Respond = (_, _) => new(Xml) }; var client = new NfAcct.NfAcct(fake); + var first = client.Get("alpha"); var all = client.List(); + Assert.Equal(900ul, first.Bytes); Assert.Equal(7ul, first.Packets); + Assert.Equal(first.Bytes, all[0].Bytes); Assert.Equal(first.Packets, all[0].Packets); + Assert.Equal(ulong.MaxValue, all[1].Packets); Assert.Null(client.Get("missing")); + Assert.True(client.Exist("alpha")); Assert.False(client.Exist("missing")); + } + [Fact] + public void CommandsResetAndEscapeNames() + { + var fake = new ScriptedSystem(); var client = new NfAcct.NfAcct(fake); + Assert.Null(client.Get("a b", true)); Assert.Empty(client.List(true)); client.Add("a b"); client.Delete("a\"b"); + Assert.Equal(new[] { "get \"a b\" xml reset", "list xml reset", "add \"a b\"", "del \"a\\\"b\"" }, fake.Calls.Select(x => x.Arguments)); + Assert.All(fake.Calls, call => { Assert.Equal("/usr/sbin/nfacct", call.Binary); Assert.True(call.Process.IsDisposed); }); + } + [Theory] + [InlineData("")] [InlineData("")] + [InlineData("a-10")] + [InlineData("a118446744073709551616")] + public void MalformedObjectsFailConsistently(string output) + { + var client = new NfAcct.NfAcct(new ScriptedSystem { Respond = (_, _) => new(output) }); + Assert.Throws(() => client.Get("a")); Assert.Throws(() => client.List()); + } + [Theory] + [InlineData(0)] [InlineData(1)] [InlineData(2)] [InlineData(3)] [InlineData(4)] + public void EveryCommandReportsNonzeroExit(int operation) + { + var fake = new ScriptedSystem { Respond = (_, _) => new("partial", "denied", 2) }; var client = new NfAcct.NfAcct(fake); + Action[] actions = { () => client.Get("a"), () => client.List(), () => client.Add("a"), () => client.Delete("a"), () => client.Exist("a") }; + Assert.Contains("denied", Assert.Throws(actions[operation]).Message); + Assert.True(Assert.Single(fake.Calls).Process.IsDisposed); + } + [Theory] + [InlineData("nfacct v1.0.2: error: No such file or directory\n")] + [InlineData("nfacct v1.0.3: error: No such file or directory\n")] + public void MissingObjectReturnsNullOrFalseAndCanBeCreated(string error) + { + var fake = new ScriptedSystem { Respond = (_, args) => args.StartsWith("get ") ? new("", error, 1) : new() }; + var client = new NfAcct.NfAcct(fake); + Assert.Null(client.Get("missing")); + Assert.Null(client.Get("missing", true)); + if (!client.Exist("missing")) client.Add("missing"); + Assert.Equal(new[] { "get missing xml", "get missing xml reset", "get missing xml", "add missing" }, fake.Calls.Select(x => x.Arguments)); + Assert.All(fake.Calls, call => Assert.True(call.Process.IsDisposed)); + } + [Theory] + [InlineData("", "nfacct v1.0.2: error: Operation not permitted", 1)] + [InlineData("", "nfacct v1.0.2: error: Permission denied", 1)] + [InlineData("", "nfacct v1.0.2: mnl_socket_open: No such file or directory", 1)] + [InlineData("", "/usr/sbin/nfacct: No such file or directory", 1)] + [InlineData("", "", 1)] + [InlineData("partial", "nfacct v1.0.2: error: No such file or directory", 1)] + [InlineData("", "nfacct v1.0.2: error: No such file or directory", 2)] + public void LookupFailuresAreNotMistakenForMissingObjects(string output, string error, int exit) + { + var fake = new ScriptedSystem { Respond = (_, _) => new(output, error, exit) }; + var client = new NfAcct.NfAcct(fake); + Assert.Throws(() => client.Get("a")); + Assert.Throws(() => client.Exist("a")); + Assert.All(fake.Calls, call => Assert.True(call.Process.IsDisposed)); + } + [Fact] + public void MissingObjectDiagnosticDoesNotHideOtherCommandFailures() + { + var fake = new ScriptedSystem { Respond = (_, _) => new("", "nfacct v1.0.2: error: No such file or directory", 1) }; + var client = new NfAcct.NfAcct(fake); + Assert.Throws(() => client.List()); + Assert.Throws(() => client.Add("a")); + Assert.Throws(() => client.Delete("a")); + } + [Fact] + public void LookupProcessStartFailurePropagates() + { + var fake = new ScriptedSystem { Respond = (_, _) => throw new System.ComponentModel.Win32Exception(2) }; + Assert.Throws(() => new NfAcct.NfAcct(fake).Exist("a")); + } +} diff --git a/IPTables.Net.Tests/RecordingAdapter.cs b/IPTables.Net.Tests/RecordingAdapter.cs new file mode 100644 index 0000000..23b2fb9 --- /dev/null +++ b/IPTables.Net.Tests/RecordingAdapter.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using IPTables.Net.Iptables; +using IPTables.Net.Iptables.Adapter; +using IPTables.Net.Iptables.Adapter.Client; +namespace IPTables.Net.Tests; +internal sealed class RecordingAdapter : IpTablesAdapterClientBase, IIPTablesAdapter +{ + public readonly List Log = new(); + public Action OnCall = _ => { }; + public Func Read; + public IpTablesSystem System; + public int Version; + public IIPTablesAdapterClient GetClient(IpTablesSystem system, int ipVersion = 4) { System = system; Version = ipVersion; return this; } + private void Call(string text) { Log.Add(text); OnCall(text); } + public override void StartTransaction() => Call("start"); + public override void EndTransactionCommit() => Call("commit"); + public override void EndTransactionRollback() => Call("rollback"); + public override void Dispose() => Call("dispose"); + public override bool HasChain(string table, string chainName) => ListRules(table).HasChain(chainName, table); + public override void AddChain(string table, string chainName) => Call($"create {table}/{chainName}"); + public override void DeleteChain(string table, string chainName, bool flush = false) => Call($"delete {table}/{chainName}"); + public override IpTablesChainSet ListRules(string table) { Call("read " + table); return Read?.Invoke(table) ?? new(Version); } + public override void DeleteRule(string table, string chainName, int position) => Call($"remove {table}/{chainName}/{position}"); + public override void DeleteRule(IpTablesRule rule) => Call("remove " + rule.GetActionCommand()); + public override void InsertRule(IpTablesRule rule) => Call("insert " + rule.GetActionCommand()); + public override void ReplaceRule(IpTablesRule rule) => Call("replace " + rule.GetActionCommand()); + public override void AddRule(IpTablesRule rule) => Call("add " + rule.GetActionCommand()); + public override void AddRule(string rule) => Call("add " + rule); + public override System.Version GetIptablesVersion() => new(1, 8, 0); +} diff --git a/IPTables.Net.Tests/RestoreAdapterCommitTests.cs b/IPTables.Net.Tests/RestoreAdapterCommitTests.cs new file mode 100644 index 0000000..8ce1d35 --- /dev/null +++ b/IPTables.Net.Tests/RestoreAdapterCommitTests.cs @@ -0,0 +1,79 @@ +using System; +using IPTables.Net.Exceptions; +using IPTables.Net.Iptables.Adapter; + +namespace IPTables.Net.Tests; +public class RestoreAdapterCommitTests +{ + [Theory] + [InlineData(0, "")] [InlineData(1, "line 2 failed")] [InlineData(1, "line 1 failed")] + [InlineData(1, "line 999 failed")] [InlineData(1, "general failure")] + [InlineData(2, "invalid option")] [InlineData(9, "unknown failure")] + public void ProductionCommitReportsExitAndInput(int exit, string error) + { + var os = new ScriptedSystem { Respond = (_, _) => new("", error, exit) }; + var system = new IpTablesSystem(os, new IPTablesRestoreAdapter()); + using var client = system.GetTableAdapter(4); + client.StartTransaction(); + client.AddRule("-A INPUT -j ACCEPT"); + try + { + if (exit == 0) client.EndTransactionCommit(); + else + { + var ex = Assert.Throws(() => client.EndTransactionCommit()); + Assert.Contains(error, ex.Message); + if (error == "line 2 failed") Assert.Contains("-A INPUT -j ACCEPT", ex.Message); + } + var call = Assert.Single(os.Calls); + Assert.Equal("iptables-restore", call.Binary); + Assert.Equal("--noflush --noclear", call.Arguments); + Assert.Equal("*filter\n-A INPUT -j ACCEPT\nCOMMIT\n\n", call.Text.Replace("\r", "")); + Assert.False(call.Input.CanWrite); + Assert.True(call.Process.IsDisposed); + } + finally { client.EndTransactionRollback(); } + } + [Theory] + [InlineData("--noclear", "", 0, true)] [InlineData("", "--noclear", 0, true)] + [InlineData("", "", 0, false)] [InlineData("--noclear", "denied", 1, false)] + [InlineData("", "Usage: ip6tables-restore [ --noflush ]", 1, false)] + [InlineData("", "Usage: ip6tables-restore [ --noclear ]", 2, false)] + [InlineData("", "Unknown option '--noclear'", 1, false)] + public void PatchedBinaryCheck(string output, string error, int exit, bool success) + { + var os = new ScriptedSystem { Respond = (_, _) => new(output, error, exit) }; + var adapter = new IPTablesRestoreAdapter(); + var system = new IpTablesSystem(os, adapter); + if (success) adapter.CheckBinary(system, 4); + else Assert.Throws(() => adapter.CheckBinary(system, 4)); + Assert.True(Assert.Single(os.Calls).Process.IsDisposed); + } + [Theory] + [InlineData(4, false)] [InlineData(4, true)] + [InlineData(6, false)] [InlineData(6, true)] + public void PatchedBinaryHelpMayExitOne(int version, bool standardError) + { + var binary = version == 4 ? "iptables-restore" : "ip6tables-restore"; + var help = $"Usage: {binary} [ --noflush ]\n\t[ --noclear ]\n"; + var os = new ScriptedSystem + { + Respond = (_, _) => standardError ? new("", help, 1) : new(help, "", 1) + }; + var adapter = new IPTablesRestoreAdapter(); + adapter.CheckBinary(new IpTablesSystem(os, adapter), version); + var call = Assert.Single(os.Calls); + Assert.Equal(binary, call.Binary); + Assert.Equal("--help", call.Arguments); + Assert.True(call.Process.IsDisposed); + } + [Fact] + public void StartFailureCanBeRolledBack() + { + var os = new ScriptedSystem { Respond = (_, _) => throw new InvalidOperationException("start") }; + using var client = new IpTablesSystem(os, new IPTablesRestoreAdapter()).GetTableAdapter(4); + client.StartTransaction(); client.AddRule("-A INPUT -j ACCEPT"); + Assert.Throws(() => client.EndTransactionCommit()); + client.EndTransactionRollback(); + } +} diff --git a/IPTables.Net.Tests/RestoreAdapterLifecycleTests.cs b/IPTables.Net.Tests/RestoreAdapterLifecycleTests.cs new file mode 100644 index 0000000..45a1350 --- /dev/null +++ b/IPTables.Net.Tests/RestoreAdapterLifecycleTests.cs @@ -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 RestoreAdapterLifecycleTests +{ + [Fact] + public void CommitRollbackAndFailureDoNotReplayOldCommands() + { + var os = new ScriptedSystem(); + using var client = new IpTablesSystem(os, new IPTablesRestoreAdapter()).GetTableAdapter(4); + client.EndTransactionCommit(); client.EndTransactionRollback(); + client.StartTransaction(); client.EndTransactionCommit(); + Assert.Empty(os.Calls); + client.StartTransaction(); client.AddRule("-A INPUT -j ACCEPT"); client.EndTransactionCommit(); + client.StartTransaction(); client.AddRule("-A INPUT -j DROP"); client.EndTransactionRollback(); + client.StartTransaction(); client.AddRule("-A OUTPUT -j RETURN"); client.EndTransactionCommit(); + Assert.DoesNotContain("INPUT", os.Calls[1].Text); + os.Respond = (_, _) => new("", "denied", 1); + client.StartTransaction(); client.AddRule("-A INPUT -j DROP"); + Assert.Throws(() => client.EndTransactionCommit()); + client.EndTransactionRollback(); + os.Respond = (_, _) => new(); + client.StartTransaction(); client.AddRule("-A OUTPUT -j ACCEPT"); client.EndTransactionCommit(); + Assert.DoesNotContain("DROP", os.Calls.Last().Text); + } + [Fact] + public void NestedStartAndActiveDisposeRequireExplicitResolution() + { + using var client = new IpTablesSystem(new ScriptedSystem(), new IPTablesRestoreAdapter()).GetTableAdapter(4); + client.StartTransaction(); + Assert.Throws(() => client.StartTransaction()); + Assert.Throws(() => client.Dispose()); + client.EndTransactionRollback(); + } + [Fact] + public void ImmediateOperationsNeverLeakIntoNextTransaction() + { + var os = new ScriptedSystem(); + var system = new IpTablesSystem(os, new IPTablesRestoreAdapter()); + using var client = system.GetTableAdapter(6); + var rule = IpTablesRule.Parse("-A INPUT -j ACCEPT", system, new IpTablesChainSet(6), 6); + rule.Chain.AddRule(rule); + client.AddRule(rule); client.AddRule("-A INPUT -j DROP"); client.InsertRule(rule); + client.ReplaceRule(rule); client.DeleteRule(rule); client.DeleteRule("filter", "INPUT", 1); + client.AddChain("filter", "custom"); client.DeleteChain("filter", "custom", true); + Assert.All(os.Calls, c => Assert.Equal("ip6tables", c.Binary)); + Assert.Equal(new[] { "-t filter -F custom", "-t filter -X custom" }, os.Calls.TakeLast(2).Select(c => c.Arguments)); + client.StartTransaction(); client.AddRule("-A OUTPUT -j RETURN"); client.EndTransactionCommit(); + Assert.Equal("*filter\n-A OUTPUT -j RETURN\nCOMMIT\n\n", os.Calls.Last().Text.Replace("\r", "")); + Assert.Equal("ip6tables-restore", os.Calls.Last().Binary); + } +} diff --git a/IPTables.Net.Tests/RestoreTableBuilderTests.cs b/IPTables.Net.Tests/RestoreTableBuilderTests.cs new file mode 100644 index 0000000..6e4abb6 --- /dev/null +++ b/IPTables.Net.Tests/RestoreTableBuilderTests.cs @@ -0,0 +1,53 @@ +using System; +using System.IO; +using System.Text; +using IPTables.Net.Exceptions; +using IPTables.Net.Iptables.Adapter.Client.Helper; +namespace IPTables.Net.Tests; +public class RestoreTableBuilderTests +{ + [Theory] + [InlineData("'two words'", "\"two words\"")] + [InlineData("\"O'Brien\"", "\"O'Brien\"")] + [InlineData("'say \"hi\"'", "\"say \\\"hi\\\"\"")] + [InlineData("''", "\"\"")] + [InlineData("'λ\\path'", "\"λ\\\\path\"")] + public void QuotesPreserveLiteralPayload(string input, string expected) + { + var builder = new IPTablesRestoreTableBuilder(); + builder.AddCommand("filter", "-A INPUT -m comment --comment " + input); + Assert.Contains("--comment " + expected + "\n", Render(builder)); + } + [Fact] + public void TablesChainsAndClearHaveExactFraming() + { + var builder = new IPTablesRestoreTableBuilder(); + builder.AddChain("filter", "INPUT"); builder.AddChain("nat", "custom"); + Assert.Throws(() => builder.AddChain("filter", "INPUT")); + Assert.Equal("*filter\n:INPUT ACCEPT [0:0]\nCOMMIT\n\n*nat\n:custom - [0:0]\nCOMMIT\n\n", Render(builder)); + builder.Clear(); Assert.False(builder.HasChain("filter", "INPUT")); Assert.Equal("", Render(builder)); + Assert.Throws(() => builder.AddCommand("filter", "-A INPUT --comment 'broken")); + } + [Theory] + [InlineData(0)] [InlineData(8)] [InlineData(30)] [InlineData(50)] + public void FailureAtAnyOutputBoundaryThrows(int limit) + { + var builder = new IPTablesRestoreTableBuilder(); builder.AddChain("filter", "INPUT"); + builder.AddCommand("filter", "-A INPUT -j ACCEPT"); + var stream = new FailingStream(limit); + var writer = new StreamWriter(stream, new UTF8Encoding(false)); + Assert.Throws(() => builder.WriteOutput(writer)); + } + private static string Render(IPTablesRestoreTableBuilder builder) + { + using var stream = new MemoryStream(); using var writer = new StreamWriter(stream, new UTF8Encoding(false)); + builder.WriteOutput(writer); writer.Flush(); return Encoding.UTF8.GetString(stream.ToArray()).Replace("\r", ""); + } + private sealed class FailingStream(int limit) : MemoryStream + { + public override void Write(byte[] buffer, int offset, int count) + { if (Length + count > limit) throw new IOException("broken pipe"); base.Write(buffer, offset, count); } + public override void Write(ReadOnlySpan buffer) + { if (Length + buffer.Length > limit) throw new IOException("broken pipe"); base.Write(buffer); } + } +} diff --git a/IPTables.Net.Tests/RuleGeneratorBoundaryTests.cs b/IPTables.Net.Tests/RuleGeneratorBoundaryTests.cs new file mode 100644 index 0000000..883d577 --- /dev/null +++ b/IPTables.Net.Tests/RuleGeneratorBoundaryTests.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using IPTables.Net.Exceptions; +using IPTables.Net.IpSet; +using IPTables.Net.Iptables; +using IPTables.Net.Iptables.DataTypes; +using IPTables.Net.Iptables.Helpers; +using IPTables.Net.Iptables.Modules.Core; +using IPTables.Net.Iptables.Modules.Tcp; +using IPTables.Net.Iptables.Modules.Udp; +using IPTables.Net.Iptables.RuleGenerator; +namespace IPTables.Net.Tests; +public class RuleGeneratorBoundaryTests +{ + [Theory] + [InlineData(14, false, false, false)] [InlineData(15, true, false, false)] + [InlineData(16, false, true, false)] [InlineData(14, true, true, true)] + public void SlotLimitsPreservePortsTargetsAndBaseMatches(int count, bool tcp, bool source, bool range) + { + var system = new IpTablesSystem(new ScriptedSystem(), new RecordingAdapter()); + var rules = new IpTablesRuleSet(4, system); rules.AddChain("TARGET", "mangle"); + var inputs = Enumerable.Range(1, count).Select(x => new PortOrRange((uint)x * 10)).ToList(); + if (range) inputs.Add(new PortOrRange(200, 210)); + var batches = new List>(); var protocol = tcp ? "tcp" : "udp"; + var gen = new MultiportAggregator("INPUT", "mangle", _ => "group", + rule => tcp ? (source ? rule.GetModule("tcp").SourcePort.Value : rule.GetModule("tcp").DestinationPort.Value) : + (source ? rule.GetModule("udp").SourcePort.Value : rule.GetModule("udp").DestinationPort.Value), + (rule, ports) => { batches.Add(ports); if (source) PortRangeHelpers.SourcePortSetter(rule, ports); else PortRangeHelpers.DestinationPortSetter(rule, ports); }, + null, "gap", "-A INPUT -t mangle -i eth0"); + foreach (var port in inputs) gen.AddRule(IpTablesRule.Parse($"-A INPUT -t mangle -p {protocol} -m {protocol} --{(source ? "sport" : "dport")} {port} -g TARGET", system, rules.Chains)); + gen.Output(system, rules); + Assert.Equal(count == 16 || range ? 2 : 1, batches.Count); + Assert.All(batches, ports => Assert.InRange(ports.Sum(p => p.IsRange() ? 2 : 1), 1, 15)); + static IEnumerable Expand(IEnumerable ports) => ports.SelectMany(p => Enumerable.Range((int)p.LowerPort, (int)(p.UpperPort - p.LowerPort + 1)).Select(x => (uint)x)); + Assert.Equal(Expand(inputs).Order(), Expand(batches.SelectMany(x => x)).Order()); + foreach (var rule in rules.Rules) + { + var core = rule.GetModule("core"); Assert.Equal("eth0", core.InInterface.Value); + var target = core.TargetMode == TargetMode.Goto ? core.Goto : core.Jump; + Assert.True(rules.Chains.HasChain(target, "mangle")); + if (target == "TARGET") { Assert.Equal(TargetMode.Goto, core.TargetMode); Assert.Equal(protocol, core.Protocol.Value); } + } + Assert.Throws(() => gen.Output(system, rules)); + } + [Fact] + public void CompressionUnionsOverlapDuplicatesZeroAndUnsignedMaximum() + { + var compressed = PortRangeHelpers.CompressRanges(new() { new(0, 3), new(2, 5), new(5), new(6), new(uint.MaxValue) }); + Assert.Equal(new[] { new PortOrRange(0, 6), new PortOrRange(uint.MaxValue) }, compressed); + } + [Fact] + public void IpSetModeEmitsOneSetContainingEveryPort() + { + var system = new IpTablesSystem(new ScriptedSystem(), new RecordingAdapter()); var rules = new IpTablesRuleSet(4, system); var sets = new IpSetSets(system); + var gen = new MultiportAggregator("INPUT", "filter", _ => "a", r => r.GetModule("udp").DestinationPort.Value, + (r, ports) => PortRangeHelpers.DestinationPortIpSetter(r, ports, "ports", sets), null, "gap", ipset: true); + for (int i = 1; i <= 16; i++) gen.AddRule(IpTablesRule.Parse($"-A INPUT -p udp -m udp --dport {i * 10} -j ACCEPT", system, rules.Chains)); + gen.Output(system, rules); + Assert.Equal(Enumerable.Range(1, 16).Select(x => (ushort)(x * 10)), Assert.Single(sets.Sets).Entries.Select(x => (ushort)x.Port).Order()); + Assert.Single(rules.Rules.Where(x => x.GetCommand().Contains("--match-set ports dst"))); + } + [Fact] + public void EmptyGroupsAndNestedGeneratorsLeaveNoDanglingJumps() + { + var system = new IpTablesSystem(new ScriptedSystem(), new RecordingAdapter()); var rules = new IpTablesRuleSet(4, system); + var gen = new MultiportAggregator("INPUT", "filter", null, _ => new(80), (_, _) => { }, null, "gap"); + gen.Rules.Add("empty", new()); gen.Output(system, rules); Assert.Empty(rules.Chains); + var rule = IpTablesRule.Parse("-A INPUT -j ACCEPT", system, new IpTablesChainSet(4)); + Assert.Throws(() => gen.AddRule(rule)); + gen.AddRule(rule, "explicit"); gen.Output(system, rules); Assert.Single(rules.Rules); + var split = new FeatureSplitter("INPUT", "filter", _ => "key", (_, _) => { }, (_, _) => new Empty(), "gap"); + rules = new IpTablesRuleSet(4, system); split.AddRule(rule); split.Output(system, rules); Assert.Empty(rules.Rules); + Assert.All(rules.Chains, chain => Assert.Equal("INPUT", chain.Name)); + Assert.Throws(() => new MultiportAggregator("INPUT", "filter", null, null, (_, _) => { }, null, "gap")); + Assert.Throws(() => new FeatureSplitter("INPUT", "filter", null, (_, _) => { }, (_, _) => new Empty(), "gap")); + } + private sealed class Empty : IRuleGenerator { public void AddRule(IpTablesRule rule) { } public void Output(IpTablesSystem system, IpTablesRuleSet rules) { } } +} diff --git a/IPTables.Net.Tests/RuleSetCloneTests.cs b/IPTables.Net.Tests/RuleSetCloneTests.cs new file mode 100644 index 0000000..4b7d59e --- /dev/null +++ b/IPTables.Net.Tests/RuleSetCloneTests.cs @@ -0,0 +1,40 @@ +using System.Linq; +using IPTables.Net.Iptables; +using IPTables.Net.Iptables.Modules.Comment; +using IPTables.Net.Iptables.Modules.Core; +namespace IPTables.Net.Tests; +public class RuleSetCloneTests +{ + [Theory] + [InlineData(4)] [InlineData(6)] + public void ClonePreservesCountersOrderingTablesAndOwnsAllMutableState(int version) + { + var system = new IpTablesSystem(new ScriptedSystem(), new RecordingAdapter()); + var source = new IpTablesRuleSet(version, system); source.AddChain("empty", "raw"); source.AddChain("custom", "filter"); + var address = version == 4 ? "192.0.2.1" : "2001:db8::1"; + string command = $"-A custom -s {address} -m comment --comment \"owner's rule\" -j ACCEPT"; + source.AddRule(command).Counters = new PacketCounters(900, 7); + source.AddRule(command).Counters = new PacketCounters(1200, 9); + source.AddRule("-A OUTPUT -t mangle -j RETURN").Counters = new PacketCounters(-1, -1); + var clone = source.DeepClone(); + Assert.Equal(source, clone); Assert.Same(system, clone.System); Assert.Equal(version, clone.IpVersion); + Assert.Empty(clone.Chains.GetChain("empty", "raw").Rules); + var originals = source.Rules.ToArray(); var copies = clone.Rules.ToArray(); + Assert.Equal(3, copies.Length); + for (int i = 0; i < copies.Length; i++) + { + Assert.Equal(originals[i].GetActionCommand(), copies[i].GetActionCommand()); + Assert.Equal(originals[i].Counters, copies[i].Counters); Assert.NotSame(originals[i], copies[i]); + Assert.NotSame(originals[i].Chain, copies[i].Chain); Assert.Same(clone.Chains.GetChain(copies[i].Chain.Name, copies[i].Chain.Table), copies[i].Chain); + Assert.Same(system, copies[i].System); Assert.Equal(version, copies[i].IpVersion); + Assert.NotSame(originals[i].GetModule("core"), copies[i].GetModule("core")); + } + copies[0].GetModule("comment").CommentText = "changed"; + copies[0].Counters = new PacketCounters(1, 2); + var custom = clone.Chains.GetChain("custom", "filter"); custom.Rules.Reverse(); custom.Rules.RemoveAt(0); + clone.AddRule("-A custom -j DROP"); clone.Chains.RemoveChain(clone.Chains.GetChain("empty", "raw")); + Assert.Equal("owner's rule", originals[0].GetModule("comment").CommentText); + Assert.Equal(900, originals[0].Counters.Bytes); Assert.Equal(7, originals[0].Counters.Packets); + Assert.Equal(originals, source.Rules.ToArray()); Assert.True(source.Chains.HasChain("empty", "raw")); Assert.NotEqual(source, clone); + } +} diff --git a/IPTables.Net.Tests/RuleSetSyncTests.cs b/IPTables.Net.Tests/RuleSetSyncTests.cs new file mode 100644 index 0000000..cc93da2 --- /dev/null +++ b/IPTables.Net.Tests/RuleSetSyncTests.cs @@ -0,0 +1,49 @@ +using System; +using System.Linq; +using IPTables.Net.Exceptions; +using IPTables.Net.Iptables; +using IPTables.Net.Iptables.TableSync; +namespace IPTables.Net.Tests; +public class RuleSetSyncTests +{ + [Theory] + [InlineData("create filter/B")] [InlineData("add -A A -j B")] [InlineData("commit")] + public void RetryRediscoversWithoutRetainingPendingChains(string failAt) + { + var adapter = new RecordingAdapter(); var system = new IpTablesSystem(null, adapter); + var desired = new IpTablesRuleSet(4, new[] { "-A A -j B", "-A B -j ACCEPT", "-A X -t nat -j RETURN" }, system); + bool failed = false; + adapter.OnCall = call => { if (!failed && call == failAt) { failed = true; throw new IpTablesNetExceptionErrno("retry", 11); } }; + desired.Sync(new DefaultRuleSync(), maxRetries: 1); + Assert.True(failed); Assert.Equal(2, adapter.Log.Count(x => x == "read filter")); + Assert.Single(adapter.Log.Where(x => x == "rollback")); + var retry = adapter.Log.Skip(adapter.Log.IndexOf("rollback") + 1).ToList(); + Assert.Single(retry.Where(x => x == "create filter/A")); + Assert.True(retry.IndexOf("create filter/B") < retry.IndexOf("add -A A -j B")); + Assert.Contains("create nat/X", retry); + } + [Theory] + [InlineData(0, 11)] [InlineData(1, 11)] [InlineData(2, 5)] + public void RetriesAreBoundedAndOriginalFailureSurvivesRollback(int retries, int errno) + { + var adapter = new RecordingAdapter(); var system = new IpTablesSystem(null, adapter); + var failure = new IpTablesNetExceptionErrno("original", errno); + adapter.OnCall = c => { if (c == "commit") throw failure; if (c == "rollback") throw new Exception("rollback"); }; + Assert.Same(failure, Assert.Throws(() => new IpTablesRuleSet(4, system).Sync(new DefaultRuleSync(), maxRetries: retries))); + Assert.Equal(errno == 11 ? retries + 1 : 1, adapter.Log.Count(x => x == "commit")); + Assert.Equal("dispose", adapter.Log.Last()); + Assert.Throws(() => new IpTablesRuleSet(4, system).Sync(new DefaultRuleSync(), maxRetries: -1)); + } + [Theory] + [InlineData(0)] [InlineData(1)] [InlineData(2)] + public void ExistingRulesAndDeletionPolicy(int mode) + { + var adapter = new RecordingAdapter(); var system = new IpTablesSystem(null, adapter); + var desired = new IpTablesRuleSet(4, new[] { "-A A -j ACCEPT" }, system); + adapter.Read = _ => new IpTablesRuleSet(4, new[] { "-A A -j ACCEPT", "-A old -j RETURN", "-A foreign -j RETURN" }, system).Chains; + Func delete = mode == 0 ? null : c => mode == 2 && c.Name == "old"; + desired.Sync(new DefaultRuleSync(), delete); desired.Sync(new DefaultRuleSync(), delete); + Assert.DoesNotContain(adapter.Log, x => x.StartsWith("add ") || x.StartsWith("replace ") || x.Contains("delete filter/foreign")); + Assert.Equal(mode == 2 ? 2 : 0, adapter.Log.Count(x => x == "delete filter/old")); + } +} diff --git a/IPTables.Net.Tests/RuleSyncProtectionTests.cs b/IPTables.Net.Tests/RuleSyncProtectionTests.cs new file mode 100644 index 0000000..a69dd08 --- /dev/null +++ b/IPTables.Net.Tests/RuleSyncProtectionTests.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using IPTables.Net.Iptables; +using IPTables.Net.Iptables.Adapter; +using IPTables.Net.Iptables.Modules.Comment; +using IPTables.Net.Iptables.TableSync; +namespace IPTables.Net.Tests; +public class RuleSyncProtectionTests +{ + [Theory] + [InlineData(0, false)] [InlineData(1, false)] [InlineData(2, false)] + [InlineData(0, true)] [InlineData(1, true)] [InlineData(2, true)] + public void ProtectedRulesSurviveChangesAndConverge(int position, bool restore) + { + var os = new ScriptedSystem(); + var system = new IpTablesSystem(os, restore ? new IPTablesRestoreAdapter() : new IPTablesBinaryAdapter()); + var commands = new List { "-A INPUT -j ACCEPT", "-A INPUT -j RETURN" }; + commands.Insert(position, "-A INPUT -j DROP -m comment --comment protected"); + var current = new IpTablesRuleSet(4, commands, system).Chains.First(); + var desired = new IpTablesRuleSet(4, new[] { "-A INPUT -p tcp -j ACCEPT", "-A INPUT -p udp -j ACCEPT", "-A INPUT -j RETURN" }, system); + var sync = new DefaultRuleSync(shouldDelete: r => r.GetModule("comment")?.CommentText != "protected"); + using var client = system.GetTableAdapter(4); + current.Sync(client, desired.Rules, sync); + Assert.Single(current.Rules.Where(r => r.GetModule("comment")?.CommentText == "protected")); + Assert.Equal(desired.Rules.Select(r => r.GetActionCommand()), current.Rules.Where(sync.ShouldDelete).Select(r => r.GetActionCommand())); + int before = os.Calls.Count; current.Sync(client, desired.Rules, sync); Assert.Equal(before, os.Calls.Count); + current.Sync(client, Array.Empty(), sync); + Assert.Single(current.Rules); + } + [Fact] + public void CustomEqualityCanIgnoreCommentsAndDuplicatesRemainOrdered() + { + var adapter = new RecordingAdapter(); var system = new IpTablesSystem(null, adapter); + var current = new IpTablesRuleSet(4, new[] { "-A INPUT -j ACCEPT -m comment --comment one", "-A INPUT -j ACCEPT -m comment --comment two" }, system).Chains.First(); + var desired = new IpTablesRuleSet(4, new[] { "-A INPUT -j ACCEPT", "-A INPUT -j ACCEPT" }, system); + new DefaultRuleSync(comparer: new IgnoreComment()).SyncChainRules(adapter, desired.Rules, current); + Assert.Empty(adapter.Log); Assert.Equal(2, current.Rules.Count); + } + private sealed class IgnoreComment : IEqualityComparer + { + public bool Equals(IpTablesRule x, IpTablesRule y) => x.GetActionCommand().Split(" -m comment")[0] == y.GetActionCommand().Split(" -m comment")[0]; + public int GetHashCode(IpTablesRule obj) => 0; + } +} diff --git a/IPTables.Net.Tests/SaveParserErrorTests.cs b/IPTables.Net.Tests/SaveParserErrorTests.cs new file mode 100644 index 0000000..ea10a93 --- /dev/null +++ b/IPTables.Net.Tests/SaveParserErrorTests.cs @@ -0,0 +1,35 @@ +using System.Linq; +using IPTables.Net.Exceptions; +using IPTables.Net.Iptables.Adapter.Client.Helper; +namespace IPTables.Net.Tests; +public class SaveParserErrorTests +{ + [Theory] + [InlineData("")] [InlineData("COMMIT")] [InlineData("*filter\n:INPUT ACCEPT [0:0]")] + [InlineData("*nat\nCOMMIT")] [InlineData("*nat\n*filter\nCOMMIT")] + [InlineData("*filter\n[12 -A INPUT -j ACCEPT\nCOMMIT")] + [InlineData("*filter\n[a:2] -A INPUT -j ACCEPT\nCOMMIT")] + [InlineData("*filter\n[1:2:3] -A INPUT -j ACCEPT\nCOMMIT")] + [InlineData("*filter\n[9223372036854775808:2] -A INPUT -j ACCEPT\nCOMMIT")] + public void MalformedDumpsAreNotEmptyTables(string input) + { Assert.Throws(() => IPTablesSaveParser.GetRulesFromOutput(null, input, "filter", 4)); } + [Theory] + [InlineData("")] [InlineData("[1:2] ")] + public void RecoveryIsConsistentForCounterAndPlainRules(string prefix) + { + var input = "*filter\n:INPUT ACCEPT [0:0]\n" + prefix + "-A INPUT --invalid\n-A INPUT -j ACCEPT\nCOMMIT"; + Assert.ThrowsAny(() => IPTablesSaveParser.GetRulesFromOutput(null, input, "filter", 4)); + var parsed = IPTablesSaveParser.GetRulesFromOutput(null, input, "filter", 4, true); + Assert.Equal("-A INPUT -j ACCEPT", Assert.Single(parsed.GetChain("INPUT", "filter").Rules).GetActionCommand()); + } + [Fact] + public void RequestedTableHasIndependentChainsOrderAndLargeCounters() + { + const string input = "# header\r\n*nat\r\n:OTHER - [0:0]\r\nCOMMIT\r\n\r\n*filter\r\n:INPUT ACCEPT [0:0]\r\n[12:9223372036854775807] -A INPUT -j ACCEPT\r\n-A INPUT -j DROP\r\nCOMMIT"; + var result = IPTablesSaveParser.GetRulesFromOutput(null, input, "filter", 4); + var chain = Assert.Single(result.Chains); Assert.Equal("INPUT", chain.Name); + Assert.Equal(new[] { "-A INPUT -j ACCEPT", "-A INPUT -j DROP" }, chain.Rules.Select(r => r.GetActionCommand())); + Assert.Equal(long.MaxValue, chain.Rules[0].Counters.Bytes); Assert.Equal(12, chain.Rules[0].Counters.Packets); + Assert.Empty(IPTablesSaveParser.GetRulesFromOutput(null, "*filter\nCOMMIT", "filter", 4).Chains); + } +} diff --git a/IPTables.Net.Tests/ScriptedSystem.cs b/IPTables.Net.Tests/ScriptedSystem.cs new file mode 100644 index 0000000..7101e72 --- /dev/null +++ b/IPTables.Net.Tests/ScriptedSystem.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using SystemInteract; +using IPTables.Net.TestFramework; + +namespace IPTables.Net.Tests; + +internal sealed class ScriptedSystem : ISystemFactory +{ + internal record Reply(string Output = "", string Error = "", int Exit = 0); + internal record Call(string Binary, string Arguments, MemoryStream Input, MockIptablesSystemProcess Process) + { + public string Text => Encoding.UTF8.GetString(Input.ToArray()); + } + public readonly List Calls = new(); + public Func Respond = (_, _) => new(); + public ISystemProcess StartProcess(string command, string arguments) + { + var reply = Respond(command, arguments); + var input = new MemoryStream(); + var process = new MockIptablesSystemProcess(Reader(reply.Output), Reader(reply.Error), reply.Exit, + new StreamWriter(input, new UTF8Encoding(false))); + Calls.Add(new(command, arguments, input, process)); + return process; + } + private static StreamReader Reader(string text) => new(new MemoryStream(Encoding.UTF8.GetBytes(text))); + public Stream Open(string path, FileMode mode, FileAccess access) => throw new NotSupportedException(); +} diff --git a/IPTables.Net/Conntrack/ConntrackApi.cs b/IPTables.Net/Conntrack/ConntrackApi.cs new file mode 100644 index 0000000..7e35454 --- /dev/null +++ b/IPTables.Net/Conntrack/ConntrackApi.cs @@ -0,0 +1,17 @@ +using System; +namespace IPTables.Net.Conntrack +{ + internal class ConntrackApi + { + internal virtual int Constant(string key) => ConntrackHelper.cr_constant(key); + internal virtual void Mark(uint mark, uint mask) => ConntrackHelper.restore_mark_init(mark, mask); + internal virtual void ClearMark() => ConntrackHelper.restore_mark_free(); + internal virtual int Restore(bool expectations, byte[] data) => ConntrackHelper.restore_nf_cts(expectations, data, data.Length); + internal virtual void Filter(int family, ConntrackQueryFilter[] filters) => ConntrackHelper.conditional_init(family, filters, filters?.Length ?? 0); + internal virtual void ClearFilter() => ConntrackHelper.conditional_free(); + internal virtual int Dump(bool expectations, ref ConntrackHelper.CrImg image) => ConntrackHelper.dump_nf_cts(expectations, ref image); + internal virtual int Length(IntPtr node) => ConntrackHelper.cr_length(node); + internal virtual void Free(ref ConntrackHelper.CrImg image) => ConntrackHelper.cr_free(ref image); + internal virtual bool Extract(ConntrackQueryFilter[] filters, byte[] data, IntPtr output, int size) => ConntrackHelper.cr_extract_field(filters, filters.Length, data, output, size); + } +} diff --git a/IPTables.Net/Conntrack/ConntrackHelper.cs b/IPTables.Net/Conntrack/ConntrackHelper.cs index 1c869e9..3e53b47 100644 --- a/IPTables.Net/Conntrack/ConntrackHelper.cs +++ b/IPTables.Net/Conntrack/ConntrackHelper.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Runtime.InteropServices; @@ -32,18 +32,19 @@ internal struct CrImg public static extern int cr_constant(string constant); [DllImport(Helper)] - public static extern int cr_free(CrImg img); + public static extern void cr_free(ref CrImg img); [DllImport(Helper)] public static extern int cr_length(IntPtr node); [DllImport(Helper)] - public static extern int conditional_free(); + public static extern void conditional_free(); [DllImport(Helper)] - public static extern int conditional_init(int address_family, [In] ConntrackQueryFilter[] qf, int qfLength); + public static extern void conditional_init(int address_family, [In] ConntrackQueryFilter[] qf, int qfLength); [DllImport(Helper)] + [return: MarshalAs(UnmanagedType.I1)] public static extern bool cr_extract_field([In] ConntrackQueryFilter[] qf, int qfLength, byte[] data, IntPtr output, int outputLen); } diff --git a/IPTables.Net/Conntrack/ConntrackSystem.cs b/IPTables.Net/Conntrack/ConntrackSystem.cs index f622730..4e60751 100644 --- a/IPTables.Net/Conntrack/ConntrackSystem.cs +++ b/IPTables.Net/Conntrack/ConntrackSystem.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -11,7 +11,10 @@ namespace IPTables.Net.Conntrack { public class ConntrackSystem { - private object _queryLock = new object(); + private static readonly object QueryLock = new object(); + private readonly ConntrackApi _native; + public ConntrackSystem() : this(new ConntrackApi()) { } + internal ConntrackSystem(ConntrackApi native) { _native = native; } private Dictionary _constants = new Dictionary(); public ushort GetConstant(string key) @@ -21,8 +24,8 @@ public ushort GetConstant(string key) ushort value; if (!_constants.TryGetValue(key, out value)) { - var v = ConntrackHelper.cr_constant(key); - if (v == -1) throw new KeyNotFoundException(string.Format("Unable to lookup constant {0}", key)); + var v = _native.Constant(key); + if (v < 0 || v > ushort.MaxValue) throw new KeyNotFoundException(string.Format("Unable to lookup constant {0}", key)); Debug.Assert(v <= ushort.MaxValue); value = (ushort) v; _constants.Add(key, value); @@ -34,13 +37,17 @@ public ushort GetConstant(string key) public bool ExtractField(ConntrackQueryFilter[] qf, byte[] conn, out T output) where T : struct { + ArgumentNullException.ThrowIfNull(qf); + ArgumentNullException.ThrowIfNull(conn); + if (conn.Length < 20 || BitConverter.ToUInt32(conn, 0) < 20 || BitConverter.ToUInt32(conn, 0) > conn.Length) + throw new ArgumentException("Invalid conntrack record length", nameof(conn)); var size = Marshal.SizeOf(typeof(T)); var handle = Marshal.AllocHGlobal(size); if (handle == IntPtr.Zero) throw new IpTablesNetException("Unable to allocate memory for Conntrack field"); try { - var ret = ConntrackHelper.cr_extract_field(qf, qf.Length, conn, handle, size); + var ret = _native.Extract(qf, conn, handle, size); if (ret) { var obj = Marshal.PtrToStructure(handle, typeof(T)); @@ -70,60 +77,57 @@ public bool ExtractField(ConntrackQueryFilter[] qf, byte[] conn, out T output /// remaining unprocessed data public int Restore(bool expectationsTable, byte[] data, uint restoreMark = 0, uint restoreMarkMask = 0) { - var useRestoreMark = restoreMark != 0 || restoreMarkMask != 0; - if (useRestoreMark) ConntrackHelper.restore_mark_init(restoreMark, restoreMarkMask); - var errorCode = ConntrackHelper.restore_nf_cts(expectationsTable, data, data.Length); - if (errorCode < 0) - throw new IpTablesNetException(string.Format("An error occured while loading NFCTs with the errno: {0}", - -errorCode)); - if (useRestoreMark) ConntrackHelper.restore_mark_free(); - - return errorCode; + ArgumentNullException.ThrowIfNull(data); + lock (QueryLock) + { + var useRestoreMark = restoreMark != 0 || restoreMarkMask != 0; + try + { + if (useRestoreMark) _native.Mark(restoreMark, restoreMarkMask); + var result = _native.Restore(expectationsTable, data); + if (result < 0) throw new IpTablesNetException($"Unable to restore conntrack records: errno {-result}"); + return result; + } + finally { if (useRestoreMark) _native.ClearMark(); } + } } public void Dump(bool expectationTable, Action cb, ConntrackQueryFilter[] qf = null, AddressFamily addressFamily = AddressFamily.Unspecified) { - lock (_queryLock) + ArgumentNullException.ThrowIfNull(cb); + int family = addressFamily switch { - ConntrackHelper.conditional_init((int) addressFamily, qf, qf == null ? 0 : qf.Length); - + AddressFamily.Unspecified => 0, + AddressFamily.InterNetwork => 2, + AddressFamily.InterNetworkV6 => 10, + _ => throw new ArgumentOutOfRangeException(nameof(addressFamily)) + }; + lock (QueryLock) + { + var img = new ConntrackHelper.CrImg(); try { - var buffer = new byte[1]; - var img = new ConntrackHelper.CrImg(); - Debug.Assert(img.CrNode == IntPtr.Zero); - - ConntrackHelper.dump_nf_cts(expectationTable, ref img); - - try - { - var ptr = img.CrNode; - while (ptr != IntPtr.Zero) - { - var crsize = ConntrackHelper.cr_length(ptr); - var newPtr = Marshal.ReadIntPtr(ptr); - - crsize -= IntPtr.Size; - Debug.Assert(crsize > 0); - if (buffer.Length != crsize) buffer = new byte[crsize]; - - Marshal.Copy(new IntPtr((long) ptr + IntPtr.Size), buffer, 0, crsize); - cb(buffer); - - ptr = newPtr; - } - } - finally + _native.Filter(family, qf); + var result = _native.Dump(expectationTable, ref img); + if (result < 0) throw new IpTablesNetException($"Unable to dump conntrack records: errno {-result}"); + var ptr = img.CrNode; + while (ptr != IntPtr.Zero) { - if (img.CrNode != IntPtr.Zero) ConntrackHelper.cr_free(img); + var size = _native.Length(ptr) - IntPtr.Size; + if (size < 20) throw new IpTablesNetException("Invalid conntrack record length"); + var buffer = new byte[size]; + Marshal.Copy(IntPtr.Add(ptr, IntPtr.Size), buffer, 0, size); + cb(buffer); + ptr = Marshal.ReadIntPtr(ptr); } } finally { - if (qf != null || addressFamily != AddressFamily.Unspecified) ConntrackHelper.conditional_free(); + try { if (img.CrNode != IntPtr.Zero) _native.Free(ref img); } + finally { _native.ClearFilter(); } } } } } -} \ No newline at end of file +} diff --git a/IPTables.Net/IpSet/Adapter/IpSetBinaryAdapter.cs b/IPTables.Net/IpSet/Adapter/IpSetBinaryAdapter.cs index e988ff6..e9029e0 100644 --- a/IPTables.Net/IpSet/Adapter/IpSetBinaryAdapter.cs +++ b/IPTables.Net/IpSet/Adapter/IpSetBinaryAdapter.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.IO; using SystemInteract; using IPTables.Net.Exceptions; @@ -24,51 +24,28 @@ public IpSetBinaryAdapter(ISystemFactory system) _system = system; } - private bool ExecuteTransaction() - { - string output, error; - - using (var process = _system.StartProcess(BinaryName, "restore")) - { - if (WriteStrings(_transactionCommands, process.StandardInput)) - { - process.StandardInput.Flush(); - process.StandardInput.Close(); - ProcessHelper.ReadToEnd(process, out output, out error); - - //OK - if (process.ExitCode == 0) return true; - } - else - { - ProcessHelper.ReadToEnd(process, out output, out error); - } - } - - error = error.Trim(); - if (error.Length != 0) - throw new IpTablesNetException(string.Format("Failed to execute transaction: {0}", error)); + private bool ExecuteTransaction() => ExecuteRestore(_transactionCommands); + private bool ExecuteRestore(IEnumerable commands) + { + using var process = _system.StartProcess(BinaryName, "restore"); + if (!WriteStrings(commands, process.StandardInput)) throw new IOException("Unable to write ipset restore input"); + process.StandardInput.Close(); + ProcessHelper.ReadToEnd(process, out var output, out var error); + if (process.ExitCode == 0) return true; + if (!string.IsNullOrWhiteSpace(error)) throw new IpTablesNetException("Failed to execute ipset restore: " + error.Trim()); return false; } public bool RestoreSets(IEnumerable sets) { - //ipset restore - using (var process = _system.StartProcess(BinaryName, "restore")) + var commands = new List(); + foreach (var set in sets) { - if (WriteSets(sets, process.StandardInput)) - { - process.StandardInput.Flush(); - process.StandardInput.Close(); - ProcessHelper.WaitForExit(process); - - //OK - if (process.ExitCode != 0) return true; - } - - return false; + commands.Add(set.GetFullCommand()); + foreach (var entry in set.Entries) commands.Add(entry.GetFullCommand()); } + return ExecuteRestore(commands); } private bool WriteStrings(IEnumerable strings, StreamWriter standardInput) @@ -111,12 +88,10 @@ public virtual void SaveSets(IpSetSets sets, string setName = null) if (!string.IsNullOrEmpty(setName)) args += " " + ShellHelper.EscapeArguments(setName); using (var process = _system.StartProcess(BinaryName, args)) { - ProcessHelper.ReadToEnd(process, line => - { - if (line == null) return; - var trimmed = line.Trim(); - if (trimmed.Length != 0) sets.Accept(trimmed, iptables); - }, err => { }); + ProcessHelper.ReadToEnd(process, out var output, out var error); + if (process.ExitCode != 0) throw new IpTablesNetException("Failed to save sets: " + error); + foreach (var line in output.Split('\n')) + if (!string.IsNullOrWhiteSpace(line)) sets.Accept(line.Trim(), iptables); } } @@ -152,14 +127,15 @@ public void DestroySet(string name) public bool EndTransactionCommit() { - var ret = true; - if (_transactionCommands != null && _transactionCommands.Count != 0) ret = ExecuteTransaction(); - _transactionCommands = null; - return ret; + try { return _transactionCommands == null || _transactionCommands.Count == 0 || ExecuteTransaction(); } + finally { _transactionCommands = null; } } + public void EndTransactionRollback() => _transactionCommands = null; + public void StartTransaction() { + if (InTransaction) throw new IpTablesNetException("IPSet transaction already started"); _transactionCommands = new List(); } diff --git a/IPTables.Net/IpSet/IpSetEntry.cs b/IPTables.Net/IpSet/IpSetEntry.cs index 3b8940d..2a03b81 100644 --- a/IPTables.Net/IpSet/IpSetEntry.cs +++ b/IPTables.Net/IpSet/IpSetEntry.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using IPTables.Net.Exceptions; @@ -122,7 +122,7 @@ public static IpSetEntry Parse(string command, IpSetSets sets, int startOffset = public static IpSetEntry Parse(string[] arguments, IpSetSets sets, int startOffset = 0) { - if (arguments.Length < 2 + startOffset) return null; + if (arguments.Length < 2 + startOffset) throw new IpTablesNetException("Missing set name or entry key"); try { @@ -131,6 +131,7 @@ public static IpSetEntry Parse(string[] arguments, IpSetSets sets, int startOffs for (var i = startOffset; i < arguments.Length; i++) i += parser.FeedToSkip(i, i == startOffset); + entry.Set.Entries.Add(entry); return entry; } catch (Exception ex) @@ -140,6 +141,8 @@ public static IpSetEntry Parse(string[] arguments, IpSetSets sets, int startOffs } } + public override int GetHashCode() => HashCode.Combine(_set, IpSetEntryKeyComparer.Instance.GetHashCode(this), _timeout); + public bool KeyEquals(IpSetEntry other, bool cidr = true) { var r = _port == other._port && (!cidr || Cidr.Equals(other.Cidr)) && Cidr2.Equals(other.Cidr2) && diff --git a/IPTables.Net/IpSet/IpSetEntryKeyComparer.cs b/IPTables.Net/IpSet/IpSetEntryKeyComparer.cs index 25d2371..95ee063 100644 --- a/IPTables.Net/IpSet/IpSetEntryKeyComparer.cs +++ b/IPTables.Net/IpSet/IpSetEntryKeyComparer.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Diagnostics; namespace IPTables.Net.IpSet @@ -7,9 +7,7 @@ internal class IpSetEntryKeyComparer : IEqualityComparer { public bool Equals(IpSetEntry x, IpSetEntry y) { - Debug.Assert(x != null, nameof(x) + " != null"); - var ret = x.KeyEquals(y); - return ret; + return ReferenceEquals(x, y) || (x != null && y != null && x.KeyEquals(y)); } public int GetHashCode(IpSetEntry obj) @@ -20,7 +18,7 @@ public int GetHashCode(IpSetEntry obj) hashCode = (hashCode * 397) ^ (obj.Protocol != null ? obj.Protocol.GetHashCode() : 0); hashCode = (hashCode * 397) ^ obj.Port.GetHashCode(); hashCode = (hashCode * 397) ^ (obj.Mac != null ? obj.Mac.GetHashCode() : 0); - hashCode = (hashCode * 397) ^ obj.Timeout.GetHashCode(); + hashCode = (hashCode * 397) ^ obj.Cidr2.GetHashCode(); return hashCode; } } diff --git a/IPTables.Net/IpSet/IpSetSet.cs b/IPTables.Net/IpSet/IpSetSet.cs index c10f9cd..1dc0400 100644 --- a/IPTables.Net/IpSet/IpSetSet.cs +++ b/IPTables.Net/IpSet/IpSetSet.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Numerics; @@ -155,7 +155,7 @@ public IpSetSet(IpSetType type, string name, int timeout, string family, IpTable internal IpSetSet(IpTablesSystem system) { _system = system; - _entries = new HashSet(); + _entries = new HashSet(IpSetEntryKeyComparer.Instance); _createOptions = new List(); } @@ -179,6 +179,8 @@ public string GetCommand() if (_bucketSize > 0 && _bucketSize != 12) command += " bucketsize " + _bucketSize; + if (_initVal != 0) command += " initval " + _initVal; + foreach (var co in _createOptions) command += " " + co; return command; @@ -192,7 +194,7 @@ public string GetFullCommand() public IEnumerable GetEntryCommands() { var ret = new List(); - foreach (var entry in Entries) ret.Add("add " + _name + " " + entry.GetKeyCommand()); + foreach (var entry in Entries) ret.Add(entry.GetFullCommand()); return ret; } @@ -215,13 +217,19 @@ public static IpSetSet Parse(string rule, IpTablesSystem system, int startOffset return Parse(arguments, system, startOffset); } + /// + /// Checks whether this set matches the desired configuration. + /// A zero InitVal in the desired set leaves the existing seed unspecified. + /// public bool SetEquals(IpSetSet set, bool size = true) { - if (!(set.MaxElem == MaxElem && set.Name == Name && set.Timeout == Timeout && _bucketSize == set._bucketSize && + if (!(set.Family == Family && set.MaxElem == MaxElem && set.Name == Name && set.Timeout == Timeout && _bucketSize == set._bucketSize && set.Type == Type && set.BitmapRange.Equals(BitmapRange) && set.CreateOptions.OrderBy(a => a) .SequenceEqual(CreateOptions.OrderBy(a => a)))) return false; + if (set.InitVal != 0 && set.InitVal != InitVal) return false; + if (size) return set.HashSize == HashSize; return true; @@ -315,4 +323,4 @@ public void SyncEntries(IpSetSet set) SyncEntries(set.Entries); } } -} \ No newline at end of file +} diff --git a/IPTables.Net/IpSet/IpSetSets.cs b/IPTables.Net/IpSet/IpSetSets.cs index 1c67e75..fbdb06b 100644 --- a/IPTables.Net/IpSet/IpSetSets.cs +++ b/IPTables.Net/IpSet/IpSetSets.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using IPTables.Net.Exceptions; using IPTables.Net.Supporting; @@ -38,6 +38,8 @@ public void Sync( //Start transaction System.SetAdapter.StartTransaction(); + try + { // Dump sets in system var systemSets = System.SetAdapter.SaveSets(System); @@ -59,13 +61,29 @@ public void Sync( //Update if applicable if (!systemSet.SetEquals(set)) { + if (systemSet.Type != set.Type || systemSet.Family != set.Family) + throw new IpTablesNetException("Cannot swap sets with different types or address families: " + set.Name); + if (systemSets.HasSet(set.Name + "_S")) + throw new IpTablesNetException("Temporary set already exists: " + set.Name + "_S"); + var previousEntries = systemSet.Entries; + // Create a new set as _S of the target systemSet = new IpSetSet(set.Type, set.Name + "_S", set.Timeout, set.Family, System, set.SyncMode, set.BitmapRange, set.CreateOptions); systemSet.HashSize = set.HashSize; systemSet.MaxElem = set.MaxElem; + systemSet.BucketSize = set.BucketSize; + systemSet.InitVal = set.InitVal; System.SetAdapter.CreateSet(systemSet); + if (set.SyncMode == IpSetSyncMode.SetOnly) + foreach (var previous in previousEntries) + { + var copy = IpSetEntry.ParseFromParts(systemSet, previous.GetKeyCommand()); + copy.Timeout = previous.Timeout; + System.SetAdapter.AddEntry(copy); + } + // Swap (setname becomes setname+"_S" but keeps it's items) System.SetAdapter.SwapSet(systemSet.Name, set.Name); System.SetAdapter.DestroySet(systemSet.Name); @@ -92,6 +110,12 @@ public void Sync( //End Transaction: COMMIT if (!System.SetAdapter.EndTransactionCommit()) throw new IpTablesNetException("Failed to commit IPSets"); + } + catch + { + if (transactional) System.SetAdapter.EndTransactionRollback(); + throw; + } } diff --git a/IPTables.Net/IpSet/IpSetTypeHelper.cs b/IPTables.Net/IpSet/IpSetTypeHelper.cs index 3bdec62..e051e6e 100644 --- a/IPTables.Net/IpSet/IpSetTypeHelper.cs +++ b/IPTables.Net/IpSet/IpSetTypeHelper.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using IPTables.Net.Exceptions; @@ -52,6 +52,7 @@ public static IpSetType StringToType(string str) else throw new IpTablesNetException(string.Format("Unknown set type: {0}", str)); + if (parts.Length != 2) throw new IpTablesNetException("Invalid set type: " + str); var types = parts[1].Split(','); foreach (var t in types) if (t == "ip") diff --git a/IPTables.Net/IpSet/Parser/IpSetEntryParser.cs b/IPTables.Net/IpSet/Parser/IpSetEntryParser.cs index 5edaca8..063ec40 100644 --- a/IPTables.Net/IpSet/Parser/IpSetEntryParser.cs +++ b/IPTables.Net/IpSet/Parser/IpSetEntryParser.cs @@ -1,4 +1,4 @@ -using IPTables.Net.Exceptions; +using IPTables.Net.Exceptions; using IPTables.Net.Iptables.DataTypes; namespace IPTables.Net.IpSet.Parser @@ -8,6 +8,7 @@ internal class IpSetEntryParser private readonly string[] _arguments; private IpSetEntry _entry; private IpSetSets _sets; + private bool _hasKey; public IpSetEntryParser(string[] arguments, IpSetEntry entry, IpSetSets sets) { @@ -23,6 +24,7 @@ public string GetCurrentArg(int position) public string GetNextArg(int position, int offset = 1) { + if (position + offset >= _arguments.Length) throw new IpTablesNetException("Missing value for " + _arguments[position]); return _arguments[position + offset]; } @@ -35,6 +37,7 @@ public static void ParseEntry(IpSetEntry entry, string value) { var typeComponents = entry.Set.TypeComponents; var optionComponents = value.Split(new char[] {','}); + if (optionComponents.Length != typeComponents.Length) throw new IpTablesNetException("Invalid entry tuple arity"); for (var i = 0; i < optionComponents.Length; i++) switch (typeComponents[i]) @@ -83,7 +86,6 @@ public int FeedToSkip(int position, bool first) var set = _sets.GetSetByName(option); if (set == null) throw new IpTablesNetException($"The set {option} does not exist"); _entry.Set = set; - set.Entries.Add(_entry); } else if (option == "timeout") { @@ -92,12 +94,16 @@ public int FeedToSkip(int position, bool first) } else if (option == "packets" || option == "bytes") { - // ignore + // Counters are observational metadata, not part of an entry key. + ulong.Parse(GetNextArg(position)); return 1; } + else if (_hasKey) + throw new IpTablesNetException("Unknown entry option: " + option); else { ParseEntry(_entry, option); + _hasKey = true; } return 0; diff --git a/IPTables.Net/IpSet/Parser/IpSetSetParser.cs b/IPTables.Net/IpSet/Parser/IpSetSetParser.cs index d7f20f1..d341027 100644 --- a/IPTables.Net/IpSet/Parser/IpSetSetParser.cs +++ b/IPTables.Net/IpSet/Parser/IpSetSetParser.cs @@ -1,4 +1,4 @@ -using IPTables.Net.Iptables.DataTypes; +using IPTables.Net.Iptables.DataTypes; namespace IPTables.Net.IpSet.Parser { @@ -21,6 +21,7 @@ public string GetCurrentArg() public string GetNextArg(int offset = 1) { + if (Position + offset >= _arguments.Length) throw new IPTables.Net.Exceptions.IpTablesNetException("Missing value for " + GetCurrentArg()); return _arguments[Position + offset]; } diff --git a/IPTables.Net/IpUtils/Sync/DefaultIpuSync.cs b/IPTables.Net/IpUtils/Sync/DefaultIpuSync.cs index d1d2f9b..f943e4f 100644 --- a/IPTables.Net/IpUtils/Sync/DefaultIpuSync.cs +++ b/IPTables.Net/IpUtils/Sync/DefaultIpuSync.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using IPTables.Net.IpUtils.Utils; @@ -19,7 +19,7 @@ public void Sync(IEnumerable with) { var objects = new HashSet(with); - foreach (var ipobj in _getter()) + foreach (var ipobj in new HashSet(_getter())) if (!objects.Contains(ipobj)) _controller.Delete(ipobj); else diff --git a/IPTables.Net/IpUtils/Utils/IpController.cs b/IPTables.Net/IpUtils/Utils/IpController.cs index ae52853..af0b0f8 100644 --- a/IPTables.Net/IpUtils/Utils/IpController.cs +++ b/IPTables.Net/IpUtils/Utils/IpController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using SystemInteract; @@ -56,6 +56,7 @@ protected virtual IpObject ParseObject(string str, string firstKey, char[] first if (firstKey != null) { + if (i >= strs.Length) throw new IpTablesNetException("Missing value for " + firstKey); var v = strs[i]; if (firstTrimChars != null) v = v.TrimEnd(firstTrimChars); ret.Pairs.Add(firstKey, v); @@ -91,9 +92,7 @@ protected string[] Command(string command, params string[] args) { string output, error; ProcessHelper.ReadToEnd(process, out output, out error); - /*Console.WriteLine(cmd); - Console.WriteLine("output" + output); - Console.WriteLine("error" + error);*/ + if (process.ExitCode != 0) throw new IpControllerException($"ip {cmd.TrimEnd()} exited with {process.ExitCode}: {error} {output}"); return new string[] {output.Trim(), error.Trim()}; } } diff --git a/IPTables.Net/IpUtils/Utils/IpObject.cs b/IPTables.Net/IpUtils/Utils/IpObject.cs index 7487e29..e2ec061 100644 --- a/IPTables.Net/IpUtils/Utils/IpObject.cs +++ b/IPTables.Net/IpUtils/Utils/IpObject.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; @@ -23,7 +23,7 @@ public bool Equals(IpObject other) { if (ReferenceEquals(null, other)) return false; if (ReferenceEquals(this, other)) return true; - return Pairs.OrderBy((a) => a.GetHashCode()).SequenceEqual(other.Pairs.OrderBy((a) => a.GetHashCode())) && + return Pairs.OrderBy(a => a.Key, StringComparer.Ordinal).SequenceEqual(other.Pairs.OrderBy(a => a.Key, StringComparer.Ordinal)) && Singles.SetEquals(other.Singles); } @@ -37,11 +37,11 @@ public override bool Equals(object obj) public override int GetHashCode() { - unchecked - { - return Singles.OfType().Union(Pairs.OfType()).OrderBy((a) => a.GetHashCode()) - .Aggregate(13, (current, m) => current * 397 + m.GetHashCode()); - } + var hash = new HashCode(); + foreach (var pair in Pairs.OrderBy(a => a.Key, StringComparer.Ordinal)) + { hash.Add(pair.Key, StringComparer.Ordinal); hash.Add(pair.Value, StringComparer.Ordinal); } + foreach (var single in Singles.OrderBy(a => a, StringComparer.Ordinal)) hash.Add(single, StringComparer.Ordinal); + return hash.ToHashCode(); } } -} \ No newline at end of file +} diff --git a/IPTables.Net/IpUtils/Utils/IpRouteController.cs b/IPTables.Net/IpUtils/Utils/IpRouteController.cs index d2b2462..b54bd72 100644 --- a/IPTables.Net/IpUtils/Utils/IpRouteController.cs +++ b/IPTables.Net/IpUtils/Utils/IpRouteController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using SystemInteract; using IPTables.Net.Exceptions; @@ -39,7 +39,7 @@ public List GetAll(string table = null) if (obj != null) { - if (table != "default" && table != "all") obj.Pairs.Add("table", table); + if (table != null && table != "default" && table != "all" && !obj.Pairs.ContainsKey("table")) obj.Pairs.Add("table", table); r.Add(obj); } } diff --git a/IPTables.Net/IpUtils/Utils/IpRuleController.cs b/IPTables.Net/IpUtils/Utils/IpRuleController.cs index fe61a76..9399741 100644 --- a/IPTables.Net/IpUtils/Utils/IpRuleController.cs +++ b/IPTables.Net/IpUtils/Utils/IpRuleController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using SystemInteract; using IPTables.Net.Exceptions; @@ -44,7 +44,10 @@ public List GetAll() public IpObject ParseObject(string str) { - return ParseObject(str, "pref", new[] {':'}); + var result = ParseObject(str, "pref", new[] { ':' }); + if (result != null && !uint.TryParse(result.Pairs["pref"], out _)) + throw new IpTablesNetException("Invalid rule priority: " + result.Pairs["pref"]); + return result; } internal override string[] ExportObject(IpObject obj) diff --git a/IPTables.Net/Iptables/Adapter/Client/Helper/IPTablesRestoreTableBuilder.cs b/IPTables.Net/Iptables/Adapter/Client/Helper/IPTablesRestoreTableBuilder.cs index 946bff4..3f23783 100644 --- a/IPTables.Net/Iptables/Adapter/Client/Helper/IPTablesRestoreTableBuilder.cs +++ b/IPTables.Net/Iptables/Adapter/Client/Helper/IPTablesRestoreTableBuilder.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -35,25 +35,47 @@ public void AddCommand(string table, string ruleCommand) var commandTable = _tables[table]; //iptables-restore doesnt support ' quotes - ruleCommand = ruleCommand.Replace('\'', '"'); + ruleCommand = NormalizeQuotes(ruleCommand); commandTable.Commands.Add(ruleCommand); } - private bool WriteOutputLine(StreamWriter output, string line) + private static string NormalizeQuotes(string value) { - if (!output.BaseStream.CanWrite) return false; - try - { - output.WriteLine(line); - output.Flush(); - } - catch (IOException) + var output = new StringBuilder(); + char quote = '\0'; + for (int i = 0; i < value.Length; i++) { - return false; + char c = value[i]; + if (quote == '\0') + { + if (c == '\'' || c == '"') quote = c; + output.Append(c == '\'' ? '"' : c); + } + else if (quote == '"') + { + output.Append(c); + if (c == '\\' && i + 1 < value.Length) output.Append(value[++i]); + else if (c == '"') quote = '\0'; + } + else if (c == '\'') { output.Append('"'); quote = '\0'; } + else + { + if (c == '\\' && i + 1 < value.Length && (value[i + 1] == '\'' || value[i + 1] == '\\')) c = value[++i]; + if (c == '"' || c == '\\') output.Append('\\'); + output.Append(c); + } } + if (quote != '\0') throw new IpTablesNetException("Unterminated quoted restore argument"); + return output.ToString(); + } + private bool WriteOutputLine(StreamWriter output, string line) + { + if (!output.BaseStream.CanWrite) throw new IOException("Restore stream is not writable"); + output.WriteLine(line); + output.Flush(); return true; } diff --git a/IPTables.Net/Iptables/Adapter/Client/Helper/IPTablesSaveParser.cs b/IPTables.Net/Iptables/Adapter/Client/Helper/IPTablesSaveParser.cs index 13d7c1d..8f951df 100644 --- a/IPTables.Net/Iptables/Adapter/Client/Helper/IPTablesSaveParser.cs +++ b/IPTables.Net/Iptables/Adapter/Client/Helper/IPTablesSaveParser.cs @@ -1,7 +1,5 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; +using System; +using System.Globalization; using IPTables.Net.Exceptions; namespace IPTables.Net.Iptables.Adapter.Client.Helper @@ -11,75 +9,56 @@ internal class IPTablesSaveParser public static IpTablesChainSet GetRulesFromOutput(IpTablesSystem system, string output, string table, int ipVersion, bool ignoreErrors = false) { - var ret = new IpTablesChainSet(ipVersion); - string ttable = null; - - foreach (var lineRaw in output.Split(new[] {'\n'})) + var result = new IpTablesChainSet(ipVersion); + string current = null; + foreach (var raw in output.Split('\n')) { - var line = lineRaw.Trim(); - - if (string.IsNullOrEmpty(line)) + var line = raw.Trim(); + if (line.Length == 0 || line[0] == '#') continue; + if (line[0] == '*') + { + if (current != null) throw new IpTablesNetException("Missing COMMIT before " + line); + current = line.Substring(1); continue; - - var c = line[0]; - IpTablesRule rule; - switch (c) + } + if (line == "COMMIT") { - case '*': - ttable = line.Substring(1); - break; - - case ':': - var split = line.Split(new[] {' '}); - ret.AddChain(new IpTablesChain(ttable, split[0].Substring(1), ipVersion, system)); - break; - - //Byte & packet count - case '[': - var positionEnd = line.IndexOf(']'); - if (positionEnd == -1) - throw new IpTablesNetException("Parsing error, could not find end of counters"); - var counters = line.Substring(1, positionEnd - 1).Split(new[] {':'}); - line = line.Substring(positionEnd + 1); - - try - { - rule = IpTablesRule.Parse(line, system, ret, ipVersion, ttable); - } - catch - { - if (ignoreErrors) continue; - throw; - } - - rule.Counters = new PacketCounters( - bytes: long.Parse(counters[1]), - packets: long.Parse(counters[0])); - ret.AddRule(rule); - break; - - - case '-': - rule = IpTablesRule.Parse(line, system, ret, ipVersion, ttable); - ret.AddRule(rule); - break; - - case '#': - break; - - case 'C': - if (line == "COMMIT" && ttable == table) - { - if (ttable == null) throw new IpTablesNetException("Parsing error"); - return ret; - } - - throw new IpTablesNetException("Unexepected table \"" + table + "\" found \"" + ttable + - "\" instead"); + if (current == null) throw new IpTablesNetException("COMMIT without table"); + if (current == table) return result; + current = null; + continue; + } + if (current == null) throw new IpTablesNetException("Content outside a table: " + line); + if (current != table) continue; + if (line[0] == ':') + { + var fields = line.Split((char[])null, StringSplitOptions.RemoveEmptyEntries); + if (fields.Length != 3 || fields[0].Length == 1) throw new IpTablesNetException("Invalid chain declaration: " + line); + result.AddChain(new IpTablesChain(table, fields[0].Substring(1), ipVersion, system)); + continue; } + PacketCounters? counters = null; + if (line[0] == '[') + { + int end = line.IndexOf(']'); + if (end < 0) throw new IpTablesNetException("Unterminated counters: " + line); + var parts = line.Substring(1, end - 1).Split(':'); + if (parts.Length != 2 || !long.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out var packets) || + !long.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var bytes)) + throw new IpTablesNetException("Invalid counters: " + line); + counters = new PacketCounters(bytes, packets); + line = line.Substring(end + 1).TrimStart(); + } + try + { + if (!line.StartsWith("-")) throw new IpTablesNetException("Invalid rule: " + line); + var rule = IpTablesRule.Parse(line, system, result, ipVersion, table); + if (counters.HasValue) rule.Counters = counters.Value; + result.AddRule(rule); + } + catch (IpTablesNetException) when (ignoreErrors) { } } - - return null; + throw new IpTablesNetException("Incomplete or missing table dump: " + table); } } } diff --git a/IPTables.Net/Iptables/Adapter/Client/IPTablesBinaryAdapterClient.cs b/IPTables.Net/Iptables/Adapter/Client/IPTablesBinaryAdapterClient.cs index 3a1deba..30d5202 100644 --- a/IPTables.Net/Iptables/Adapter/Client/IPTablesBinaryAdapterClient.cs +++ b/IPTables.Net/Iptables/Adapter/Client/IPTablesBinaryAdapterClient.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Text.RegularExpressions; using SystemInteract; @@ -63,24 +63,22 @@ public override Version GetIptablesVersion() { string versionOutput, error; ExecutionHelper.ExecuteIptables(_iptables, "-V", _iptablesBinary, out versionOutput, out error); - var r = new Regex(@"iptables v([0-9]+\.[0-9]+\.[0-9]+)"); + var r = new Regex(@"^\s*ip6?tables\s+v([0-9]+\.[0-9]+\.[0-9]+)(?=\s|$)"); if (!r.IsMatch(versionOutput)) throw new IpTablesNetException("Unable to get version string"); var match = r.Match(versionOutput); - return new Version(match.Groups[1].Value); + if (!Version.TryParse(match.Groups[1].Value, out var version)) throw new IpTablesNetException("Unable to get version string"); + return version; } public override bool HasChain(string table, string chainName) { var command = string.Format("-L {0} -t {1}", chainName, table); - try - { - ExecutionHelper.ExecuteIptables(_iptables, command, _iptablesBinary); - return true; - } - catch (IpTablesNetException) - { - return false; - } + using var process = System.StartProcess(_iptablesBinary, command); + ProcessHelper.ReadToEnd(process, out var output, out var error); + if (process.ExitCode == 0) return true; + if (process.ExitCode == 1 && (error.Contains("No chain/target/match by that name") || + error.Contains("does not exist"))) return false; + throw new IpTablesNetException("Unable to query chain " + chainName + ": " + error); } public override void AddChain(string table, string chainName) @@ -111,7 +109,7 @@ public override IpTablesChainSet ListRules(string table) string output, error; ProcessHelper.ReadToEnd(process, out output, out error); - if (string.IsNullOrEmpty(output) && !string.IsNullOrEmpty(error)) + if (process.ExitCode != 0 || (string.IsNullOrEmpty(output) && !string.IsNullOrEmpty(error))) { throw new IpTablesNetException(error); } diff --git a/IPTables.Net/Iptables/Adapter/Client/IPTablesLibAdapterClient.cs b/IPTables.Net/Iptables/Adapter/Client/IPTablesLibAdapterClient.cs index 8100366..952c956 100644 --- a/IPTables.Net/Iptables/Adapter/Client/IPTablesLibAdapterClient.cs +++ b/IPTables.Net/Iptables/Adapter/Client/IPTablesLibAdapterClient.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; @@ -231,7 +231,7 @@ public void EndTransactionCommit(IEnumerable tableCommitOrder) { if (!_inTransaction) return; - if (tableCommitOrder == null) tableCommitOrder = _interfaces.Keys; + tableCommitOrder = (tableCommitOrder ?? Enumerable.Empty()).Concat(_interfaces.Keys).Distinct().ToList(); IpTablesNetExceptionErrno ex = null; foreach (var table in tableCommitOrder) @@ -268,10 +268,6 @@ public override void EndTransactionRollback() _inTransaction = false; } - ~IPTablesLibAdapterClient() - { - Dispose(); - } public override void Dispose() { diff --git a/IPTables.Net/Iptables/Adapter/Client/IPTablesRestoreAdapterClient.cs b/IPTables.Net/Iptables/Adapter/Client/IPTablesRestoreAdapterClient.cs index 4f2ae5e..7e2cf13 100644 --- a/IPTables.Net/Iptables/Adapter/Client/IPTablesRestoreAdapterClient.cs +++ b/IPTables.Net/Iptables/Adapter/Client/IPTablesRestoreAdapterClient.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -53,7 +53,11 @@ public void CheckBinary() { string output, error; ProcessHelper.ReadToEnd(process, out output, out error); - if (!error.Contains(NoClearOption)) + var help = output + "\n" + error; + // Older patched restore binaries exit 1 after printing usage for --help. + var helpExit = process.ExitCode == 0 || + (process.ExitCode == 1 && Regex.IsMatch(help, @"(?m)^Usage:\s", RegexOptions.IgnoreCase)); + if (!helpExit || !help.Contains(NoClearOption)) throw new IpTablesNetException( "iptables-restore client is not compiled from patched source (patch-iptables-restore.diff)"); } @@ -109,6 +113,7 @@ public override void ReplaceRule(IpTablesRule rule) //Revert to using IPTables Binary if non transactional var binaryClient = new IPTablesBinaryAdapterClient(_ipVersion, _system, _iptablesBinary); binaryClient.ReplaceRule(rule); + return; } var command = rule.GetActionCommand("-R", false, true); @@ -131,6 +136,11 @@ public override void AddRule(IpTablesRule rule) public override void AddRule(string command) { + if (!_inTransaction) + { + new IPTablesBinaryAdapterClient(_ipVersion, _system, _iptablesBinary).AddRule(command); + return; + } var table = ExtractTable(command); _builder.AddCommand(table, command); } @@ -160,6 +170,7 @@ public override void AddChain(string table, string chainName) //Revert to using IPTables Binary if non transactional var binaryClient = new IPTablesBinaryAdapterClient(_ipVersion, _system, _iptablesBinary); binaryClient.AddChain(table, chainName); + return; } _builder.AddChain(table, chainName); @@ -169,12 +180,16 @@ public override void DeleteChain(string table, string chainName, bool flush = fa { if (_inTransaction) { - _builder.DeleteChain(table, chainName); + if (!_builder.DeleteChain(table, chainName)) + { + if (flush) _builder.AddCommand(table, "-F " + chainName); + _builder.AddCommand(table, "-X " + chainName); + } return; } var binaryClient = new IPTablesBinaryAdapterClient(_ipVersion, _system, _iptablesBinary); - binaryClient.DeleteChain(table, chainName); + binaryClient.DeleteChain(table, chainName, flush); } public override IpTablesChainSet ListRules(string table) @@ -183,6 +198,7 @@ public override IpTablesChainSet ListRules(string table) { string toEnd, error; ProcessHelper.ReadToEnd(process, out toEnd, out error); + if (process.ExitCode != 0) throw new IpTablesNetException(error); return IPTablesSaveParser.GetRulesFromOutput(_system, toEnd, table, _ipVersion); } } @@ -196,78 +212,29 @@ public override void StartTransaction() public override void EndTransactionCommit() { if (!_inTransaction) return; - - using (var process = StartProcess(_iptablesRestoreBinary, NoFlushOption + " " + NoClearOption)) + using var buffer = new MemoryStream(); + using (var writer = new StreamWriter(buffer, new System.Text.UTF8Encoding(false), 1024, true)) { - if (_builder.WriteOutput(process.StandardInput)) - { - process.StandardInput.Flush(); - process.StandardInput.Close(); - string output, error; - ProcessHelper.ReadToEnd(process, out output, out error); - - //OK - if (process.ExitCode != 0) - { - //ERR: INVALID COMMAND LINE - if (process.ExitCode == 2) - { - var ms = new MemoryStream(); - var sw = new StreamWriter(ms); - _builder.WriteOutput(sw); - sw.Flush(); - ms.Seek(0, SeekOrigin.Begin); - var sr = new StreamReader(ms); - Log.Error("Error invalid command line: {error}", sr.ReadToEnd()); - throw new IpTablesNetException( - "IpTables-Restore execution failed: Invalid Command Line - " + - process.StandardError.ReadToEnd()); - } - - //ERR: GENERAL ERROR - if (process.ExitCode == 1) - { - Log.Error("An General Error Occured: {error}", error); - - var ms = new MemoryStream(); - var sw = new StreamWriter(ms); - _builder.WriteOutput(sw); - sw.Flush(); - ms.Seek(0, SeekOrigin.Begin); - var sr = new StreamReader(ms); - var rules = sr.ReadToEnd(); - - var r = new Regex("line ([0-9]+) failed"); - if (r.IsMatch(error)) - { - var m = r.Match(error); - var g = m.Groups[1]; - var i = int.Parse(g.Value); - - throw new IpTablesNetException("IpTables-Restore failed to parse rule: " + - rules.Split(new char[] {'\n'}) - .Skip(i - 1) - .FirstOrDefault()); - } - - throw new IpTablesNetException("IpTables-Restore execution failed: Error"); - } - - //ERR: UNKNOWN - throw new IpTablesNetException("IpTables-Restore execution failed: Unknown Error"); - } - } - - try - { - process.Close(); - } - catch + _builder.WriteOutput(writer); + writer.Flush(); + } + var rules = System.Text.Encoding.UTF8.GetString(buffer.ToArray()); + if (rules.Length != 0) + { + using var process = StartProcess(_iptablesRestoreBinary, NoFlushOption + " " + NoClearOption); + process.StandardInput.Write(rules); + process.StandardInput.Close(); + ProcessHelper.ReadToEnd(process, out var output, out var error); + if (process.ExitCode != 0) { + var line = Regex.Match(error, @"line ([0-9]+) failed"); + string context = ""; + if (line.Success && int.TryParse(line.Groups[1].Value, out var number)) + context = rules.Split('\n').ElementAtOrDefault(number - 1) ?? ""; + throw new IpTablesNetException($"IpTables-Restore execution failed (exit {process.ExitCode}): {error} {context}".Trim()); } } - - + _builder.Clear(); _inTransaction = false; } @@ -277,10 +244,6 @@ public override void EndTransactionRollback() _inTransaction = false; } - ~IPTablesRestoreAdapterClient() - { - Dispose(); - } public override void Dispose() diff --git a/IPTables.Net/Iptables/DataTypes/IPPortOrRange.cs b/IPTables.Net/Iptables/DataTypes/IPPortOrRange.cs index b0e20c8..965baf5 100644 --- a/IPTables.Net/Iptables/DataTypes/IPPortOrRange.cs +++ b/IPTables.Net/Iptables/DataTypes/IPPortOrRange.cs @@ -1,5 +1,6 @@ -using System; +using System; using System.Net; +using System.Numerics; using System.Net.Sockets; using System.Text.RegularExpressions; using IPTables.Net.Exceptions; @@ -17,29 +18,18 @@ public struct IPPortOrRange public IPPortOrRange(IPAddress lowerAddress, IPAddress upperAddress, PortOrRange port) { + if (lowerAddress.AddressFamily != upperAddress.AddressFamily || + new BigInteger(lowerAddress.GetAddressBytes(), true, true) > new BigInteger(upperAddress.GetAddressBytes(), true, true)) + throw new ArgumentException("Invalid address range"); + if (port.UpperPort > ushort.MaxValue) throw new ArgumentOutOfRangeException(nameof(port)); _lowerAddress = lowerAddress; _upperAddress = upperAddress; _port = port; } - public IPPortOrRange(IPAddress lowerAddress, IPAddress upperAddress) - { - _lowerAddress = lowerAddress; - _upperAddress = upperAddress; - _port = PortOrRange.Any; - } - - public IPPortOrRange(IPAddress lowerAddress, PortOrRange port) - { - _upperAddress = _lowerAddress = lowerAddress; - _port = port; - } - - public IPPortOrRange(IPAddress lowerAddress) - { - _upperAddress = _lowerAddress = lowerAddress; - _port = PortOrRange.Any; - } + public IPPortOrRange(IPAddress lowerAddress, IPAddress upperAddress) : this(lowerAddress, upperAddress, PortOrRange.Any) { } + public IPPortOrRange(IPAddress lowerAddress, PortOrRange port) : this(lowerAddress, lowerAddress, port) { } + public IPPortOrRange(IPAddress lowerAddress) : this(lowerAddress, lowerAddress, PortOrRange.Any) { } public IPAddress LowerAddress => _lowerAddress; diff --git a/IPTables.Net/Iptables/DataTypes/IpCidr.cs b/IPTables.Net/Iptables/DataTypes/IpCidr.cs index 2a33866..1de2a74 100644 --- a/IPTables.Net/Iptables/DataTypes/IpCidr.cs +++ b/IPTables.Net/Iptables/DataTypes/IpCidr.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Net; using System.Net.Sockets; using System.Numerics; @@ -18,6 +18,9 @@ public struct IpCidr : IEquatable, IComparable, IComparable public IpCidr(IPAddress address, uint prefix) { + ArgumentNullException.ThrowIfNull(address); + if (prefix > (address.AddressFamily == AddressFamily.InterNetworkV6 ? 128u : 32u)) + throw new ArgumentOutOfRangeException(nameof(prefix)); Address = address; Prefix = prefix; } @@ -50,6 +53,7 @@ public bool Equals(IpCidr other) public static IpCidr Parse(string cidr) { var p = cidr.Split(new[] {'/'}); + if (p.Length > 2) throw new IpTablesNetException("Invalid CIDR components"); IPAddress ip; try { @@ -62,7 +66,6 @@ public static IpCidr Parse(string cidr) if (p.Length == 1) return new IpCidr(ip); - if (Equals(ip, IPAddress.Any)) return new IpCidr(ip, 0); try { @@ -129,25 +132,16 @@ public int CompareTo(object obj) public bool Contains(IpCidr cidr) { - var thisNetwork = GetIPNetwork(); - var innerNetwork = cidr.GetIPNetwork(); - - if (thisNetwork.Network.ToInt() <= innerNetwork.Network.ToInt() && - thisNetwork.Broadcast.ToInt() >= innerNetwork.Broadcast.ToInt()) - return true; - - return false; + return Address.AddressFamily == cidr.Address.AddressFamily && Prefix <= cidr.Prefix && Contains(cidr.Address); } public bool Contains(IPAddress addr) { - var thisNetwork = GetIPNetwork(); - var innerNetwork = addr.ToInt(); - if (thisNetwork.Network.ToInt() <= innerNetwork && - thisNetwork.Broadcast.ToInt() >= innerNetwork) - return true; - - return false; + if (Address.AddressFamily != addr.AddressFamily) return false; + var outer = Address.GetAddressBytes(); var inner = addr.GetAddressBytes(); + for (int bit = 0; bit < Prefix; bit++) + if ((outer[bit / 8] & (128 >> (bit % 8))) != (inner[bit / 8] & (128 >> (bit % 8)))) return false; + return true; } public override bool Equals(object obj) @@ -162,19 +156,13 @@ public override int GetHashCode() public static IpCidr NewRebase(IPAddress findAddress, uint u) { - // IPv4 - if (findAddress.AddressFamily == AddressFamily.InterNetwork) - { - if (u == 32) return new IpCidr(findAddress, u); - var iAddr = findAddress.ToInt() & ~(long) (Math.Pow(2, 32 - u) - 1); - var ip = IPAddressExtension.ToAddr(iAddr); - return new IpCidr(ip, u); - } - - // IPv6 - if (u == 128) return new IpCidr(findAddress, u); - var ipNet = IPNetwork2::System.Net.IPNetwork.Parse(findAddress.ToString(), (byte) u); - return new IpCidr(ipNet.Network, u); + _ = new IpCidr(findAddress, u); // Validate before changing address bits. + var bytes = findAddress.GetAddressBytes(); + for (int bit = (int)u; bit < bytes.Length * 8; bit++) bytes[bit / 8] &= (byte)~(128 >> (bit % 8)); + var network = findAddress.AddressFamily == AddressFamily.InterNetworkV6 + ? new IPAddress(bytes, findAddress.ScopeId) + : new IPAddress(bytes); + return new IpCidr(network, u); } } -} \ No newline at end of file +} diff --git a/IPTables.Net/Iptables/DataTypes/IpPort.cs b/IPTables.Net/Iptables/DataTypes/IpPort.cs index ae89559..942de29 100644 --- a/IPTables.Net/Iptables/DataTypes/IpPort.cs +++ b/IPTables.Net/Iptables/DataTypes/IpPort.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Net; namespace IPTables.Net.Iptables.DataTypes @@ -17,32 +17,19 @@ public IpPort(IPAddress address, uint port) public static IpPort Parse(string ipPort) { - var p = ipPort.Split(new[] {':'}); - IPAddress ip; try { - ip = IPAddress.Parse(p[0]); - } - catch (Exception) - { - return Any; - } - - if (p.Length != 2) return new IpPort(ip, 0); - - try - { - return new IpPort(ip, uint.Parse(p[1])); - } - catch (Exception) - { - return Any; + var parsed = IPPortOrRange.Parse(ipPort); + if (!parsed.LowerAddress.Equals(parsed.UpperAddress) || parsed.Port.IsRange()) return Any; + return new IpPort(parsed.LowerAddress, parsed.Port.LowerPort); } + catch (Exception ex) when (ex is ArgumentException || ex is FormatException || ex is OverflowException) + { return Any; } } public override string ToString() { - return Address + ":" + Port; + return (Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 ? "[" + Address + "]" : Address.ToString()) + ":" + Port; } } } \ No newline at end of file diff --git a/IPTables.Net/Iptables/DataTypes/PortOrRange.cs b/IPTables.Net/Iptables/DataTypes/PortOrRange.cs index dc19644..7c3d0ad 100644 --- a/IPTables.Net/Iptables/DataTypes/PortOrRange.cs +++ b/IPTables.Net/Iptables/DataTypes/PortOrRange.cs @@ -1,4 +1,4 @@ -using System; +using System; namespace IPTables.Net.Iptables.DataTypes { @@ -11,6 +11,7 @@ public struct PortOrRange : IEquatable public PortOrRange(uint lowerPort, uint upperPort, char splitChar = ':') { + if (lowerPort > upperPort) throw new ArgumentOutOfRangeException(nameof(upperPort)); _lowerPort = lowerPort; _upperPort = upperPort; _splitChar = splitChar; @@ -43,6 +44,7 @@ public static PortOrRange Parse(string getNextArg, char splitChar) var split = getNextArg.Split(new[] {splitChar}); if (split.Length == 1) return new PortOrRange(uint.Parse(split[0]), splitChar); + if (split.Length != 2) throw new FormatException("Expected one value or two range endpoints"); return new PortOrRange(uint.Parse(split[0]), uint.Parse(split[1]), splitChar); } diff --git a/IPTables.Net/Iptables/Helpers/ExecutionHelper.cs b/IPTables.Net/Iptables/Helpers/ExecutionHelper.cs index a763dd6..d90e80a 100644 --- a/IPTables.Net/Iptables/Helpers/ExecutionHelper.cs +++ b/IPTables.Net/Iptables/Helpers/ExecutionHelper.cs @@ -1,4 +1,4 @@ -using System; +using System; using SystemInteract; using IPTables.Net.Exceptions; @@ -28,14 +28,14 @@ public static void ExecuteIptables(IpTablesSystem system, string command, string //ERR: INVALID COMMAND LINE if (process.ExitCode == 2) - throw new IpTablesNetException("IPTables execution failed: Invalid Command Line - " + command); + throw new IpTablesNetException("IPTables execution failed: Invalid Command Line - " + command + ": " + error); //ERR: GENERAL ERROR if (process.ExitCode == 1) - throw new IpTablesNetException("IPTables execution failed: Error - " + command); + throw new IpTablesNetException("IPTables execution failed: Error - " + command + ": " + error); //ERR: UNKNOWN - throw new IpTablesNetException("IPTables execution failed: Unknown Error - " + command); + throw new IpTablesNetException("IPTables execution failed: Unknown Error - " + command + ": " + error); } } } diff --git a/IPTables.Net/Iptables/Helpers/PortRangeHelpers.cs b/IPTables.Net/Iptables/Helpers/PortRangeHelpers.cs index 38443e1..a1e7d44 100644 --- a/IPTables.Net/Iptables/Helpers/PortRangeHelpers.cs +++ b/IPTables.Net/Iptables/Helpers/PortRangeHelpers.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text; @@ -17,43 +17,16 @@ public class PortRangeHelpers public static List CompressRanges(List ranges) { var ret = new List(); - var start = new PortOrRange(0); - int previous = -1, previousLower = -1; - foreach (var current in ranges.OrderBy((a) => a.LowerPort)) + foreach (var current in ranges.OrderBy(a => a.LowerPort).ThenBy(a => a.UpperPort)) { - if (current.LowerPort == previous + 1) - { - if (start.LowerPort == 0) start = new PortOrRange((uint) previousLower, current.UpperPort); - } + if (ret.Count == 0 || (ulong)current.LowerPort > (ulong)ret[ret.Count - 1].UpperPort + 1) + ret.Add(current); else { - if (start.UpperPort != 0) - { - ret.Add(new PortOrRange(start.LowerPort, (uint) previous)); - start = new PortOrRange(0); - } - else if (previous != -1) - { - ret.Add(new PortOrRange((uint) previousLower, (uint) previous)); - } + var previous = ret[ret.Count - 1]; + ret[ret.Count - 1] = new PortOrRange(previous.LowerPort, Math.Max(previous.UpperPort, current.UpperPort)); } - - previous = (int) current.UpperPort; - previousLower = (int) current.LowerPort; } - - if (start.UpperPort != 0) - { - ret.Add(new PortOrRange(start.LowerPort, (uint) previous)); - // ReSharper disable RedundantAssignment - start = new PortOrRange(0); - // ReSharper restore RedundantAssignment - } - else if (previous != -1) - { - ret.Add(new PortOrRange((uint) previousLower, (uint) previous)); - } - return ret; } @@ -92,8 +65,7 @@ public static void SortRangeFirstLowHigh(List ports) { if (a.IsRange() && b.IsRange() || !a.IsRange() && !b.IsRange()) { - if (a.LowerPort < b.LowerPort) return -1; - return 1; + return a.LowerPort.CompareTo(b.LowerPort); } if (a.IsRange()) return -1; diff --git a/IPTables.Net/Iptables/Helpers/SynProxyHelper.cs b/IPTables.Net/Iptables/Helpers/SynProxyHelper.cs index cc48cf1..9bc74b8 100644 --- a/IPTables.Net/Iptables/Helpers/SynProxyHelper.cs +++ b/IPTables.Net/Iptables/Helpers/SynProxyHelper.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text; @@ -27,7 +27,7 @@ public static bool IptablesSupported(IIPTablesAdapterClient adapter) } /// - /// Does the Linux Kernel support SYNPROXY + /// Whether the kernel version is eligible for SYNPROXY; this does not probe module availability. /// /// /// @@ -38,20 +38,14 @@ public static bool KernelSupported(ISystemFactory system) { ProcessHelper.ReadToEnd(process, out output, out error); if (process.ExitCode != 0) - throw new IpTablesNetException("Unable to execute uname and retreive the kenel version"); + throw new IpTablesNetException($"Unable to retrieve kernel version: uname exited with {process.ExitCode}: {error}"); } - var regex = new Regex(@"([0-9]+)\.([0-9]+)\.([0-9]+)\-([0-9]+)"); - if (regex.IsMatch(output)) - { - var match = regex.Match(output); - var version = new Version(int.Parse(match.Groups[1].Value), int.Parse(match.Groups[2].Value), - int.Parse(match.Groups[3].Value), int.Parse(match.Groups[4].Value)); - - if (version >= new Version(3, 12)) return true; - } - - return false; + var match = Regex.Match(output, @"^\s*([0-9]+)\.([0-9]+)(?:\.([0-9]+))?(?:-[^\s]+|\+[^\s]*)?\s*$"); + if (!match.Success) return false; + var numeric = match.Groups[1].Value + "." + match.Groups[2].Value + "." + + (match.Groups[3].Success ? match.Groups[3].Value : "0"); + return Version.TryParse(numeric, out var version) && version >= new Version(3, 12); } } -} \ No newline at end of file +} diff --git a/IPTables.Net/Iptables/IpTablesCommand.cs b/IPTables.Net/Iptables/IpTablesCommand.cs index 78d97eb..bd2e3a6 100644 --- a/IPTables.Net/Iptables/IpTablesCommand.cs +++ b/IPTables.Net/Iptables/IpTablesCommand.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -70,7 +70,9 @@ internal static IpTablesCommand Parse(string rule, IpTablesSystem system, IpTabl out CommandParser parserOut, int version = 4, string defaultTable = "filter") { Debug.Assert(chains.IpVersion == version); - var arguments = ArgumentHelper.SplitArguments(rule); + string[] arguments; + try { arguments = ArgumentHelper.SplitArguments(rule); } + catch (Exception ex) { throw new IpTablesParserException(rule, ex); } var count = arguments.Length; var ipRule = new IpTablesRule(system, new IpTablesChain(defaultTable, null, version, system)); var ipCmd = new IpTablesCommand(null, defaultTable, IpTablesCommandType.Unknown, -1, ipRule); @@ -84,6 +86,7 @@ internal static IpTablesCommand Parse(string rule, IpTablesSystem system, IpTabl { if (arguments[i] == "!") { + if (not) throw new IpTablesNetException("Repeated negation"); not = true; continue; } @@ -91,6 +94,8 @@ internal static IpTablesCommand Parse(string rule, IpTablesSystem system, IpTabl i += parser.FeedToSkip(i, not); not = false; } + if (not) throw new IpTablesNetException("Trailing negation"); + if (count == 0 || string.IsNullOrWhiteSpace(ipCmd.ChainName)) throw new IpTablesNetException("Missing command or chain"); } catch (Exception ex) { diff --git a/IPTables.Net/Iptables/IpTablesRule.cs b/IPTables.Net/Iptables/IpTablesRule.cs index d83ed65..5c0c7e7 100644 --- a/IPTables.Net/Iptables/IpTablesRule.cs +++ b/IPTables.Net/Iptables/IpTablesRule.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -509,8 +509,11 @@ public void ReplaceRule(IIPTablesAdapterClient client, IpTablesRule withRule) if (Chain == null) throw new IpTablesNetException("Unknown Chain"); var idx = Chain.Rules.IndexOf(this); if (idx == -1) throw new IpTablesNetException("Could not find rule to replace"); - client.ReplaceRule(withRule); - Chain.Rules[idx] = withRule; + var replacement = withRule.ShallowClone(); + replacement.Chain = Chain; + Chain.Rules[idx] = replacement; + try { client.ReplaceRule(replacement); } + catch { Chain.Rules[idx] = this; throw; } } #endregion diff --git a/IPTables.Net/Iptables/IpTablesRuleSet.cs b/IPTables.Net/Iptables/IpTablesRuleSet.cs index ac55d33..4823c57 100644 --- a/IPTables.Net/Iptables/IpTablesRuleSet.cs +++ b/IPTables.Net/Iptables/IpTablesRuleSet.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Net.Sockets; @@ -148,128 +148,43 @@ public IpTablesChain AddChain(string name, string table) public void Sync(IRuleSync sync, Func canDeleteChain = null, int maxRetries = 10) { - using (var client = _system.GetTableAdapter(_ipVersion)) + if (maxRetries < 0) throw new ArgumentOutOfRangeException(nameof(maxRetries)); + using var client = _system.GetTableAdapter(_ipVersion); + for (int attempt = 0; ; attempt++) { - var chainsToAdd = new List(); - bool needed; - var retries = maxRetries; - - do + try { - try + client.StartTransaction(); + var tables = Chains.Select(c => c.Table).Distinct().ToList(); + var current = tables.ToDictionary(t => t, t => _system.GetChains(client, t).ToList()); + foreach (var chain in Chains) + if (!current[chain.Table].Any(c => c.Name == chain.Name)) + current[chain.Table].Add(_system.AddChain(client, chain)); + if (client is IPTablesLibAdapterClient) { - //Start transaction - client.StartTransaction(); - try - { - //Load all chains, figure out what to add - var tableChains = new Dictionary>(); - foreach (var chain in Chains) - { - if (!tableChains.ContainsKey(chain.Table)) - { - var chains = _system.GetChains(client, chain.Table).ToList(); - tableChains.Add(chain.Table, chains); - } - - if ( - tableChains[chain.Table].FirstOrDefault( - a => a.Name == chain.Name && a.Table == chain.Table) == - null) - //Chain doesnt exist, to create - chainsToAdd.Add(chain); - } - - //Add the new chains / rules - foreach (var chain in chainsToAdd) - tableChains[chain.Table].Add(_system.AddChain(client, chain)); - - chainsToAdd.Clear(); - - //Special case - if (client is IPTablesLibAdapterClient) - { - //Sync chain adds before starting rule adds - client.EndTransactionCommit(); - client.StartTransaction(); - } - - //Update chains with differing rules - foreach (var chain in Chains) - { - var realChain = - tableChains[chain.Table].First(a => a.Name == chain.Name && a.Table == chain.Table); - if (realChain != null) - //Update chain - realChain.SyncInternal(client, chain.Rules, sync); - } - } - catch - { - try - { - client.EndTransactionRollback(); - } - catch - { - } - - throw; - } - - //End Transaction: COMMIT client.EndTransactionCommit(); - - if (canDeleteChain != null) - { - //Start transaction - //Needs new transaction, bug in libiptc? - client.StartTransaction(); - - try - { - foreach (var table in Chains.Select(a => a.Table).Distinct()) - foreach (var chain in _system.GetChains(table, _ipVersion)) - if (!_chains.HasChain(chain.Name, chain.Table) && canDeleteChain(chain)) - chain.Delete(client); - } - catch - { - try - { - client.EndTransactionRollback(); - } - catch - { - } - - throw; - } - - //End Transaction: COMMIT - if (client is IPTablesLibAdapterClient) - (client as IPTablesLibAdapterClient).EndTransactionCommit(sync.TableOrder); - else - client.EndTransactionCommit(); - } - - needed = false; + client.StartTransaction(); } - catch (IpTablesNetExceptionErrno ex) + foreach (var chain in Chains) + current[chain.Table].First(c => c.Name == chain.Name).SyncInternal(client, chain.Rules, sync); + client.EndTransactionCommit(); + if (canDeleteChain != null) { - client.EndTransactionRollback(); - if (ex.Errno == 11 && retries != 0) //Resource Temporarily unavailable - { - Thread.Sleep(100 * (maxRetries - retries)); - retries--; - needed = true; - } - else - { - throw; - } + client.StartTransaction(); + foreach (var table in tables) + foreach (var chain in _system.GetChains(client, table)) + if (!_chains.HasChain(chain.Name, table) && canDeleteChain(chain)) chain.Delete(client); + if (client is IPTablesLibAdapterClient native) native.EndTransactionCommit(sync.TableOrder); + else client.EndTransactionCommit(); } - } while (needed); + return; + } + catch (Exception ex) + { + try { client.EndTransactionRollback(); } catch { /* Preserve the original failure. */ } + if (ex is not IpTablesNetExceptionErrno error || error.Errno != 11 || attempt >= maxRetries) throw; + Thread.Sleep(100 * attempt); + } } } @@ -299,12 +214,13 @@ public override int GetHashCode() } } + /// Copies chains, ordered rules, module state and counters while retaining the system and IP version. public IpTablesRuleSet DeepClone() { var rs = new IpTablesRuleSet(IpVersion, System); foreach (var chain in _chains) rs.AddChain(chain.Name, chain.Table); - foreach (var rule in Rules) rs.AddRule(rule.GetActionCommand()); + foreach (var rule in Rules) rs.AddRule(rule.GetActionCommand()).Counters = rule.Counters; return rs; } diff --git a/IPTables.Net/Iptables/Modules/CommandParser.cs b/IPTables.Net/Iptables/Modules/CommandParser.cs index 76cc7bb..bec39f3 100644 --- a/IPTables.Net/Iptables/Modules/CommandParser.cs +++ b/IPTables.Net/Iptables/Modules/CommandParser.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -57,6 +57,7 @@ public string GetCurrentArg() public string GetNextArg(int offset = 1) { + if (Position + offset >= _arguments.Length) throw new IpTablesNetException("Missing value for " + GetCurrentArg()); return _arguments[Position + offset]; } @@ -82,15 +83,16 @@ public int FeedToSkip(int position, bool not) _ipCommand.Type = IpTablesCommand.GetCommandType(option); if (option == "-D" || option == "-R" || option == "-I") { - var nextArg = GetNextArg(2); + var nextArg = GetRemainingArgs() >= 2 ? GetNextArg(2) : ""; uint offset; if (uint.TryParse(nextArg, out offset)) { - if (offset == 0) throw new Exception("Invalid offset"); + if (offset == 0 || offset > int.MaxValue) throw new IpTablesNetException("Invalid offset"); _ipCommand.Offset = (int) offset - 1; return 2; } + if (option == "-R" || (option == "-D" && nextArg.Length == 0)) throw new IpTablesNetException("Missing rule position or specification"); _ipCommand.Offset = -1; } diff --git a/IPTables.Net/Iptables/Modules/Core/CoreModule.cs b/IPTables.Net/Iptables/Modules/Core/CoreModule.cs index f3d2180..e65df81 100644 --- a/IPTables.Net/Iptables/Modules/Core/CoreModule.cs +++ b/IPTables.Net/Iptables/Modules/Core/CoreModule.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Text; @@ -41,8 +41,15 @@ public class CoreModule : ModuleBase, IIpTablesModule, IEquatable public string Target { get; set; } = null; public TargetMode TargetMode { get; set; } = TargetMode.Jump; - public CoreModule(int version) : base(version) + private readonly int _version; + public CoreModule(int version) : base(version) { _version = version; } + + private IpCidr ParseAddress(string text) { + var value = IpCidr.Parse(text); + var family = _version == 4 ? System.Net.Sockets.AddressFamily.InterNetwork : System.Net.Sockets.AddressFamily.InterNetworkV6; + if (value.Address.AddressFamily != family) throw new IPTables.Net.Exceptions.IpTablesNetException("Address family does not match rule version"); + return value; } public string Jump @@ -93,11 +100,11 @@ public int Feed(CommandParser parser, bool not) return 1; case OptionSourceLong: case OptionSourceShort: - Source = new ValueOrNot(IpCidr.Parse(parser.GetNextArg()), IpCidr.Any, not); + Source = new ValueOrNot(ParseAddress(parser.GetNextArg()), IpCidr.Any, not); return 1; case OptionDestinationLong: case OptionDestinationShort: - Destination = new ValueOrNot(IpCidr.Parse(parser.GetNextArg()), IpCidr.Any, not); + Destination = new ValueOrNot(ParseAddress(parser.GetNextArg()), IpCidr.Any, not); return 1; case OptionJumpLong: case OptionJumpShort: diff --git a/IPTables.Net/Iptables/NativeLibrary/IptcInterface.cs b/IPTables.Net/Iptables/NativeLibrary/IptcInterface.cs index 7a61d44..fdc3e93 100644 --- a/IPTables.Net/Iptables/NativeLibrary/IptcInterface.cs +++ b/IPTables.Net/Iptables/NativeLibrary/IptcInterface.cs @@ -1,4 +1,4 @@ -//#define DEBUG_NATIVE_IPTCP +//#define DEBUG_NATIVE_IPTCP using System; using System.Collections.Generic; @@ -434,6 +434,8 @@ public string LastError() return err; } + private static readonly object HelperLock = new object(); + private bool _ownsHelper; private static int _helperInit = 0; private static int _helperInitCount = 0; public bool Disposed = false; @@ -473,8 +475,11 @@ public static bool DllExists() public IptcInterface(string table, int ipVersion, ILogger log = null) { + if (ipVersion != 4 && ipVersion != 6) throw new ArgumentOutOfRangeException(nameof(ipVersion)); _ipVersion = ipVersion; logger = log; + lock (HelperLock) + { if (_helperInit == ipVersion) { _helperInitCount++; @@ -497,7 +502,9 @@ public IptcInterface(string table, int ipVersion, ILogger log = null) _helperInitCount++; } - OpenTable(table); + _ownsHelper = true; + try { OpenTable(table); } catch { Dispose(); throw; } + } } ~IptcInterface() @@ -507,16 +514,17 @@ public IptcInterface(string table, int ipVersion, ILogger log = null) public void Dispose() { - if (_handle != IntPtr.Zero) - { - Debug.Assert(_helperInit >= 0); - Free(); - } - - if (!Disposed) + lock (HelperLock) { - if (--_helperInitCount == 0) _helperInit = 0; + if (Disposed) return; + if (_handle != IntPtr.Zero) Free(); + if (_ownsHelper) + { + if (--_helperInitCount == 0) _helperInit = 0; + _ownsHelper = false; + } Disposed = true; + GC.SuppressFinalize(this); } } @@ -533,7 +541,7 @@ private void DebugEntry(string message) private void RequireHandle() { - Debug.Assert(!Disposed); + if (Disposed) throw new ObjectDisposedException(nameof(IptcInterface)); if (_handle == IntPtr.Zero) throw new IpTablesNetException("No IP Table currently open"); } @@ -549,6 +557,7 @@ private void Free() public void OpenTable(string table) { + if (Disposed) throw new ObjectDisposedException(nameof(IptcInterface)); if (_handle != IntPtr.Zero) throw new IpTablesNetException("A table is already open, commit or discard first"); _lastCommitError = null; diff --git a/IPTables.Net/Iptables/RuleGenerator/FeatureSplitter.cs b/IPTables.Net/Iptables/RuleGenerator/FeatureSplitter.cs index a74be97..f973a11 100644 --- a/IPTables.Net/Iptables/RuleGenerator/FeatureSplitter.cs +++ b/IPTables.Net/Iptables/RuleGenerator/FeatureSplitter.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using IPTables.Net.Exceptions; using IPTables.Net.Iptables.Helpers; @@ -44,9 +44,9 @@ public FeatureSplitter(string chain, string table, Func extr { _chain = chain; _table = table; - _extractor = extractor; - _setter = setter; - _nestedGenerator = nestedGenerator; + _extractor = extractor ?? throw new ArgumentNullException(nameof(extractor)); + _setter = setter ?? throw new ArgumentNullException(nameof(setter)); + _nestedGenerator = nestedGenerator ?? throw new ArgumentNullException(nameof(nestedGenerator)); _commentPrefix = commentPrefix; } @@ -54,7 +54,7 @@ public void AddRule(IpTablesRule rule) { var key = _extractor(rule); if (!_protocols.ContainsKey(key)) - _protocols.Add(key, _nestedGenerator(ShortHash.HexHash(_chain + "_" + key), _table)); + _protocols.Add(key, _nestedGenerator(ShortHash.HexHash(_chain + "_" + key), _table) ?? throw new InvalidOperationException("Nested generator returned null")); var gen = _protocols[key]; @@ -76,12 +76,15 @@ public void Output(IpTablesSystem system, IpTablesRuleSet ruleSet) jumpRule.GetModuleOrLoad("core").Jump = chainName; jumpRule.GetModuleOrLoad("comment").CommentText = _commentPrefix + "|FS|" + description; _setter(jumpRule, p.Key); - ruleSet.AddRule(jumpRule); + //Nested output ruleSet.AddChain(chainName, _table); p.Value.Output(system, ruleSet); + var generated = ruleSet.Chains.GetChainOrDefault(chainName, _table); + if (generated != null && generated.Rules.Count > 0) ruleSet.AddRule(jumpRule); + else if (generated != null) ruleSet.Chains.RemoveChain(generated); } } } diff --git a/IPTables.Net/Iptables/RuleGenerator/MultiportAggregator.cs b/IPTables.Net/Iptables/RuleGenerator/MultiportAggregator.cs index c158a4c..60edac1 100644 --- a/IPTables.Net/Iptables/RuleGenerator/MultiportAggregator.cs +++ b/IPTables.Net/Iptables/RuleGenerator/MultiportAggregator.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -54,8 +54,8 @@ public MultiportAggregator(string chain, string table, Func _chain = chain; _table = table; _extractKey = extractKey; - _extractPort = extractPort; - _setPort = setPort; + _extractPort = extractPort ?? throw new ArgumentNullException(nameof(extractPort)); + _setPort = setPort ?? throw new ArgumentNullException(nameof(setPort)); _setJump = setJump; _commentPrefix = commentPrefix; if (baseRule == null) baseRule = "-A " + chain + " -t " + table; @@ -102,9 +102,9 @@ private IpTablesRule OutputRulesForGroup(IpTablesRuleSet ruleSet, IpTablesSystem ruleComment.CommentText = _commentPrefix + "|" + chainName + "|" + ruleIdx; // Create just one rule if there is only one set of multiports - if (ruleCount == 1 && ranges.Count == 1 && _setJump != null) + if (ruleCount == 1 && ranges.Count == 1) { - _setJump(rule1, key); + _setJump?.Invoke(rule1, key); rule1.Chain = ruleSet.Chains.GetChainOrDefault(_chain, _table); } else @@ -181,7 +181,7 @@ public void Output(IpTablesSystem system, IpTablesRuleSet ruleSet) if (chain.Rules.Count != 0) { var jumpRule = IpTablesRule.Parse(_baseRule, system, ruleSet.Chains, ruleSet.IpVersion); - _setJump(jumpRule, p.Key); + _setJump?.Invoke(jumpRule, p.Key); //jumpRule. jumpRule.GetModuleOrLoad("core").Jump = chainName; jumpRule.GetModuleOrLoad("comment").CommentText = _commentPrefix + "|MA|" + @@ -190,13 +190,13 @@ public void Output(IpTablesSystem system, IpTablesRuleSet ruleSet) } else { - Log.Debug(string.Format("No rules in the chain \"{chainName}\", skipping jump from {chain}.", + Log.Debug(string.Format("No rules in the chain \"{0}\", skipping jump from {1}.", chainName, _chain)); } } else { - _setJump(singleRule, p.Key); + _setJump?.Invoke(singleRule, p.Key); //ruleSet.AddRule(singleRule); } diff --git a/IPTables.Net/Iptables/TableSync/DefaultRuleSync.cs b/IPTables.Net/Iptables/TableSync/DefaultRuleSync.cs index 8bd3fa4..996dfc9 100644 --- a/IPTables.Net/Iptables/TableSync/DefaultRuleSync.cs +++ b/IPTables.Net/Iptables/TableSync/DefaultRuleSync.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using IPTables.Net.Iptables.Adapter.Client; @@ -51,6 +51,7 @@ public void SyncChainRules(IIPTablesAdapterClient client, IEnumerable(chain.Rules); + with = with.ToList(); int i = 0, len = with.Count(); @@ -77,6 +78,8 @@ public void SyncChainRules(IIPTablesAdapterClient client, IEnumerable Parse(string output) { - XDocument doc; + if (string.IsNullOrWhiteSpace(output)) return new List(); try { - doc = XDocument.Parse(output); + return XDocument.Parse(output).Descendants("obj").Select(node => + { + var name = (string)node.Element("name"); + if (string.IsNullOrEmpty(name) || !ulong.TryParse((string)node.Element("bytes"), out var bytes) || + !ulong.TryParse((string)node.Element("pkts"), out var packets)) + throw new FormatException("Invalid nfacct object: name, bytes and pkts are required."); + return new NfAcctUsage(name, bytes, packets); + }).ToList(); } - catch (XmlException) - { - return null; - } - - var usages = from node in doc.Descendants("obj") - where node.Descendants("name").First().Value == name - select new NfAcctUsage(node.Descendants("name").First().Value, - ulong.Parse(node.Descendants("pkts").First().Value), - ulong.Parse(node.Descendants("bytes").First().Value)); - - return usages.FirstOrDefault(); - } - - public NfAcctUsage Get(string name, bool reset = false) - { - var cmd = "get {0} xml"; - if (reset) cmd += " reset"; - - string output, error; - using (var process = _system.StartProcess("/usr/sbin/nfacct", string.Format(cmd, name))) - { - ProcessHelper.ReadToEnd(process, out output, out error); - } - - if (output.Trim().Length == 0) return null; - - return FromXml(output, name); - } - - public bool Exist(string name) - { - return Get(name) != null; - } - - public void Add(string name) - { - var cmd = "add {0}"; - using (var process = _system.StartProcess("/usr/sbin/nfacct", string.Format(cmd, name))) - { - string output, error; - ProcessHelper.ReadToEnd(process, out output, out error); - } - } - - public void Delete(string name) - { - var cmd = "del {0}"; - using (var process = _system.StartProcess("/usr/sbin/nfacct", string.Format(cmd, name))) - { - string output, error; - ProcessHelper.ReadToEnd(process, out output, out error); - } - } - - public List List(bool reset = false) - { - var cmd = "list xml"; - if (reset) cmd += " reset"; - - string output, error; - using (var process = _system.StartProcess("/usr/sbin/nfacct", cmd)) - { - ProcessHelper.ReadToEnd(process, out output, out error); - } - - //No XML returned for empty - if (output.Trim().Length == 0) return new List(); - - var doc = XDocument.Parse(output); - var usages = from node in doc.Descendants("obj") - select new NfAcctUsage(node.Descendants("name").First().Value, - ulong.Parse(node.Descendants("bytes").First().Value), - ulong.Parse(node.Descendants("pkts").First().Value)); - - return usages.ToList(); + catch (XmlException ex) { throw new FormatException("Invalid nfacct XML", ex); } } + /// Gets accounting data, or null if the object does not exist. + public NfAcctUsage Get(string name, bool reset = false) => + Parse(Execute(reset ? new[] { "get", name, "xml", "reset" } : new[] { "get", name, "xml" })).FirstOrDefault(x => x.Name == name); + public bool Exist(string name) => Get(name) != null; + public void Add(string name) => Execute("add", name); + public void Delete(string name) => Execute("del", name); + public List List(bool reset = false) => Parse(Execute(reset ? new[] { "list", "xml", "reset" } : new[] { "list", "xml" })); } -} \ No newline at end of file +} diff --git a/IPTables.Net/Supporting/ArgumentHelper.cs b/IPTables.Net/Supporting/ArgumentHelper.cs index 28f0cf2..37283f7 100644 --- a/IPTables.Net/Supporting/ArgumentHelper.cs +++ b/IPTables.Net/Supporting/ArgumentHelper.cs @@ -1,57 +1,34 @@ -using System; +using System; using System.Collections.Generic; -using System.Linq; using System.Text; - namespace IPTables.Net.Supporting { public class ArgumentHelper { public static string[] SplitArguments(string commandLine) { - var parmChars = commandLine.ToCharArray(); - var inSingleQuote = false; - var inDoubleQuote = false; - var inSpace = true; - var lastChar = 0; - for (var index = 0; index < parmChars.Length; index++) + ArgumentNullException.ThrowIfNull(commandLine); + var result = new List(); var token = new StringBuilder(); + char quote = '\0'; bool started = false; + for (int i = 0; i < commandLine.Length; i++) { - // replace double quote with 0x00 if not in single quote - if (parmChars[index] == '"' && !inSingleQuote) + char c = commandLine[i]; + if (c == '\\' && i + 1 < commandLine.Length && + (commandLine[i + 1] == '\\' || commandLine[i + 1] == quote || + (quote == '\0' && (char.IsWhiteSpace(commandLine[i + 1]) || commandLine[i + 1] == '\'' || commandLine[i + 1] == '"')))) + { token.Append(commandLine[++i]); started = true; } + else if (quote != '\0') + { if (c == quote) quote = '\0'; else token.Append(c); started = true; } + else if (c == '\'' || c == '"') { quote = c; started = true; } + else if (char.IsWhiteSpace(c)) { - inDoubleQuote = !inDoubleQuote; - parmChars[index] = '\x00'; - } - - // replace single quote with 0x00 if not in single quote - if (parmChars[index] == '\'' && !inDoubleQuote) - { - inSingleQuote = !inSingleQuote; - parmChars[index] = '\x00'; - } - - // replace space with 0x01 if not in any quotes - if (parmChars[index] == ' ' && !inSingleQuote && !inDoubleQuote) - { - if (inSpace) - { - parmChars[index] = '\x00'; - } - else - { - parmChars[index] = '\x01'; - inSpace = true; - } - } - else - { - lastChar = index; - inSpace = false; + if (started) { result.Add(token.ToString()); token.Clear(); started = false; } } + else { token.Append(c); started = true; } } - - // remove all ignore chars (0x00), then split by space seperator (0x01) - return new string(parmChars, 0, lastChar + 1).Replace("\x00", "").Split(new[] {'\x01'}); + if (quote != '\0') throw new FormatException("Unterminated quoted argument"); + if (started) result.Add(token.ToString()); + return result.ToArray(); } } -} \ No newline at end of file +} diff --git a/IPTables.Net/Supporting/DictionaryExtension.cs b/IPTables.Net/Supporting/DictionaryExtension.cs index 0bb2a56..bd582b1 100644 --- a/IPTables.Net/Supporting/DictionaryExtension.cs +++ b/IPTables.Net/Supporting/DictionaryExtension.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Diagnostics; using IPTables.Net.IpSet; using IPTables.Net.Iptables.DataTypes; @@ -84,7 +84,7 @@ public static bool FindCidr(this IDictionary dict, I out IpSetEntry o, out TValue f) { var find = new IpSetEntry(findOriginal.Set, findOriginal.Cidr, findOriginal.Protocol, findOriginal.Port, - findOriginal.Mac); + findOriginal.Mac) { Cidr2 = findOriginal.Cidr2 }; for (var i = find.Cidr.Prefix; i != 0; i--) { find.Cidr = IpCidr.NewRebase(find.Cidr.Address, i); diff --git a/build.sh b/build.sh old mode 100644 new mode 100755 diff --git a/ipthelper/ct.cpp b/ipthelper/ct.cpp index 1323322..6bb9345 100644 --- a/ipthelper/ct.cpp +++ b/ipthelper/ct.cpp @@ -512,7 +512,8 @@ int restore_nf_cts(bool expectation, char* data, int data_len) nlh = (struct nlmsghdr *)&data[i]; - if (i + nlh->nlmsg_len > data_len) + if (nlh->nlmsg_len < sizeof(struct nlmsghdr)) { exit_code = -EINVAL; goto out; } + if (nlh->nlmsg_len > (unsigned)(data_len - i)) { break; } diff --git a/ipthelper/ip6tables.c b/ipthelper/ip6tables.c index 5952f7e..a4d8049 100644 --- a/ipthelper/ip6tables.c +++ b/ipthelper/ip6tables.c @@ -1368,32 +1368,18 @@ EXPORT void* set_modprobe(const char* program) { } EXPORT void* init_handle6(const char* table){ - const char* err; - void* handle = ip6tc_init(table); - err = ip6tc_strerror(errno); - - /* try to insmod the module if iptc_init failed */ - if (!handle) - { - if (xtables_modprobe_program == NULL) - { - xtables_error(OTHER_PROBLEM, "init error: %s", err); - } - else if (xtables_load_ko(xtables_modprobe_program, false) != -1) - { - handle = ip6tc_init(table); - if (!handle) - { - xtables_error(OTHER_PROBLEM, "unable to init module %s after loading: %s", xtables_modprobe_program, err); - } - } - else - { - xtables_error(OTHER_PROBLEM, "unable to load module %s. init error: %s", xtables_modprobe_program, err); - } - } - - return handle; + extern char *errbuffer; + errbuffer = NULL; + void *handle = ip6tc_init(table); + if (!handle && xtables_modprobe_program != NULL) { + int saved_errno = errno; + if (xtables_load_ko(xtables_modprobe_program, false) != -1) + handle = ip6tc_init(table); + else + errno = saved_errno; + } + /* Managed callers have no active xtables longjmp target. Return errno. */ + return handle; } int do_command6(int argc, char *argv[], char **table, void **handle) diff --git a/ipthelper/iptables.c b/ipthelper/iptables.c index a852412..49b1670 100644 --- a/ipthelper/iptables.c +++ b/ipthelper/iptables.c @@ -921,32 +921,18 @@ static void clear_rule_matches(struct xtables_rule_match **matches) } EXPORT void* init_handle4(const char* table){ - const char* err; - void* handle = iptc_init(table); - err = iptc_strerror(errno); - - /* try to insmod the module if iptc_init failed */ - if (!handle) - { - if (xtables_modprobe_program == NULL) - { - xtables_error(OTHER_PROBLEM, "init error: %s", err); - } - else if (xtables_load_ko(xtables_modprobe_program, false) != -1) - { - handle = iptc_init(table); - if (!handle) - { - xtables_error(OTHER_PROBLEM, "unable to init module %s after loading: %s", xtables_modprobe_program, err); - } - } - else - { - xtables_error(OTHER_PROBLEM, "unable to load module %s. init error: %s", xtables_modprobe_program, err); - } - } - - return handle; + extern char *errbuffer; + errbuffer = NULL; + void *handle = iptc_init(table); + if (!handle && xtables_modprobe_program != NULL) { + int saved_errno = errno; + if (xtables_load_ko(xtables_modprobe_program, false) != -1) + handle = iptc_init(table); + else + errno = saved_errno; + } + /* Managed callers have no active xtables longjmp target. Return errno. */ + return handle; } static void diff --git a/ipthelper/ipthelper.c b/ipthelper/ipthelper.c index 31097d3..8cc2a3e 100644 --- a/ipthelper/ipthelper.c +++ b/ipthelper/ipthelper.c @@ -1178,48 +1178,29 @@ EXPORT void ipth_free(void* ptr) EXPORT char* ipth_bpf_compile(const char* dltname, const char* code, int length) { - struct bpf_program program; - struct bpf_insn *ins; - int i, dlt, n; - char* buffer = (char*)malloc(length + 1); - char* bufferptr = buffer; - - dlt = pcap_datalink_name_to_val(dltname); - if (dlt == -1) { - return NULL; - } - - if (pcap_compile_nopcap(65535, dlt, &program, code, 1, - PCAP_NETMASK_UNKNOWN)) { - fprintf(stderr, "Compilation error\n"); - return NULL; - } - - n = snprintf(bufferptr, length, "%d,", program.bf_len); - bufferptr += n; - length -= n; - ins = program.bf_insns; - for (i = 0; i < program.bf_len-1; ++ins, ++i){ - if(length == 0){ - goto error; - } - n = snprintf(bufferptr, length, "%u %u %u %u,", ins->code, ins->jt, ins->jf, ins->k); - bufferptr += n; - length -= n; - } - - n = snprintf(bufferptr, length, "%u %u %u %u", ins->code, ins->jt, ins->jf, ins->k); - bufferptr += n; - length -= n; - if(length == 0){ - goto error; - } - - goto ok; + struct bpf_program program; + int dlt, n, used = 0; + char *buffer; + if (length <= 0 || dltname == NULL || code == NULL) return NULL; + dlt = pcap_datalink_name_to_val(dltname); + if (dlt == -1) return NULL; + if (pcap_compile_nopcap(65535, dlt, &program, code, 1, PCAP_NETMASK_UNKNOWN)) return NULL; + buffer = malloc((size_t)length); + if (buffer == NULL) { pcap_freecode(&program); return NULL; } + n = snprintf(buffer, length, "%u,", program.bf_len); + if (n < 0 || n >= length) goto error; + used = n; + for (unsigned int i = 0; i < program.bf_len; ++i) { + struct bpf_insn *ins = &program.bf_insns[i]; + n = snprintf(buffer + used, length - used, "%u %u %u %u%s", ins->code, ins->jt, ins->jf, ins->k, + i + 1 < program.bf_len ? "," : ""); + if (n < 0 || n >= length - used) goto error; + used += n; + } + pcap_freecode(&program); + return buffer; error: - free(buffer); - buffer = NULL; -ok: - pcap_freecode(&program); - return buffer; + free(buffer); + pcap_freecode(&program); + return NULL; } diff --git a/scripts/check-native-results.py b/scripts/check-native-results.py new file mode 100644 index 0000000..6d584b0 --- /dev/null +++ b/scripts/check-native-results.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Require stable native coverage on provisioned CI runners, and report skips.""" +import sys +import xml.etree.ElementTree as ET + +REQUIRED = {"NativeFamilyTests": 3, "NativeLifecycleTests": 8, "IptcInterfaceTest": 7, "IpTableSystemTests": 1} + + +def check(path): + root = ET.parse(path).getroot() + results = [node for node in root.iter() if node.tag.rsplit("}", 1)[-1] == "UnitTestResult"] + counts = {} + for result in results: + outcome = result.get("outcome", "Unknown") + counts[outcome] = counts.get(outcome, 0) + 1 + print(f"Test outcomes: {counts}") + errors = [] + for name in REQUIRED: + selected = [r for r in results if name + "." in r.get("testName", "")] + passed = sum(r.get("outcome") == "Passed" for r in selected) + print(f"{name}: {passed}/{len(selected)} passed") + if len(selected) < REQUIRED[name] or passed != len(selected): + errors.append(f"{name}: native tests missing, skipped, or failed") + if errors: + raise ValueError("; ".join(errors)) + + +if __name__ == "__main__": + try: + check(sys.argv[1]) + except (ValueError, IndexError, OSError, ET.ParseError) as error: + sys.exit(str(error)) diff --git a/scripts/tests/test-native-results.py b/scripts/tests/test-native-results.py new file mode 100644 index 0000000..3d60a41 --- /dev/null +++ b/scripts/tests/test-native-results.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +import importlib.util +from pathlib import Path +import tempfile +import sys +sys.dont_write_bytecode = True +import unittest + +spec = importlib.util.spec_from_file_location("checker", Path(__file__).parents[1] / "check-native-results.py") +checker = importlib.util.module_from_spec(spec) +spec.loader.exec_module(checker) + + +class NativeResultsTests(unittest.TestCase): + def check(self, outcomes): + with tempfile.NamedTemporaryFile(mode="w", suffix=".trx") as report: + report.write('') + for name, outcome in outcomes: + report.write(f'') + report.write('') + report.flush() + checker.check(report.name) + + def test_all_required_classes_pass(self): + self.check([(name, "Passed") for name, count in checker.REQUIRED.items() for _ in range(count)]) + + def test_skipped_native_case_is_an_error_even_with_passed_cases(self): + with self.assertRaisesRegex(ValueError, "NativeFamilyTests"): + self.check([(name, "Passed") for name, count in checker.REQUIRED.items() for _ in range(count)] + [("NativeFamilyTests", "NotExecuted")]) + + def test_partially_missing_class_is_an_error(self): + with self.assertRaisesRegex(ValueError, "NativeFamilyTests"): + self.check([(name, "Passed") for name, count in checker.REQUIRED.items() + for _ in range(count - (name == "NativeFamilyTests"))]) + + def test_missing_native_classes_are_an_error(self): + with self.assertRaisesRegex(ValueError, "missing"): + self.check([]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test-runner.sh b/scripts/tests/test-runner.sh new file mode 100755 index 0000000..05b27eb --- /dev/null +++ b/scripts/tests/test-runner.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# Exercises the real entry points with fake process/build boundaries. No sudo or firewall access. +set -euo pipefail +repo="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +scratch="$(mktemp -d)" +trap 'rm -rf -- "$scratch"' EXIT +mkdir -p "$scratch/scripts" "$scratch/bin" +cp "$repo/test.sh" "$repo/build.sh" "$scratch/" +cp "$repo/scripts/common.sh" "$scratch/scripts/common.sh" +cat >> "$scratch/scripts/common.sh" <<'STUB' +is_linux() { [[ "${FAKE_LINUX:-1}" == 1 ]]; } +can_run_privileged() { [[ "${FAKE_PRIVILEGED:-1}" == 1 ]]; } +ensure_dotnet() { + echo ensure >> "$FAKE_LOG" + export DOTNET_ROOT=/fake NUGET_PACKAGES=/fake DOTNET_CLI_TELEMETRY_OPTOUT=1 DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1 +} +helper_build_requirements_present() { return 1; } +build_ipthelper() { echo helper >> "$FAKE_LOG"; } +run_as_root() { + echo "root $*" >> "$FAKE_LOG" + case "$1" in + env|update-alternatives|modprobe|iptables*|ip6tables*) "$@" ;; + *) echo "Unexpected privileged command: $*" >&2; exit 99 ;; + esac +} +STUB +cat > "$scratch/bin/dotnet" <<'STUB' +#!/usr/bin/env bash +printf 'dotnet %s\n' "$*" >> "$FAKE_LOG" +if [[ "$1" == test ]]; then + echo "skip=${SKIP_SYSTEM_TESTS:-unset} cpu=${DOTNET_PROCESSOR_COUNT:-unset} heap=${DOTNET_GCHeapHardLimit:-unset}" >> "$FAKE_LOG" + exit "${FAKE_TEST_STATUS:-0}" +fi +STUB +cat > "$scratch/bin/update-alternatives" <<'STUB' +#!/usr/bin/env bash +printf 'alternatives %s\n' "$*" >> "$FAKE_LOG" +if [[ "$1" == --query ]]; then echo "Value: /original/$2"; fi +if [[ "${FAKE_SWITCH_FAIL:-0}" == 1 && "$*" == *'--set ip6tables '*legacy ]]; then exit 9; fi +STUB +for binary in iptables ip6tables iptables-legacy ip6tables-legacy iptables-nft ip6tables-nft modprobe; do + cat > "$scratch/bin/$binary" <<'STUB' +#!/usr/bin/env bash +printf '%s %s\n' "${0##*/}" "$*" >> "$FAKE_LOG" +STUB +done +chmod +x "$scratch/bin/"* +export PATH="$scratch/bin:$PATH" FAKE_LOG="$scratch/commands" +export DOTNET_PROCESSOR_COUNT=2 DOTNET_GCHeapHardLimit=0x40000000 +cases=0 +run() { + local expected="$1"; shift + : > "$FAKE_LOG" + local status=0 + "$@" > "$scratch/output" 2>&1 || status=$? + if [[ "$status" != "$expected" ]]; then cat "$scratch/output"; echo "Expected $expected, got $status: $*" >&2; exit 1; fi + cases=$((cases + 1)) +} +has() { grep -Fq -- "$1" "$FAKE_LOG" || { cat "$FAKE_LOG"; echo "Missing: $1" >&2; exit 1; }; } +lacks() { if grep -Fq -- "$1" "$FAKE_LOG"; then cat "$FAKE_LOG"; echo "Unexpected: $1" >&2; exit 1; fi; } +run 0 "$scratch/test.sh" --help +lacks ensure +run 0 "$scratch/build.sh" --help +lacks ensure +run 1 "$scratch/test.sh" --configuration +run 1 "$scratch/test.sh" --iptables-backend +run 1 "$scratch/test.sh" --iptables-backend bogus +run 1 env TEST_MODE=bogus "$scratch/test.sh" +lacks ensure +run 0 env SKIP_SYSTEM_TESTS=0 "$scratch/test.sh" --fast --filter 'Name=managed' +has 'skip=1'; has 'Name=managed'; lacks 'root '; lacks 'Category!=' +run 0 env TEST_MODE=auto FAKE_PRIVILEGED=0 "$scratch/test.sh" +has 'skip=1'; lacks 'root ' +run 0 env TEST_MODE=auto FAKE_LINUX=0 "$scratch/test.sh" +has 'skip=1'; lacks helper +run 0 env TEST_MODE=auto SKIP_SYSTEM_TESTS=1 "$scratch/test.sh" +has 'skip=unset'; has 'Category!=NotWorkingOnTravis'; has 'cpu=2 heap=0x40000000' +has 'alternatives --set iptables /original/iptables'; has 'alternatives --set ip6tables /original/ip6tables' +run 0 "$scratch/test.sh" --full --iptables-backend current +lacks 'alternatives '; has 'iptables -X test3' +run 0 env RUN_UNSTABLE_SYSTEM_TESTS=1 "$scratch/test.sh" --full --iptables-backend=current +lacks 'Category!=' +run 0 "$scratch/test.sh" --full --filter 'Name=native' +has 'Name=native'; lacks 'Category!=' +run 0 "$scratch/test.sh" --full --filter=Name=native --iptables-backend=nft +has '--filter=Name=native'; has 'ip6tables-nft'; lacks 'Category!=' +run 7 env FAKE_TEST_STATUS=7 "$scratch/test.sh" --full +has 'alternatives --set iptables /original/iptables'; has 'alternatives --set ip6tables /original/ip6tables'; has 'ip6tables-nft -X test3' +run 9 env FAKE_SWITCH_FAIL=1 "$scratch/test.sh" --full +has 'alternatives --set iptables /original/iptables'; has 'alternatives --set ip6tables /original/ip6tables'; has 'iptables -X test3'; lacks 'dotnet test' +run 0 "$scratch/build.sh" -p:Example=true +has 'dotnet build'; has '-p:Example=true'; has helper +printf 'Runner contracts: %s cases passed.\n' "$cases" diff --git a/test.sh b/test.sh old mode 100644 new mode 100755 index c74a82d..503de33 --- a/test.sh +++ b/test.sh @@ -155,6 +155,12 @@ cleanup_test_chains() { run_full_tests() { local results_dir local trap_command + unset SKIP_SYSTEM_TESTS + local -a runtime_env=() + local runtime_name + for runtime_name in DOTNET_PROCESSOR_COUNT DOTNET_GCHeapHardLimit; do + if [[ -n "${!runtime_name:-}" ]]; then runtime_env+=("${runtime_name}=${!runtime_name}"); fi + done local -a effective_test_args=("${DOTNET_TEST_ARGS[@]}") results_dir="$(mktemp -d)" trap_command="$(printf 'rm -rf -- %q; cleanup_test_chains; restore_iptables_backend' "$results_dir")" @@ -186,6 +192,7 @@ run_full_tests() { "DOTNET_SKIP_FIRST_TIME_EXPERIENCE=${DOTNET_SKIP_FIRST_TIME_EXPERIENCE}" \ "LD_LIBRARY_PATH=${LD_LIBRARY_PATH:-}" \ "HOME=/root" \ + "${runtime_env[@]}" \ dotnet test "${REPO_ROOT}/IPTables.Net.Tests/IPTables.Net.Tests.csproj" \ --configuration "$CONFIGURATION" \ --no-build \ @@ -249,6 +256,11 @@ EOF shift done +case "$TEST_MODE" in + auto|fast|full) ;; + *) die "Unsupported test mode '${TEST_MODE}'. Expected auto, fast or full." ;; +esac + IPTABLES_BACKEND="$(normalize_iptables_backend "$IPTABLES_BACKEND")" if [[ "$TEST_MODE" == "auto" ]]; then