diff --git a/.gitea/workflows/quality.yaml b/.gitea/workflows/quality.yaml index 036564c..d5dc2ae 100644 --- a/.gitea/workflows/quality.yaml +++ b/.gitea/workflows/quality.yaml @@ -322,12 +322,11 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Install isolated Linux smoke prerequisites - run: sudo apt-get update && sudo apt-get install -y iproute2 nftables iptables ipset python3 curl util-linux coreutils - - name: Run shipped agent in isolated network and mount namespaces + - name: Run shipped agent in a dedicated namespace-capable helper + timeout-minutes: 10 run: | - python3 scripts/ci/linux-agent-fixture.test.py - sudo bash scripts/ci/linux-netns-smoke.sh + sh scripts/ci/linux-agent-container-smoke.test.sh + sh scripts/ci/linux-agent-container-smoke.sh docker-check: needs: [changes] diff --git a/docs/runtime-validation.md b/docs/runtime-validation.md index 0fb12b9..be038c5 100644 --- a/docs/runtime-validation.md +++ b/docs/runtime-validation.md @@ -4,7 +4,17 @@ Docker/Linux runtime and RouterOS hardware are unavailable in this Windows works Local verification: `node --test scripts/ci/sqlite-backup.test.mjs` passed on Windows / Node 25.5.0 (1 test): committed WAL contents, refusal to overwrite a backup, offline restore requirement, removal of stale sidecars and preservation of the old destination when the input is invalid. Windows sandbox denied atomic rename; the identical test passed after an approved sandbox escalation. Bash syntax and workflow YAML parsing passed. These checks do not verify Alpine, Node 22 or the container image. -## Build and publication +## Containerized Gitea Linux-agent runner correction + +The first pushed `linux-agent-smoke` job failed at `unshare: Operation not permitted`: its Gitea job container did not have the kernel capabilities needed by the nested namespace harness. The workflow now builds `scripts/ci/linux-agent-smoke.Dockerfile` and launches it using `linux-agent-container-smoke.sh`. Prerequisites and the checked-out agent/fixture/harness are copied into the helper image, so a remote Docker daemon needs no runner filesystem bind mounts. + +The helper has its own network/PID/mount namespaces, `--network none`, a read-only image filesystem and a bounded temporary filesystem. It drops all capabilities except `SYS_ADMIN` (namespace/mount operations) and `NET_ADMIN` (veth, nftables and ipset inside the test namespaces). It retains Docker's default seccomp policy, enables no-new-privileges and never receives privileged mode, host networking/PIDs, host mounts or a Docker socket. On AppArmor-enabled daemons only this helper uses `apparmor=unconfined`, because Docker's default AppArmor policy denies the required mounts. See Docker's [capability guidance](https://docs.docker.com/engine/containers/run/) and [AppArmor policy guidance](https://docs.docker.com/engine/security/apparmor/). + +The original harness still creates and verifies separate network/mount/PID namespaces before firewall mutations. A preflight namespace probe fails clearly when the daemon/kernel does not support the required isolation; there is no skip, privileged retry or host-firewall fallback. The daemon must support namespaces, IPv6, veth, nftables, iptables and ipset; the helper cannot load missing host kernel modules. Its immutable image ID is used for execution and its container/image are cleaned up while preserving the original test exit code. + +Because the harness masks `/etc`, the helper resolves all six Debian iptables/ip6tables/save/restore alternatives to direct binary links at build time. Only public protocol/service name databases from the helper image are restored into the private `/etc`; no runner `/etc` files or credentials are copied. Local validation covers YAML and shell syntax, the fixture ACK unit test and a fake-Docker wrapper regression (isolation flags, build-context behavior, cleanup and nonzero exit propagation). Docker is unavailable locally, so the corrected native helper run remains pending CI. + +## Image build and publication 2026-10-26 checkpoint: Docker CLI is still absent and `wsl --status` reports WSL is not installed. Local checks passed for the full-size fresh fixture/reuse refusal, selected-platform manifest validation and generated-schema drift check. These are not native container results. The load runner requires zero telemetry history/watermarks/totals, checks three unique raw reports and 200/400 deltas per agent plus 20000/40000 fleet totals, and reports report-ingestion and timeseries p95 independently. The first v2 counter establishes a baseline, so it contributes no fabricated traffic. diff --git a/scripts/ci/linux-agent-container-entry.sh b/scripts/ci/linux-agent-container-entry.sh new file mode 100644 index 0000000..a4ced4e --- /dev/null +++ b/scripts/ci/linux-agent-container-entry.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +# Fail before any firewall work if the daemon/kernel still forbids namespaces. +if ! unshare --net --mount --pid --fork --mount-proc true; then + echo 'Linux agent helper cannot create required namespaces. A rootful Docker daemon with namespace/netfilter support and the documented helper capabilities is required; smoke was not run.' >&2 + exit 1 +fi +python3 -B scripts/ci/linux-agent-fixture.test.py +export EVOFW_TEST_ETC_DATA=/work/helper-etc +exec bash scripts/ci/linux-netns-smoke.sh diff --git a/scripts/ci/linux-agent-container-smoke.sh b/scripts/ci/linux-agent-container-smoke.sh new file mode 100644 index 0000000..6d26275 --- /dev/null +++ b/scripts/ci/linux-agent-container-smoke.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env sh +# Run from the checkout root. Build context is sent to Docker, never bind-mounted. +set -eu +command -v docker >/dev/null +[ "$(docker info --format '{{.OSType}}')" = linux ] || { echo 'A Linux Docker daemon is required for the agent namespace smoke' >&2; exit 1; } +tmp="$(mktemp -d)" +name="evofw-linux-smoke-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}-$$" +image='' +cleanup() { + result=$? + trap - EXIT HUP INT TERM + docker rm -f "$name" >/dev/null 2>&1 || true + if [ -n "$image" ]; then docker image rm "$image" >/dev/null 2>&1 || true; fi + rm -rf "$tmp" + exit "$result" +} +trap cleanup EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM +docker build --file scripts/ci/linux-agent-smoke.Dockerfile --iidfile "$tmp/image-id" . +image="$(cat "$tmp/image-id")" +printf '%s\n' "$image" | grep -Eq '^sha256:[0-9a-f]{64}$' || { echo 'Missing immutable helper image ID' >&2; exit 1; } +set -- docker run --rm --name "$name" --network none \ + --cap-drop ALL --cap-add SYS_ADMIN --cap-add NET_ADMIN \ + --security-opt no-new-privileges:true \ + --read-only --tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \ + --pids-limit 256 --cpus 2 --memory 512m +# docker-default AppArmor denies the mount operations used by unshare/ip-netns. +# Only this disposable, networkless helper gets the mount-capable exception. +# Default seccomp remains enabled and adapts to the two granted capabilities. +security="$(docker info --format '{{json .SecurityOptions}}')" +if printf '%s' "$security" | grep -q apparmor; then + set -- "$@" --security-opt apparmor=unconfined +fi +set -- "$@" "$image" +"$@" diff --git a/scripts/ci/linux-agent-container-smoke.test.sh b/scripts/ci/linux-agent-container-smoke.test.sh new file mode 100644 index 0000000..0940f33 --- /dev/null +++ b/scripts/ci/linux-agent-container-smoke.test.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env sh +# Exercise the wrapper without a Docker daemon or any firewall operation. +set -eu +wrapper="$(pwd)/scripts/ci/linux-agent-container-smoke.sh" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT HUP INT TERM +mkdir "$tmp/bin" +cat >"$tmp/bin/docker" <<'DOCKER' +#!/usr/bin/env sh +set -eu +printf '%s\n' "$*" >>"$FAKE_LOG" +case "$1" in + info) + case "$*" in + *SecurityOptions*) if [ "$FAKE_APPARMOR" = true ]; then printf '["name=apparmor","name=seccomp,profile=default"]\n'; else printf '["name=seccomp,profile=default"]\n'; fi ;; + *) printf 'linux\n' ;; + esac ;; + build) + while [ "$#" -gt 0 ]; do + if [ "$1" = --iidfile ]; then shift; printf 'sha256:%064d\n' 0 >"$1"; break; fi + shift + done ;; + run) exit "$FAKE_RUN_EXIT" ;; + rm|image) exit 0 ;; + *) exit 98 ;; +esac +DOCKER +chmod +x "$tmp/bin/docker" +for expected in 0 23; do + log="$tmp/docker-$expected.log" + apparmor=false; [ "$expected" = 0 ] || apparmor=true + if PATH="$tmp/bin:$PATH" FAKE_LOG="$log" FAKE_APPARMOR="$apparmor" FAKE_RUN_EXIT="$expected" sh "$wrapper"; then actual=0; else actual=$?; fi + [ "$actual" = "$expected" ] || { echo 'Wrapper did not preserve the container exit code' >&2; exit 1; } + grep -F 'build --file scripts/ci/linux-agent-smoke.Dockerfile --iidfile ' "$log" >/dev/null + grep -F ' --network none --cap-drop ALL --cap-add SYS_ADMIN --cap-add NET_ADMIN ' "$log" >/dev/null + grep -F ' --security-opt no-new-privileges:true --read-only --tmpfs /tmp:' "$log" >/dev/null + grep -F 'image rm sha256:' "$log" >/dev/null + grep -E '^rm -f evofw-linux-smoke-' "$log" >/dev/null + if grep -E -- '(--privileged|--network host|--pid host|--mount|--volume|docker.sock|seccomp=)' "$log" >/dev/null; then echo 'Unsafe helper isolation flags' >&2; exit 1; fi + if [ "$apparmor" = true ]; then grep -F ' --security-opt apparmor=unconfined ' "$log" >/dev/null + elif grep -F 'apparmor=unconfined' "$log" >/dev/null; then echo 'Unneeded AppArmor exception' >&2; exit 1 + fi +done +echo 'Helper wrapper regression passed (flags, remote context, exit status, cleanup)' diff --git a/scripts/ci/linux-agent-smoke.Dockerfile b/scripts/ci/linux-agent-smoke.Dockerfile new file mode 100644 index 0000000..ed279aa --- /dev/null +++ b/scripts/ci/linux-agent-smoke.Dockerfile @@ -0,0 +1,23 @@ +FROM debian:bookworm-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash ca-certificates coreutils curl iproute2 ipset iptables netbase nftables python3 util-linux \ + && rm -rf /var/lib/apt/lists/* +# The harness masks /etc. Debian's public xtables links normally traverse +# /etc/alternatives, so resolve them while that directory is still visible. +RUN set -eu; for tool in iptables iptables-save iptables-restore ip6tables ip6tables-save ip6tables-restore; do \ + binary="$(readlink -f "$(command -v "$tool")")"; \ + test -x "$binary"; \ + case "$binary" in /etc/*) exit 1;; esac; \ + ln -sf "$binary" "/usr/sbin/$tool"; \ + test "$(readlink -f "/usr/sbin/$tool")" = "$binary"; \ + done +WORKDIR /work +# Public protocol/service name databases only; never copy the runner's /etc. +RUN mkdir helper-etc && cp /etc/protocols /etc/services helper-etc/ +# COPY makes this work with a remote Docker daemon; no runner path is mounted. +COPY apps/api/src/agent-scripts/evofw-firewall.sh apps/api/src/agent-scripts/evofw-firewall.sh +COPY scripts/ci/linux-netns-smoke.sh scripts/ci/linux-agent-fixture.py scripts/ci/linux-agent-fixture.test.py scripts/ci/linux-agent-container-entry.sh scripts/ci/ +RUN bash -n scripts/ci/linux-netns-smoke.sh scripts/ci/linux-agent-container-entry.sh \ + && python3 -B scripts/ci/linux-agent-fixture.test.py +ENTRYPOINT ["bash", "scripts/ci/linux-agent-container-entry.sh"] diff --git a/scripts/ci/linux-netns-smoke.sh b/scripts/ci/linux-netns-smoke.sh index b909a3c..ecd1c9c 100644 --- a/scripts/ci/linux-netns-smoke.sh +++ b/scripts/ci/linux-netns-smoke.sh @@ -24,6 +24,10 @@ export NO_PROXY='*' mount --make-rprivate / # Agent hardcoded config/state/log paths are private mounts, never host writes. for directory in /etc /var/lib /var/log /run; do mount -t tmpfs tmpfs "$directory"; done +if [[ -n "${EVOFW_TEST_ETC_DATA:-}" ]]; then + [[ "$EVOFW_TEST_ETC_DATA" == /work/helper-etc ]] + cp "$EVOFW_TEST_ETC_DATA/protocols" "$EVOFW_TEST_ETC_DATA/services" /etc/ +fi mkdir -p /etc/evofw /var/lib/evofw ip link set lo up ip netns add client