diff --git a/README.md b/README.md index 4cf1c48..2bad134 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,34 @@ # libnftables-dotnet -.NET bindings for system-installed `libnftables`, generated with SWIG. +`libnftables-dotnet` is a command-centric .NET wrapper over system-installed `libnftables`, with low-level SWIG-generated bindings and a small managed API for common workflows. + +## Current Scope + +This library is intentionally narrow. + +- High-level managed API: + - `Validate` + - `Apply` + - `Snapshot` + - `Restore` +- Low-level managed wrapper: + - `NftContext` for direct control over flags, buffering, include paths, variables, and command execution + +Non-goals for the current release: + +- Typed .NET models for tables, chains, rules, sets, or maps +- Event monitoring or subscriptions +- Cross-platform support beyond Linux x64 + +## Runtime Support + +Native operations currently support: + +- Linux only +- x64 only +- System-installed `libnftables` + +The package includes the generated Linux x64 native wrapper, but it still depends on the host system providing `libnftables`. ## Requirements @@ -8,7 +36,9 @@ - `libnftables` headers and shared library installed - `gcc` - .NET SDK 10+ -- `swig` (only needed to regenerate bindings) +- `swig` only if you regenerate bindings + +On Debian/Ubuntu-like systems, the runtime dependency is typically installed from the system package repository. Exact package names can vary by distro. ## Build @@ -16,35 +46,21 @@ dotnet build ``` -`dotnet build` compiles the native SWIG wrapper (`libLibNftablesBindings.so`) from checked-in generated C wrapper code. +`dotnet build` compiles the native SWIG wrapper (`libLibNftablesBindings.so`) from the checked-in generated C wrapper code. -## Regenerate SWIG bindings +## Test ```bash -./eng/regen-bindings.sh +dotnet test LibNftables.slnx ``` -## Native wrapper build only +The test suite contains: -```bash -./eng/build-native.sh -``` +- Managed/unit tests that do not require a native runtime +- Native integration tests that self-gate when `libnftables` is unavailable +- Capability-dependent tests that only run when `CAP_NET_ADMIN` is available -## Notes - -- Native dependency remains system-level `libnftables` (`-lnftables`). -- Managed APIs: - - `NftContext`: advanced low-level context wrapper over native calls. - - `INftablesClient` / `NftablesClient`: high-level command-centric API with `Validate`, `Apply`, `Snapshot`, and `Restore` (sync + async). -- Fail-fast runtime policy: Linux x64 only for native operations. - -## Documentation - -- High-level managed API XML docs are provided on all public `LibNftables` types/members. -- Low-level generated binding reference: `docs/low-level-bindings-reference.md`. -- Generated SWIG files under `src/LibNftables.Bindings/Generated/` are auto-generated and not hand-edited. - -## High-level example +## High-Level Example ```csharp using LibNftables; @@ -57,3 +73,64 @@ if (validation.IsValid) client.Apply(NftApplyRequest.FromText("add table inet my_table")); } ``` + +## Low-Level Example + +```csharp +using LibNftables; + +using var context = new NftContext(); +context.DryRun = true; +context.BufferOutput(); +context.BufferError(); + +context.RunCommand("add table inet demo"); + +string? output = context.GetOutputBuffer(); +string? error = context.GetErrorBuffer(); +``` + +## Troubleshooting + +### `NftNativeLoadException` + +This usually means one of these is missing or incompatible: + +- the bundled wrapper `libLibNftablesBindings.so` +- the host `libnftables` shared library +- Linux x64 runtime compatibility + +### `NftUnsupportedException` + +This is expected on: + +- non-Linux hosts +- non-x64 processes + +### `NftPermissionException` + +Some operations require elevated privileges or `CAP_NET_ADMIN`, especially when interacting with the live ruleset. + +### Validation failures + +`Validate` returns `IsValid = false` for invalid nft syntax. `Apply` and `Restore` throw when the request shape is invalid or native parsing fails. + +## Bindings and Regeneration + +- Native wrapper build only: + +```bash +./eng/build-native.sh +``` + +- Regenerate SWIG bindings: + +```bash +./eng/regen-bindings.sh +``` + +Low-level generated binding reference: + +- `docs/low-level-bindings-reference.md` + +Generated SWIG files under `src/LibNftables.Bindings/Generated/` are generated artifacts and should not be edited by hand. diff --git a/src/LibNftables/INftContextHandle.cs b/src/LibNftables/INftContextHandle.cs new file mode 100644 index 0000000..dd92fe3 --- /dev/null +++ b/src/LibNftables/INftContextHandle.cs @@ -0,0 +1,26 @@ +namespace LibNftables; + +internal interface INftContextHandle : System.IDisposable +{ + bool DryRun { get; set; } + + NftOptimizeFlags OptimizeFlags { get; set; } + + NftInputFlags InputFlags { get; set; } + + NftOutputFlags OutputFlags { get; set; } + + NftDebugLevel DebugFlags { get; set; } + + void BufferOutput(); + + void BufferError(); + + string? GetOutputBuffer(); + + string? GetErrorBuffer(); + + void RunCommand(string commandText); + + void RunCommandFromFile(string path); +} diff --git a/src/LibNftables/LibNftables.csproj b/src/LibNftables/LibNftables.csproj index f110e65..3f60bf1 100644 --- a/src/LibNftables/LibNftables.csproj +++ b/src/LibNftables/LibNftables.csproj @@ -10,6 +10,30 @@ enable true $(WarningsAsErrors);CS1591 + $(NoWarn);NU5128 + LibNftables + 0.1.0 + LibNftables Contributors + Command-centric .NET wrapper for system-installed libnftables on Linux x64. + nftables;netfilter;linux;interop + README.md + Initial usable release of the managed command-centric wrapper over libnftables. + false + true + $(TargetsForTfmSpecificBuildOutput);IncludeProjectReferenceBuildOutput + + + + + + + + + + + diff --git a/src/LibNftables/NftContext.cs b/src/LibNftables/NftContext.cs index 69a4442..006a74a 100644 --- a/src/LibNftables/NftContext.cs +++ b/src/LibNftables/NftContext.cs @@ -10,7 +10,7 @@ namespace LibNftables; /// Use for high-level workflows. This type is intended for advanced scenarios /// requiring direct control over context flags and command execution. /// -public sealed class NftContext : IDisposable +public sealed class NftContext : INftContextHandle { private SWIGTYPE_p_nft_ctx? _ctx; diff --git a/src/LibNftables/NftRuntimeGuard.cs b/src/LibNftables/NftRuntimeGuard.cs index 42d4a8e..d584ffc 100644 --- a/src/LibNftables/NftRuntimeGuard.cs +++ b/src/LibNftables/NftRuntimeGuard.cs @@ -26,7 +26,8 @@ internal static class NftRuntimeGuard { if (!OperatingSystem.IsLinux()) { - throw new NftUnsupportedException("libnftables managed API supports Linux only."); + throw new NftUnsupportedException( + "libnftables managed API supports Linux only because it depends on the system libnftables runtime."); } if (RuntimeInformation.ProcessArchitecture != Architecture.X64) @@ -50,7 +51,7 @@ internal static class NftRuntimeGuard TypeInitializationException) { var wrapped = new NftNativeLoadException( - "Failed to load native libnftables runtime. Ensure system package/lib is installed and loadable.", + "Failed to load the native libnftables runtime. Ensure libnftables is installed on the system and the bundled Linux x64 wrapper can be loaded.", ex); failure = wrapped; initialized = true; diff --git a/src/LibNftables/NftablesClient.cs b/src/LibNftables/NftablesClient.cs index 42e6d19..c589ec1 100644 --- a/src/LibNftables/NftablesClient.cs +++ b/src/LibNftables/NftablesClient.cs @@ -13,7 +13,7 @@ namespace LibNftables; public sealed class NftablesClient : INftablesClient { private readonly NftablesClientOptions _options; - private readonly System.Func _contextFactory; + private readonly System.Func _contextFactory; /// /// Initializes a new instance of the class. @@ -26,11 +26,17 @@ public sealed class NftablesClient : INftablesClient { } - internal NftablesClient(NftablesClientOptions? options, System.Func contextFactory) + internal NftablesClient( + NftablesClientOptions? options, + System.Func contextFactory, + bool skipRuntimeGuard = false) { _options = options ?? new NftablesClientOptions(); _contextFactory = contextFactory; - NftRuntimeGuard.EnsureInitializedForLinuxX64(); + if (!skipRuntimeGuard) + { + NftRuntimeGuard.EnsureInitializedForLinuxX64(); + } } /// @@ -131,7 +137,7 @@ public sealed class NftablesClient : INftablesClient { ct.ThrowIfCancellationRequested(); - using NftContext ctx = _contextFactory(); + using INftContextHandle ctx = _contextFactory(); ConfigureContext(ctx, request, forceDryRun); if (!string.IsNullOrWhiteSpace(request.RulesetText)) @@ -146,7 +152,7 @@ public sealed class NftablesClient : INftablesClient return new CommandExecutionResult(ctx.GetOutputBuffer(), ctx.GetErrorBuffer()); } - private void ConfigureContext(NftContext ctx, NftApplyRequest request, bool? forceDryRun) + private void ConfigureContext(INftContextHandle ctx, NftApplyRequest request, bool? forceDryRun) { ctx.DryRun = forceDryRun ?? request.DryRun; ctx.InputFlags = _options.DefaultInputFlags | request.InputFlags; diff --git a/src/LibNftables/Properties/AssemblyInfo.cs b/src/LibNftables/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..343bc31 --- /dev/null +++ b/src/LibNftables/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("LibNftables.Tests")] diff --git a/tests/LibNftables.Tests/NativeTestSupport.cs b/tests/LibNftables.Tests/NativeTestSupport.cs new file mode 100644 index 0000000..c2aedc5 --- /dev/null +++ b/tests/LibNftables.Tests/NativeTestSupport.cs @@ -0,0 +1,64 @@ +using System.Globalization; + +namespace LibNftables.Tests; + +internal static class NativeTestSupport +{ + internal static bool HasCapNetAdmin() + { + const int capNetAdminBit = 12; + const ulong mask = 1UL << capNetAdminBit; + + try + { + foreach (var line in File.ReadLines("/proc/self/status")) + { + if (!line.StartsWith("CapEff:", StringComparison.Ordinal)) + { + continue; + } + + var hex = line["CapEff:".Length..].Trim(); + if (ulong.TryParse(hex, NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out var value)) + { + return (value & mask) != 0; + } + } + } + catch + { + // If capability probing fails, keep tests conservative. + } + + return false; + } + + internal static bool TryCreateContext(out NftContext context) + { + try + { + context = new NftContext(); + return true; + } + catch (DllNotFoundException) + { + context = null!; + return false; + } + catch (TypeInitializationException ex) when (ex.InnerException is DllNotFoundException) + { + context = null!; + return false; + } + catch (BadImageFormatException) + { + context = null!; + return false; + } + catch (EntryPointNotFoundException) + { + context = null!; + return false; + } + } +} diff --git a/tests/LibNftables.Tests/NftContextTests.cs b/tests/LibNftables.Tests/NftContextTests.cs index 79fce66..259b24d 100644 --- a/tests/LibNftables.Tests/NftContextTests.cs +++ b/tests/LibNftables.Tests/NftContextTests.cs @@ -1,5 +1,3 @@ -using System.Globalization; - namespace LibNftables.Tests; public sealed class NftContextTests @@ -7,7 +5,7 @@ public sealed class NftContextTests [Fact] public void ContextFlagsRoundTrip_Works() { - if (!TryCreateContext(out var ctx)) + if (!NativeTestSupport.TryCreateContext(out var ctx)) { return; } @@ -38,7 +36,7 @@ public sealed class NftContextTests [Fact] public void InvalidCommand_ThrowsNftExceptionWithErrorBuffer() { - if (!TryCreateContext(out var ctx)) + if (!NativeTestSupport.TryCreateContext(out var ctx)) { return; } @@ -57,12 +55,12 @@ public sealed class NftContextTests [Fact] public void ValidDryRunCommand_CanExecuteAndBufferOutput() { - if (!TryCreateContext(out var ctx)) + if (!NativeTestSupport.TryCreateContext(out var ctx)) { return; } - if (!HasCapNetAdmin()) + if (!NativeTestSupport.HasCapNetAdmin()) { return; } @@ -82,61 +80,4 @@ public sealed class NftContextTests } } - private static bool HasCapNetAdmin() - { - const int capNetAdminBit = 12; - const ulong mask = 1UL << capNetAdminBit; - - try - { - foreach (var line in File.ReadLines("/proc/self/status")) - { - if (!line.StartsWith("CapEff:", StringComparison.Ordinal)) - { - continue; - } - - var hex = line["CapEff:".Length..].Trim(); - if (ulong.TryParse(hex, NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out var value)) - { - return (value & mask) != 0; - } - } - } - catch - { - // If capability probing fails, keep test conservative. - } - - return false; - } - - private static bool TryCreateContext(out NftContext context) - { - try - { - context = new NftContext(); - return true; - } - catch (DllNotFoundException) - { - context = null!; - return false; - } - catch (TypeInitializationException ex) when (ex.InnerException is DllNotFoundException) - { - context = null!; - return false; - } - catch (BadImageFormatException) - { - context = null!; - return false; - } - catch (EntryPointNotFoundException) - { - context = null!; - return false; - } - } } diff --git a/tests/LibNftables.Tests/NftErrorTranslatorTests.cs b/tests/LibNftables.Tests/NftErrorTranslatorTests.cs new file mode 100644 index 0000000..0a0a7f6 --- /dev/null +++ b/tests/LibNftables.Tests/NftErrorTranslatorTests.cs @@ -0,0 +1,41 @@ +namespace LibNftables.Tests; + +public sealed class NftErrorTranslatorTests +{ + [Fact] + public void PermissionErrors_AreClassified() + { + NftException ex = NftErrorTranslator.FromOperationFailure("RunCommand", 1, "Operation not permitted"); + + Assert.IsType(ex); + Assert.Equal(1, ex.NativeErrorCode); + } + + [Fact] + public void ValidationErrors_AreClassified() + { + NftException ex = NftErrorTranslator.FromOperationFailure("RunCommand", 2, "syntax error, unexpected token"); + + Assert.IsType(ex); + Assert.Equal(2, ex.NativeErrorCode); + } + + [Fact] + public void UnsupportedErrors_AreClassified() + { + NftException ex = NftErrorTranslator.FromOperationFailure("RunCommand", 3, "operation not supported"); + + Assert.IsType(ex); + Assert.Equal(3, ex.NativeErrorCode); + } + + [Fact] + public void UnknownErrors_FallBackToBaseException() + { + NftException ex = NftErrorTranslator.FromOperationFailure("RunCommand", 4, "unexpected native failure"); + + Assert.IsType(ex); + Assert.Equal(4, ex.NativeErrorCode); + Assert.DoesNotContain("syntax error", ex.Message, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/tests/LibNftables.Tests/NftablesClientTests.cs b/tests/LibNftables.Tests/NftablesClientIntegrationTests.cs similarity index 70% rename from tests/LibNftables.Tests/NftablesClientTests.cs rename to tests/LibNftables.Tests/NftablesClientIntegrationTests.cs index 2173a11..87c3752 100644 --- a/tests/LibNftables.Tests/NftablesClientTests.cs +++ b/tests/LibNftables.Tests/NftablesClientIntegrationTests.cs @@ -1,23 +1,15 @@ namespace LibNftables.Tests; -public sealed class NftablesClientTests +public sealed class NftablesClientIntegrationTests { - [Fact] - public void Apply_WithBothTextAndFile_ThrowsValidationException() - { - var client = new NftablesClient(); - var request = new NftApplyRequest - { - RulesetText = "flush ruleset", - RulesetFilePath = "/tmp/does-not-matter.nft", - }; - - Assert.Throws(() => client.Apply(request)); - } - [Fact] public void Validate_InvalidRuleset_ReturnsInvalidResult() { + if (!CanCreateClient()) + { + return; + } + var client = new NftablesClient(); var request = NftApplyRequest.FromText("this is not valid nft syntax"); @@ -28,8 +20,13 @@ public sealed class NftablesClientTests } [Fact] - public async System.Threading.Tasks.Task ValidateAsync_InvalidRuleset_ReturnsInvalidResult() + public async Task ValidateAsync_InvalidRuleset_ReturnsInvalidResult() { + if (!CanCreateClient()) + { + return; + } + var client = new NftablesClient(); var request = NftApplyRequest.FromText("this is not valid nft syntax"); @@ -42,6 +39,11 @@ public sealed class NftablesClientTests [Fact] public void Apply_InvalidRuleset_ThrowsValidationException() { + if (!CanCreateClient()) + { + return; + } + var client = new NftablesClient(); var request = NftApplyRequest.FromText("this is not valid nft syntax"); @@ -51,6 +53,11 @@ public sealed class NftablesClientTests [Fact] public void Snapshot_WithInsufficientPrivileges_ThrowsPermissionOrReturnsRuleset() { + if (!CanCreateClient()) + { + return; + } + var client = new NftablesClient(); try @@ -63,4 +70,17 @@ public sealed class NftablesClientTests // Expected in unprivileged environments. } } + + private static bool CanCreateClient() + { + try + { + _ = new NftablesClient(); + return true; + } + catch (NftException) + { + return false; + } + } } diff --git a/tests/LibNftables.Tests/NftablesClientUnitTests.cs b/tests/LibNftables.Tests/NftablesClientUnitTests.cs new file mode 100644 index 0000000..848fd13 --- /dev/null +++ b/tests/LibNftables.Tests/NftablesClientUnitTests.cs @@ -0,0 +1,252 @@ +namespace LibNftables.Tests; + +public sealed class NftablesClientUnitTests +{ + [Fact] + public void Apply_WithBothTextAndFile_ThrowsValidationException() + { + var client = CreateClient(); + var request = new NftApplyRequest + { + RulesetText = "flush ruleset", + RulesetFilePath = "/tmp/does-not-matter.nft", + }; + + Assert.Throws(() => client.Apply(request)); + } + + [Fact] + public void Apply_WithMissingFile_ThrowsValidationException() + { + var client = CreateClient(); + var request = NftApplyRequest.FromFile("/tmp/does-not-exist.nft"); + + Assert.Throws(() => client.Apply(request)); + } + + [Fact] + public void Apply_NullRequest_ThrowsValidationException() + { + var client = CreateClient(); + + Assert.Throws(() => client.Apply(null!)); + } + + [Fact] + public void Validate_WhenCommandThrowsValidation_ReturnsInvalidResult() + { + var context = new FakeContext + { + CommandException = new NftValidationException("invalid ruleset", 7, "syntax error"), + }; + var client = CreateClient(() => context); + + NftValidationResult result = client.Validate(NftApplyRequest.FromText("invalid")); + + Assert.False(result.IsValid); + Assert.Equal("syntax error", result.Diagnostics); + } + + [Fact] + public async Task ValidateAsync_UsesSynchronousValidationFlow() + { + var context = new FakeContext + { + OutputBuffer = "ok", + ErrorBuffer = string.Empty, + }; + var client = CreateClient(() => context); + + NftValidationResult result = await client.ValidateAsync(NftApplyRequest.FromText("flush ruleset", dryRun: false)); + + Assert.True(result.IsValid); + Assert.True(context.DryRun); + Assert.Equal("ok", result.Output); + } + + [Fact] + public void Apply_WithFileRequest_UsesFileExecutionPath() + { + string path = Path.GetTempFileName(); + + try + { + var context = new FakeContext(); + var client = CreateClient(() => context); + + client.Apply(NftApplyRequest.FromFile(path)); + + Assert.Equal(path, context.LastFilePath); + Assert.Null(context.LastCommandText); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void Apply_PropagatesOptionsAndRequestFlags() + { + var context = new FakeContext(); + var options = new NftablesClientOptions + { + DefaultInputFlags = NftInputFlags.NoDns, + DefaultOutputFlags = NftOutputFlags.Json, + DefaultDebugFlags = NftDebugLevel.Parser, + DefaultOptimizeFlags = NftOptimizeFlags.Enabled, + }; + var request = NftApplyRequest.FromText("flush ruleset", dryRun: true); + request.InputFlags = NftInputFlags.Json; + request.OutputFlags = NftOutputFlags.Echo; + request.DebugFlags = NftDebugLevel.Scanner; + + var client = CreateClient(() => context, options); + + client.Apply(request); + + Assert.True(context.DryRun); + Assert.Equal(NftInputFlags.NoDns | NftInputFlags.Json, context.InputFlags); + Assert.Equal(NftOutputFlags.Json | NftOutputFlags.Echo, context.OutputFlags); + Assert.Equal(NftDebugLevel.Parser | NftDebugLevel.Scanner, context.DebugFlags); + Assert.Equal(NftOptimizeFlags.Enabled, context.OptimizeFlags); + Assert.True(context.BufferOutputCalled); + Assert.True(context.BufferErrorCalled); + } + + [Fact] + public void Snapshot_WithOutput_ReturnsSnapshot() + { + var context = new FakeContext + { + OutputBuffer = "table inet filter { }", + ErrorBuffer = string.Empty, + }; + var client = CreateClient(() => context); + + NftSnapshot snapshot = client.Snapshot(); + + Assert.Equal("table inet filter { }", snapshot.RulesetText); + Assert.Equal("list ruleset", context.LastCommandText); + } + + [Fact] + public void Snapshot_WithPermissionError_ThrowsPermissionException() + { + var context = new FakeContext + { + OutputBuffer = string.Empty, + ErrorBuffer = "Operation not permitted", + }; + var client = CreateClient(() => context); + + Assert.Throws(() => client.Snapshot()); + } + + [Fact] + public void Snapshot_WithEmptyOutputAndNoError_ReturnsFlushRulesetFallback() + { + var context = new FakeContext + { + OutputBuffer = string.Empty, + ErrorBuffer = string.Empty, + }; + var client = CreateClient(() => context); + + NftSnapshot snapshot = client.Snapshot(); + + Assert.Equal("flush ruleset", snapshot.RulesetText); + } + + [Fact] + public void Restore_WithNullSnapshot_ThrowsValidationException() + { + var client = CreateClient(); + + Assert.Throws(() => client.Restore(null!)); + } + + [Fact] + public void Restore_WithEmptyRuleset_ThrowsValidationException() + { + var client = CreateClient(); + + Assert.Throws(() => client.Restore(new NftSnapshot(" ", DateTimeOffset.UtcNow))); + } + + [Fact] + public async Task RestoreAsync_UsesSnapshotRulesetText() + { + var context = new FakeContext(); + var client = CreateClient(() => context); + var snapshot = new NftSnapshot("flush ruleset", DateTimeOffset.UtcNow); + + await client.RestoreAsync(snapshot); + + Assert.Equal("flush ruleset", context.LastCommandText); + } + + private static NftablesClient CreateClient( + Func? contextFactory = null, + NftablesClientOptions? options = null) + => new(options, contextFactory ?? (() => new FakeContext()), skipRuntimeGuard: true); + + private sealed class FakeContext : INftContextHandle + { + public bool DryRun { get; set; } + + public NftOptimizeFlags OptimizeFlags { get; set; } + + public NftInputFlags InputFlags { get; set; } + + public NftOutputFlags OutputFlags { get; set; } + + public NftDebugLevel DebugFlags { get; set; } + + public bool BufferOutputCalled { get; private set; } + + public bool BufferErrorCalled { get; private set; } + + public string? OutputBuffer { get; set; } + + public string? ErrorBuffer { get; set; } + + public string? LastCommandText { get; private set; } + + public string? LastFilePath { get; private set; } + + public Exception? CommandException { get; set; } + + public void BufferOutput() => BufferOutputCalled = true; + + public void BufferError() => BufferErrorCalled = true; + + public string? GetOutputBuffer() => OutputBuffer; + + public string? GetErrorBuffer() => ErrorBuffer; + + public void RunCommand(string commandText) + { + LastCommandText = commandText; + ThrowIfNeeded(); + } + + public void RunCommandFromFile(string path) + { + LastFilePath = path; + ThrowIfNeeded(); + } + + public void Dispose() + { + } + + private void ThrowIfNeeded() + { + if (CommandException is not null) + { + throw CommandException; + } + } + } +}