mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-30 19:46:15 +00:00
Add firewall integration suite covering fips0 default-deny baseline
End-to-end exercise the v0.3.0 nftables firewall baseline so the
security claim — "services on fips0 are not exposed by default" — is
validated in CI rather than by operator opt-in. The packaging postinst
state is pinned by the deb-install matrix; this suite pins the actual
ruleset behavior.
testing/firewall/ — new directory mirroring the acl-allowlist
precedent (kept in its own directory, not extending testing/static):
docker-compose.yml (52 lines): two FIPS containers on bridge
172.32.0.0/24, peered over UDP/2121. node-b mounts
packaging/common/fips.nft RO at /etc/fips/fips.nft and a generated
drop-in services.nft at /etc/fips/fips.d/.
test.sh (247 lines): four-case asserter
(a) curl from node-a to node-b:8000 → DROP (port not allowlisted;
terminal counter drop fires)
(b) curl from node-b to node-a:8000 → 200 OK (reply traverses
node-b's ct state established,related accept)
(c) ping6 a→b → success (icmpv6 echo-request accept)
(d) nc -z a→b:22 → success (drop-in tcp dport 22 accept honored
via include "/etc/fips/fips.d/*.nft")
Plus drop-counter check after case (a) confirms the dropped
connection actually hit the chain's terminal counter drop.
generate-configs.sh (111 lines): mirrors acl-allowlist generator,
produces the drop-in services.nft + fips configs.
README.md (111 lines): how to run, expected output, design notes.
.gitignore: ignores generated-configs/.
testing/ci-local.sh: FIREWALL_SUITES=(firewall) array + run_firewall
runner; dispatch in run_integration and run_suite mirroring
run_acl_allowlist.
.github/workflows/ci.yml: matrix row {suite: firewall, type:
firewall} + 3 steps gated on matrix.type == 'firewall' between
acl-allowlist and gateway. Reuses fips-linux artifact + fips-test:
latest image.
Activation note: the unified test image does not run systemd, so
test.sh invokes the fips-firewall.service ExecStart
(/usr/sbin/nft -f /etc/fips/fips.nft) directly. The systemd-unit
enablement path is covered by the deb-install matrix; this suite
exercises what the unit configures, not how it gets started.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
generated-configs
|
||||
@@ -0,0 +1,111 @@
|
||||
# Firewall Baseline Test
|
||||
|
||||
End-to-end exercise of the production fips0 nftables baseline at
|
||||
`packaging/common/fips.nft`. Closes the v0.3.0 audit gap that the
|
||||
default-deny + conntrack + drop-in semantics had no integration coverage.
|
||||
|
||||
## What this exercises
|
||||
|
||||
The `fips.nft` baseline polices ONLY the fips0 mesh interface and
|
||||
implements default-deny inbound. This suite asserts the four behaviors
|
||||
documented in the file's header are actually true on a live mesh:
|
||||
|
||||
- **(a)** Unallowed inbound on fips0 is **dropped**
|
||||
- **(b)** Outbound-initiated flows get their reply via the
|
||||
`ct state established,related accept` rule
|
||||
- **(c)** ICMPv6 echo-request is **accepted** (ping6 reachability)
|
||||
- **(d)** A drop-in `.nft` file under `/etc/fips/fips.d/` adds an
|
||||
allowlisted port and that port is **accepted**
|
||||
|
||||
A drop-counter check after case (a) confirms the connection was
|
||||
actively DROP'd by the fips chain (not silently unrouted).
|
||||
|
||||
## Topology
|
||||
|
||||
Two FIPS nodes peered over UDP on a Docker bridge network:
|
||||
|
||||
| Container | Hostname | docker IPv4 | Firewall |
|
||||
|-------------------------|----------|---------------|----------|
|
||||
| `fips-fw-container-a` | `host-a` | 172.32.0.10 | none (probe) |
|
||||
| `fips-fw-container-b` | `host-b` | 172.32.0.11 | `fips.nft` + drop-in |
|
||||
|
||||
`node-b` mounts the production `packaging/common/fips.nft` read-only at
|
||||
`/etc/fips/fips.nft`, plus a drop-in at `/etc/fips/fips.d/services.nft`
|
||||
containing `tcp dport 22 accept`. `node-a` is unfirewalled and serves
|
||||
as the probe origin.
|
||||
|
||||
Both containers run the unified test image's `default` mode, which
|
||||
starts dnsmasq + sshd (port 22) + iperf3 + python http.server on
|
||||
port 8000 + the FIPS daemon.
|
||||
|
||||
## fips-firewall.service activation
|
||||
|
||||
The production unit's ExecStart is:
|
||||
|
||||
```text
|
||||
ExecStart=/usr/sbin/nft -f /etc/fips/fips.nft
|
||||
```
|
||||
|
||||
The unified test image does not run systemd, so `test.sh` invokes the
|
||||
same `nft -f` command directly inside `node-b` after fips0 is up and
|
||||
peering has converged. The deb-install harness covers the systemd
|
||||
unit-enablement path under real systemd separately.
|
||||
|
||||
## Run
|
||||
|
||||
Build the Linux binaries and test image:
|
||||
|
||||
```bash
|
||||
./testing/scripts/build.sh --no-docker
|
||||
```
|
||||
|
||||
Run the suite:
|
||||
|
||||
```bash
|
||||
./testing/firewall/test.sh
|
||||
```
|
||||
|
||||
`test.sh` regenerates fixtures automatically before starting Docker.
|
||||
Use `--skip-build` to reuse the existing release binaries. Use
|
||||
`--keep-up` to leave the containers running for inspection.
|
||||
|
||||
## Expected output shape
|
||||
|
||||
```text
|
||||
=== Generating firewall fixtures
|
||||
=== Starting firewall harness
|
||||
=== Waiting for fips0 on both nodes
|
||||
=== Waiting for peer convergence
|
||||
=== Resolving fips0 addresses
|
||||
node-a: fd97:...
|
||||
node-b: fd97:...
|
||||
=== Activating fips-firewall on fips-fw-container-b
|
||||
PASS: fips-fw-container-b: fips.nft baseline + drop-in loaded
|
||||
=== Case (c): ICMPv6 echo-request to firewalled node
|
||||
PASS: (c) ICMPv6 ping node-a → node-b accepted
|
||||
=== Case (a): unallowed inbound TCP/8000 from node-a → node-b
|
||||
PASS: (a) inbound TCP/8000 blocked (curl rc=28)
|
||||
=== Case (b): node-b initiates outbound TCP, expects reply via conntrack
|
||||
PASS: (b) outbound from node-b got HTTP 200 via conntrack reply path
|
||||
=== Case (d): drop-in allowlisted TCP/22 from node-a → node-b
|
||||
PASS: (d) drop-in allowlisted TCP/22 reachable
|
||||
=== Drop counter incremented (case a should have ticked it)
|
||||
PASS: drop counter = N (case a was actually dropped, not just unrouted)
|
||||
=== Firewall integration test passed
|
||||
```
|
||||
|
||||
## Inspect the loaded ruleset
|
||||
|
||||
```bash
|
||||
docker exec fips-fw-container-b nft list table inet fips
|
||||
```
|
||||
|
||||
## Stop and clean up
|
||||
|
||||
```bash
|
||||
docker compose -f testing/firewall/docker-compose.yml down
|
||||
```
|
||||
|
||||
## Generated fixture location
|
||||
|
||||
`testing/firewall/generated-configs/` (gitignored).
|
||||
@@ -0,0 +1,52 @@
|
||||
networks:
|
||||
fw-net:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.32.0.0/24
|
||||
|
||||
x-fips-common: &fips-common
|
||||
build:
|
||||
context: ../docker
|
||||
image: fips-test:latest
|
||||
entrypoint: ["/usr/local/bin/entrypoint.sh"]
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- NET_RAW
|
||||
devices:
|
||||
- /dev/net/tun:/dev/net/tun
|
||||
sysctls:
|
||||
- net.ipv6.conf.all.disable_ipv6=0
|
||||
restart: "no"
|
||||
environment:
|
||||
- FIPS_TEST_MODE=default
|
||||
- RUST_LOG=info,fips::node=debug
|
||||
|
||||
services:
|
||||
service-a:
|
||||
<<: *fips-common
|
||||
container_name: fips-fw-container-a
|
||||
hostname: host-a
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/node-a/hosts:/etc/fips/hosts:ro
|
||||
- ./generated-configs/node-a/fips.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs/node-a/fips.key:/etc/fips/fips.key:ro
|
||||
networks:
|
||||
fw-net:
|
||||
ipv4_address: 172.32.0.10
|
||||
|
||||
service-b:
|
||||
<<: *fips-common
|
||||
container_name: fips-fw-container-b
|
||||
hostname: host-b
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/node-b/hosts:/etc/fips/hosts:ro
|
||||
- ./generated-configs/node-b/fips.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs/node-b/fips.key:/etc/fips/fips.key:ro
|
||||
- ../../packaging/common/fips.nft:/etc/fips/fips.nft:ro
|
||||
- ./generated-configs/node-b/fips.d:/etc/fips/fips.d:ro
|
||||
networks:
|
||||
fw-net:
|
||||
ipv4_address: 172.32.0.11
|
||||
Executable
+111
@@ -0,0 +1,111 @@
|
||||
#!/bin/bash
|
||||
# Generate fixtures for the firewall integration test.
|
||||
#
|
||||
# Two FIPS nodes (a, b). node-b mounts the production fips.nft baseline
|
||||
# plus a single drop-in (.nft) under /etc/fips/fips.d/ that allows TCP
|
||||
# port 22 inbound — the test asserts this is honored. node-a is a
|
||||
# probe-only node with no firewall.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
GENERATED_DIR="$SCRIPT_DIR/generated-configs"
|
||||
|
||||
# Deterministic test identities (mirrors the acl-allowlist style).
|
||||
NPUB_A="npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m"
|
||||
KEY_A="0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
|
||||
|
||||
NPUB_B="npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le"
|
||||
KEY_B="b102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fb0"
|
||||
|
||||
write_file() {
|
||||
local path="$1"
|
||||
mkdir -p "$(dirname "$path")"
|
||||
cat > "$path"
|
||||
}
|
||||
|
||||
write_hosts_file() {
|
||||
local node="$1"
|
||||
write_file "$GENERATED_DIR/$node/hosts" <<EOF
|
||||
node-a $NPUB_A
|
||||
node-b $NPUB_B
|
||||
EOF
|
||||
}
|
||||
|
||||
echo "Generating firewall fixtures..."
|
||||
rm -rf "$GENERATED_DIR"
|
||||
|
||||
# ── node-a ────────────────────────────────────────────────────────────
|
||||
write_file "$GENERATED_DIR/node-a/fips.yaml" <<EOF
|
||||
node:
|
||||
identity:
|
||||
persistent: true
|
||||
|
||||
tun:
|
||||
enabled: true
|
||||
name: fips0
|
||||
mtu: 1280
|
||||
|
||||
dns:
|
||||
enabled: true
|
||||
|
||||
transports:
|
||||
udp:
|
||||
bind_addr: "0.0.0.0:2121"
|
||||
|
||||
peers:
|
||||
- npub: "$NPUB_B"
|
||||
alias: "node-b"
|
||||
addresses:
|
||||
- transport: udp
|
||||
addr: "172.32.0.11:2121"
|
||||
connect_policy: auto_connect
|
||||
EOF
|
||||
|
||||
write_file "$GENERATED_DIR/node-a/fips.key" <<EOF
|
||||
$KEY_A
|
||||
EOF
|
||||
|
||||
# ── node-b ────────────────────────────────────────────────────────────
|
||||
write_file "$GENERATED_DIR/node-b/fips.yaml" <<EOF
|
||||
node:
|
||||
identity:
|
||||
persistent: true
|
||||
|
||||
tun:
|
||||
enabled: true
|
||||
name: fips0
|
||||
mtu: 1280
|
||||
|
||||
dns:
|
||||
enabled: true
|
||||
|
||||
transports:
|
||||
udp:
|
||||
bind_addr: "0.0.0.0:2121"
|
||||
|
||||
peers:
|
||||
- npub: "$NPUB_A"
|
||||
alias: "node-a"
|
||||
addresses:
|
||||
- transport: udp
|
||||
addr: "172.32.0.10:2121"
|
||||
connect_policy: auto_connect
|
||||
EOF
|
||||
|
||||
write_file "$GENERATED_DIR/node-b/fips.key" <<EOF
|
||||
$KEY_B
|
||||
EOF
|
||||
|
||||
# ── node-b drop-in: allow inbound TCP/22 (Case d) ─────────────────────
|
||||
# The simplest possible operator-supplied allowance, matching the
|
||||
# fips.nft header example. The test asserts this rule unblocks an
|
||||
# otherwise-DROP'd TCP/22 SYN.
|
||||
write_file "$GENERATED_DIR/node-b/fips.d/services.nft" <<'EOF'
|
||||
tcp dport 22 accept
|
||||
EOF
|
||||
|
||||
write_hosts_file node-a
|
||||
write_hosts_file node-b
|
||||
|
||||
echo "Firewall fixtures written to $GENERATED_DIR"
|
||||
Executable
+247
@@ -0,0 +1,247 @@
|
||||
#!/bin/bash
|
||||
# Integration test for the fips0 nftables baseline (packaging/common/fips.nft).
|
||||
#
|
||||
# Asserts the four behaviors documented in the fips.nft header:
|
||||
# (a) unallowed inbound on fips0 → DROP
|
||||
# (b) outbound-initiated reply → conntrack established/related ACCEPT
|
||||
# (c) ICMPv6 echo-request → ACCEPT
|
||||
# (d) drop-in allowlisted port → ACCEPT
|
||||
#
|
||||
# fips-firewall.service activation: the unit's ExecStart is
|
||||
# `/usr/sbin/nft -f /etc/fips/fips.nft`. The test image does not run
|
||||
# systemd, so this script invokes the same nft command directly inside
|
||||
# the container after fips0 is up. The full deb-install harness covers
|
||||
# the systemd unit-enablement path separately.
|
||||
#
|
||||
# Usage: ./test.sh [--skip-build] [--keep-up]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
TESTING_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
COMPOSE_FILE="$SCRIPT_DIR/docker-compose.yml"
|
||||
GENERATE_CONFIGS="$SCRIPT_DIR/generate-configs.sh"
|
||||
|
||||
CONTAINER_A="fips-fw-container-a"
|
||||
CONTAINER_B="fips-fw-container-b"
|
||||
|
||||
NPUB_A="npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m"
|
||||
NPUB_B="npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le"
|
||||
|
||||
# Port not present in any drop-in. Used for case (a) to assert DROP.
|
||||
UNALLOWED_PORT=8000
|
||||
# Port present in node-b's fips.d drop-in. Used for case (d) to assert ACCEPT.
|
||||
ALLOWED_PORT=22
|
||||
# Port that node-a listens on for the conntrack reply test (case b).
|
||||
OUTBOUND_TARGET_PORT=8000
|
||||
|
||||
SKIP_BUILD=false
|
||||
KEEP_UP=false
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--skip-build) SKIP_BUILD=true; shift ;;
|
||||
--keep-up) KEEP_UP=true; shift ;;
|
||||
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
cleanup() {
|
||||
if [ "$KEEP_UP" = false ]; then
|
||||
docker compose -f "$COMPOSE_FILE" down >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
log() {
|
||||
echo "=== $*"
|
||||
}
|
||||
|
||||
pass() {
|
||||
echo "PASS: $*"
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Wait for fips0 to exist and have a global IPv6 address inside container.
|
||||
wait_for_fips0() {
|
||||
local container="$1"
|
||||
local timeout="${2:-30}"
|
||||
for _ in $(seq 1 "$timeout"); do
|
||||
if docker exec "$container" ip -6 addr show fips0 2>/dev/null \
|
||||
| grep -qE 'inet6 fd[0-9a-f]+:'; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
fail "$container fips0 did not come up within ${timeout}s"
|
||||
}
|
||||
|
||||
# Wait for the peer count on a container to reach the expected value.
|
||||
wait_for_peers_exact() {
|
||||
local container="$1"
|
||||
local expected_count="$2"
|
||||
local timeout="${3:-30}"
|
||||
for _ in $(seq 1 "$timeout"); do
|
||||
local count
|
||||
count=$(docker exec "$container" fipsctl show peers 2>/dev/null \
|
||||
| python3 -c 'import json,sys; data=json.load(sys.stdin); print(sum(1 for p in data.get("peers", []) if p.get("connectivity") == "connected"))' 2>/dev/null || echo 0)
|
||||
if [ "$count" -eq "$expected_count" ]; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
fail "$container did not reach $expected_count connected peers in ${timeout}s"
|
||||
}
|
||||
|
||||
# Resolve `<npub>.fips` inside a container and print the AAAA answer.
|
||||
resolve_fips_addr() {
|
||||
local container="$1"
|
||||
local npub="$2"
|
||||
docker exec "$container" getent ahostsv6 "${npub}.fips" \
|
||||
| awk '{print $1; exit}'
|
||||
}
|
||||
|
||||
# Activate the fips firewall baseline inside a container. Mirrors the
|
||||
# fips-firewall.service ExecStart.
|
||||
activate_firewall() {
|
||||
local container="$1"
|
||||
docker exec "$container" nft -f /etc/fips/fips.nft
|
||||
# Sanity: the table must now exist.
|
||||
if ! docker exec "$container" nft list table inet fips >/dev/null 2>&1; then
|
||||
fail "$container: inet fips table not present after nft -f"
|
||||
fi
|
||||
}
|
||||
|
||||
# Verify default-policy and key chain rules look right.
|
||||
assert_baseline_loaded() {
|
||||
local container="$1"
|
||||
local listing
|
||||
listing="$(docker exec "$container" nft list table inet fips)"
|
||||
# Default-deny is achieved via the trailing `counter drop` (chain
|
||||
# policy is `accept` for return-on-non-fips0 to work safely).
|
||||
if ! printf '%s' "$listing" | grep -q 'counter packets'; then
|
||||
fail "$container: counter drop rule missing from inet fips"
|
||||
fi
|
||||
if ! printf '%s' "$listing" | grep -q 'iifname != "fips0" return'; then
|
||||
fail "$container: non-fips0 early return rule missing"
|
||||
fi
|
||||
if ! printf '%s' "$listing" | grep -q 'ct state established,related accept'; then
|
||||
fail "$container: conntrack established,related rule missing"
|
||||
fi
|
||||
if ! printf '%s' "$listing" | grep -q 'icmpv6 type echo-request accept'; then
|
||||
fail "$container: ICMPv6 echo-request rule missing"
|
||||
fi
|
||||
if ! printf '%s' "$listing" | grep -q 'tcp dport 22 accept'; then
|
||||
fail "$container: drop-in tcp dport 22 rule missing (fips.d not included?)"
|
||||
fi
|
||||
pass "$container: fips.nft baseline + drop-in loaded"
|
||||
}
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
if [ "$SKIP_BUILD" = false ]; then
|
||||
log "Building Linux test binaries"
|
||||
"$TESTING_DIR/scripts/build.sh" --no-docker
|
||||
fi
|
||||
|
||||
log "Generating firewall fixtures"
|
||||
"$GENERATE_CONFIGS"
|
||||
|
||||
log "Starting firewall harness"
|
||||
docker compose -f "$COMPOSE_FILE" down >/dev/null 2>&1 || true
|
||||
docker compose -f "$COMPOSE_FILE" up -d --build
|
||||
|
||||
log "Waiting for fips0 on both nodes"
|
||||
wait_for_fips0 "$CONTAINER_A" 40
|
||||
wait_for_fips0 "$CONTAINER_B" 40
|
||||
|
||||
log "Waiting for peer convergence"
|
||||
wait_for_peers_exact "$CONTAINER_A" 1 40
|
||||
wait_for_peers_exact "$CONTAINER_B" 1 40
|
||||
|
||||
log "Resolving fips0 addresses"
|
||||
ADDR_A="$(resolve_fips_addr "$CONTAINER_A" "$NPUB_A")"
|
||||
ADDR_B="$(resolve_fips_addr "$CONTAINER_B" "$NPUB_B")"
|
||||
[ -z "$ADDR_A" ] && fail "could not resolve node-a fips0 address"
|
||||
[ -z "$ADDR_B" ] && fail "could not resolve node-b fips0 address"
|
||||
echo " node-a: $ADDR_A"
|
||||
echo " node-b: $ADDR_B"
|
||||
|
||||
log "Activating fips-firewall on $CONTAINER_B"
|
||||
activate_firewall "$CONTAINER_B"
|
||||
assert_baseline_loaded "$CONTAINER_B"
|
||||
|
||||
# ── (c) Pre-firewall sanity: confirm both ports are reachable BEFORE ─
|
||||
# the firewall is up would be ideal, but we activated already to
|
||||
# keep the test deterministic. Instead we run case (c) ICMPv6
|
||||
# first, since it's the most basic reachability check.
|
||||
|
||||
log "Case (c): ICMPv6 echo-request to firewalled node"
|
||||
if docker exec "$CONTAINER_A" ping6 -c 3 -W 5 "$ADDR_B" >/dev/null 2>&1; then
|
||||
pass "(c) ICMPv6 ping node-a → node-b accepted"
|
||||
else
|
||||
fail "(c) ICMPv6 ping node-a → node-b should succeed but was dropped"
|
||||
fi
|
||||
|
||||
# ── (a) Unallowed inbound is dropped ───────────────────────────────────
|
||||
log "Case (a): unallowed inbound TCP/${UNALLOWED_PORT} from node-a → node-b"
|
||||
# python3 http.server is already listening on :: per entrypoint default mode.
|
||||
# Use curl --max-time 5 — must time out (exit 28) or otherwise fail.
|
||||
set +e
|
||||
docker exec "$CONTAINER_A" curl -6 --silent --output /dev/null \
|
||||
--max-time 5 "http://[${ADDR_B}]:${UNALLOWED_PORT}/"
|
||||
RC=$?
|
||||
set -e
|
||||
if [ "$RC" -eq 0 ]; then
|
||||
fail "(a) connection to ${UNALLOWED_PORT} succeeded but should have been DROP'd (rc=0)"
|
||||
fi
|
||||
pass "(a) inbound TCP/${UNALLOWED_PORT} blocked (curl rc=$RC)"
|
||||
|
||||
# ── (b) Outbound-initiated flow + conntrack reply ──────────────────────
|
||||
log "Case (b): node-b initiates outbound TCP, expects reply via conntrack"
|
||||
# node-b → node-a:8000 on the fips overlay. node-a has http.server on
|
||||
# [::]:8000 and is NOT firewalled, so this is purely a test of node-b's
|
||||
# outbound + ct state established,related path on the way back.
|
||||
set +e
|
||||
docker exec "$CONTAINER_B" curl -6 --silent --max-time 5 \
|
||||
--output /dev/null --write-out '%{http_code}' \
|
||||
"http://[${ADDR_A}]:${OUTBOUND_TARGET_PORT}/" >/tmp/fw_b_rc 2>/dev/null
|
||||
RC=$?
|
||||
set -e
|
||||
HTTP_CODE="$(cat /tmp/fw_b_rc 2>/dev/null || true)"
|
||||
rm -f /tmp/fw_b_rc
|
||||
if [ "$RC" -ne 0 ]; then
|
||||
fail "(b) outbound from node-b failed (curl rc=$RC, http=$HTTP_CODE) — conntrack reply path broken"
|
||||
fi
|
||||
if [ "$HTTP_CODE" != "200" ]; then
|
||||
fail "(b) outbound returned http=$HTTP_CODE (expected 200) — reply blocked?"
|
||||
fi
|
||||
pass "(b) outbound from node-b got HTTP $HTTP_CODE via conntrack reply path"
|
||||
|
||||
# ── (d) Drop-in allowlisted port accepted ──────────────────────────────
|
||||
log "Case (d): drop-in allowlisted TCP/${ALLOWED_PORT} from node-a → node-b"
|
||||
# nc -zv -w3: zero-I/O scan, verbose, 3-second timeout. Exit 0 = port
|
||||
# open and reachable. The container's sshd is listening on [::]:22 by
|
||||
# default per the test entrypoint.
|
||||
if docker exec "$CONTAINER_A" nc -6 -z -v -w 3 "$ADDR_B" "$ALLOWED_PORT" 2>&1 \
|
||||
| grep -qE 'succeeded|open'; then
|
||||
pass "(d) drop-in allowlisted TCP/${ALLOWED_PORT} reachable"
|
||||
else
|
||||
fail "(d) drop-in allowlisted TCP/${ALLOWED_PORT} should be reachable but was blocked"
|
||||
fi
|
||||
|
||||
# ── Drop-counter sanity ────────────────────────────────────────────────
|
||||
log "Drop counter incremented (case a should have ticked it)"
|
||||
DROP_PKTS="$(docker exec "$CONTAINER_B" nft list table inet fips \
|
||||
| awk '/counter packets/ {print $3; exit}')"
|
||||
if [ -z "${DROP_PKTS:-}" ] || [ "$DROP_PKTS" -lt 1 ]; then
|
||||
fail "drop counter is $DROP_PKTS — case (a) should have produced drops"
|
||||
fi
|
||||
pass "drop counter = $DROP_PKTS (case a was actually dropped, not just unrouted)"
|
||||
|
||||
log "Firewall integration test passed"
|
||||
Reference in New Issue
Block a user