diff --git a/.gitea/workflows/smoke.yml b/.gitea/workflows/smoke.yml index 1520de3..be277a9 100644 --- a/.gitea/workflows/smoke.yml +++ b/.gitea/workflows/smoke.yml @@ -37,6 +37,43 @@ jobs: gcc --version | head -n 1 pkg-config --modversion libnftables + - name: Detect runner mode + run: | + set -euo pipefail + + uid="$(id -u)" + echo "RUNNER_UID=$uid" >> "$GITHUB_ENV" + + cap_net_admin=0 + if [ -r /proc/self/status ]; then + cap_eff_hex="$(awk '/^CapEff:/ { print $2 }' /proc/self/status)" + if [ -n "${cap_eff_hex:-}" ]; then + cap_eff_value=$((16#$cap_eff_hex)) + if (( (cap_eff_value & (1 << 12)) != 0 )); then + cap_net_admin=1 + fi + fi + fi + + echo "RUNNER_HAS_CAP_NET_ADMIN=$cap_net_admin" >> "$GITHUB_ENV" + + if [ "$uid" -eq 0 ]; then + echo "RUNNER_IS_ROOT=1" >> "$GITHUB_ENV" + echo "Root runner detected (uid 0)." + + if [ "$cap_net_admin" -eq 1 ]; then + echo "RUN_PRIVILEGED_TESTS=1" >> "$GITHUB_ENV" + echo "Privileged test lane enabled." + else + echo "RUN_PRIVILEGED_TESTS=0" >> "$GITHUB_ENV" + echo "::warning::Root runner detected, but CAP_NET_ADMIN is unavailable. Privileged tests will be skipped." + fi + else + echo "RUNNER_IS_ROOT=0" >> "$GITHUB_ENV" + echo "RUN_PRIVILEGED_TESTS=0" >> "$GITHUB_ENV" + echo "Non-root runner detected; smoke-only test lane enabled." + fi + - name: Install .NET SDK run: | set -euo pipefail @@ -58,7 +95,13 @@ jobs: set -euo pipefail "$HOME/.dotnet/dotnet" build --no-restore - - name: Test + - name: Smoke tests run: | set -euo pipefail - "$HOME/.dotnet/dotnet" test LibNftables.slnx --no-build + LIBNFTABLES_RUN_PRIVILEGED_TESTS=0 "$HOME/.dotnet/dotnet" test LibNftables.slnx --no-build --filter "Category!=Privileged" + + - name: Privileged tests + if: env.RUN_PRIVILEGED_TESTS == '1' + run: | + set -euo pipefail + LIBNFTABLES_RUN_PRIVILEGED_TESTS=1 "$HOME/.dotnet/dotnet" test LibNftables.slnx --no-build --filter "Category=Privileged" diff --git a/README.md b/README.md index 041b2c8..4436be7 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ The test suite contains: - 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 +- Privileged tests that only run when `LIBNFTABLES_RUN_PRIVILEGED_TESTS=1`, `uid == 0`, and `CAP_NET_ADMIN` is available ## Gitea Smoke CI @@ -69,12 +69,13 @@ The repository includes a Gitea Actions smoke workflow at `.gitea/workflows/smok - Trigger: push and pull request - Runner label: `debian-13` -- Job model: non-root smoke verification +- Job model: root-aware smoke verification - Workflow actions: - bootstrap the .NET 10 SDK inside the job - restore - build - - run `dotnet test` + - always run the non-privileged smoke test lane + - run the privileged test lane when the runner process is `uid 0` Important runner prerequisites: @@ -85,7 +86,15 @@ Important runner prerequisites: - `pkg-config` - system-installed `libnftables` development/runtime packages discoverable by `pkg-config` -The job does not attempt package-manager installs or privilege escalation. It is intended to catch restore/build/test regressions, not to provide privileged nftables coverage. +The job does not attempt package-manager installs or privilege escalation. It is intended to catch restore/build/test regressions with opportunistic privileged smoke coverage on root runners. + +The workflow auto-detects `id -u` at runtime: + +- non-root runners stay on the smoke-only path +- root runners enable `LIBNFTABLES_RUN_PRIVILEGED_TESTS=1` +- root runners without effective `CAP_NET_ADMIN` emit a warning and skip the privileged lane + +For local runs, `dotnet test` keeps privileged tests disabled by default. To opt in intentionally, set `LIBNFTABLES_RUN_PRIVILEGED_TESTS=1` in an environment where the process is running as root with `CAP_NET_ADMIN`. ## High-Level Example diff --git a/tests/LibNftables.Tests/NativeTestSupport.cs b/tests/LibNftables.Tests/NativeTestSupport.cs index c2aedc5..7bfac2d 100644 --- a/tests/LibNftables.Tests/NativeTestSupport.cs +++ b/tests/LibNftables.Tests/NativeTestSupport.cs @@ -4,6 +4,58 @@ namespace LibNftables.Tests; internal static class NativeTestSupport { + private const string PrivilegedTestsEnvironmentVariable = "LIBNFTABLES_RUN_PRIVILEGED_TESTS"; + + internal static bool IsRunningAsRoot() + { + if (!OperatingSystem.IsLinux()) + { + return false; + } + + try + { + foreach (var line in File.ReadLines("/proc/self/status")) + { + if (!line.StartsWith("Uid:", StringComparison.Ordinal)) + { + continue; + } + + var parts = line["Uid:".Length..] + .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + return parts.Length > 0 && parts[0] == "0"; + } + } + catch + { + // If uid probing fails, keep tests conservative. + } + + return false; + } + + internal static bool PrivilegedTestsRequested() + { + var value = Environment.GetEnvironmentVariable(PrivilegedTestsEnvironmentVariable); + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + return value.Equals("1", StringComparison.Ordinal) + || value.Equals("true", StringComparison.OrdinalIgnoreCase) + || value.Equals("yes", StringComparison.OrdinalIgnoreCase); + } + + internal static bool ShouldRunPrivilegedTests() + { + return PrivilegedTestsRequested() + && IsRunningAsRoot() + && HasCapNetAdmin(); + } + internal static bool HasCapNetAdmin() { const int capNetAdminBit = 12; diff --git a/tests/LibNftables.Tests/NftContextTests.cs b/tests/LibNftables.Tests/NftContextTests.cs index 259b24d..de4c2ea 100644 --- a/tests/LibNftables.Tests/NftContextTests.cs +++ b/tests/LibNftables.Tests/NftContextTests.cs @@ -53,6 +53,7 @@ public sealed class NftContextTests } [Fact] + [Trait("Category", "Privileged")] public void ValidDryRunCommand_CanExecuteAndBufferOutput() { if (!NativeTestSupport.TryCreateContext(out var ctx)) @@ -60,7 +61,7 @@ public sealed class NftContextTests return; } - if (!NativeTestSupport.HasCapNetAdmin()) + if (!NativeTestSupport.ShouldRunPrivilegedTests()) { return; } diff --git a/tests/LibNftables.Tests/NftablesClientIntegrationTests.cs b/tests/LibNftables.Tests/NftablesClientIntegrationTests.cs index 5b8827c..e29f135 100644 --- a/tests/LibNftables.Tests/NftablesClientIntegrationTests.cs +++ b/tests/LibNftables.Tests/NftablesClientIntegrationTests.cs @@ -71,6 +71,27 @@ public sealed class NftablesClientIntegrationTests } } + [Fact] + [Trait("Category", "Privileged")] + public void Snapshot_WithPrivilegedLane_ReturnsRuleset() + { + if (!CanCreateClient()) + { + return; + } + + if (!NativeTestSupport.ShouldRunPrivilegedTests()) + { + return; + } + + var client = new NftablesClient(); + + NftSnapshot snapshot = client.Snapshot(); + + Assert.False(string.IsNullOrWhiteSpace(snapshot.RulesetText)); + } + [Fact] public void ValidateRuleset_WithTypedSetDefinition_ReturnsValidResult() {