diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..9ec31f4 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,106 @@ +name: Build & Test + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + branches: [main] + +jobs: + unit-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run unit tests + run: bash tests/test_unit.sh + + integration-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install firewalld + run: | + sudo apt-get update + sudo apt-get install -y firewalld python3 curl iproute2 + + - name: Start firewalld + run: | + sudo systemctl unmask firewalld + sudo systemctl start firewalld + sudo firewall-cmd --state + + - name: Run integration tests + run: sudo bash tests/test_integration.sh + + build: + runs-on: ubuntu-latest + needs: [unit-test] + strategy: + matrix: + include: + - distro: fedora:latest + name: fedora + - distro: rockylinux:8 + name: el8 + - distro: rockylinux:9 + name: el9 + - distro: almalinux:9 + name: alma9 + container: ${{ matrix.distro }} + steps: + - uses: actions/checkout@v4 + + - name: Install build dependencies + run: | + dnf install -y rpm-build make systemd-rpm-macros + + - name: Build RPM + run: make rpm + + - name: Verify RPM + run: | + rpm -qip ~/rpmbuild/RPMS/noarch/firewalld-cloudflare-http-*.rpm + rpm -qlp ~/rpmbuild/RPMS/noarch/firewalld-cloudflare-http-*.rpm + + - name: Upload RPM + uses: actions/upload-artifact@v4 + with: + name: rpm-${{ matrix.name }} + path: ~/rpmbuild/RPMS/noarch/firewalld-cloudflare-http-*.rpm + + - name: Upload SRPM + if: matrix.name == 'fedora' + uses: actions/upload-artifact@v4 + with: + name: srpm + path: ~/rpmbuild/SRPMS/firewalld-cloudflare-http-*.src.rpm + + release: + needs: [build, integration-test] + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download all RPM artifacts + uses: actions/download-artifact@v4 + with: + pattern: rpm-* + path: ./rpms + merge-multiple: true + + - name: Download SRPM + uses: actions/download-artifact@v4 + with: + name: srpm + path: ./srpms + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + files: | + ./rpms/*.rpm + ./srpms/*.src.rpm diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cd1fb23 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.tar.gz +*.rpm +firewalld-cloudflare-http-*/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a9b2967 --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +NAME := firewalld-cloudflare-http +VERSION := 1.0.0 +TARBALL := $(NAME)-$(VERSION).tar.gz + +RPMBUILD := $(HOME)/rpmbuild + +.PHONY: all srpm rpm tarball clean + +all: rpm + +tarball: + mkdir -p $(NAME)-$(VERSION) + cp -a src LICENSE $(NAME)-$(VERSION)/ + cp $(NAME).spec $(NAME)-$(VERSION)/ + tar czf $(TARBALL) $(NAME)-$(VERSION) + rm -rf $(NAME)-$(VERSION) + +srpm: tarball + mkdir -p $(RPMBUILD)/{SOURCES,SPECS} + cp $(TARBALL) $(RPMBUILD)/SOURCES/ + cp $(NAME).spec $(RPMBUILD)/SPECS/ + rpmbuild -bs $(RPMBUILD)/SPECS/$(NAME).spec + +rpm: tarball + mkdir -p $(RPMBUILD)/{SOURCES,SPECS} + cp $(TARBALL) $(RPMBUILD)/SOURCES/ + cp $(NAME).spec $(RPMBUILD)/SPECS/ + rpmbuild -ba $(RPMBUILD)/SPECS/$(NAME).spec + +clean: + rm -f $(TARBALL) + rm -rf $(NAME)-$(VERSION) diff --git a/README.md b/README.md new file mode 100644 index 0000000..448e1eb --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# firewalld-cloudflare-http + +firewalld の ipset + rich rule を使って、HTTP/HTTPS (80/443) を Cloudflare IP からのみ許可する RPM パッケージ。 + +## インストール + +```bash +# RPM をビルドしてインストール +make rpm +sudo dnf install ~/rpmbuild/RPMS/noarch/firewalld-cloudflare-http-*.rpm +``` + +## 動作 + +インストール時に以下が自動で行われます: + +1. Cloudflare の公開 IP リスト ([IPv4](https://www.cloudflare.com/ips-v4), [IPv6](https://www.cloudflare.com/ips-v6)) を取得 +2. firewalld ipset (`cloudflare-ipv4`, `cloudflare-ipv6`) を作成 +3. デフォルト zone に HTTP/HTTPS を許可する rich rule を追加 +4. 週次の systemd timer で IP リストを自動更新 + +## インストール後の確認 + +```bash +# ipset の確認 +sudo firewall-cmd --get-ipsets +sudo firewall-cmd --info-ipset=cloudflare-ipv4 +sudo firewall-cmd --info-ipset=cloudflare-ipv6 + +# rich rule の確認 +sudo firewall-cmd --list-rich-rules + +# timer の確認 +systemctl status firewalld-cloudflare-http-update.timer +``` + +## 既存の HTTP/HTTPS サービスの無効化 + +Cloudflare IP 以外からの HTTP/HTTPS を拒否するには、zone から `http`/`https` サービスを削除してください: + +```bash +sudo firewall-cmd --permanent --remove-service=http +sudo firewall-cmd --permanent --remove-service=https +sudo firewall-cmd --reload +``` + +## 手動更新 + +```bash +# IP リストの手動更新 +sudo /usr/libexec/firewalld-cloudflare-http/update update + +# rich rule の再セットアップ (特定の zone を指定可能) +sudo /usr/libexec/firewalld-cloudflare-http/update setup [zone] +``` + +## アンインストール + +```bash +sudo dnf remove firewalld-cloudflare-http +``` + +アンインストール時に rich rule と ipset は自動で削除されます。 + +## ビルド要件 + +- `rpmbuild` (`rpm-build` パッケージ) +- `make` + +```bash +sudo dnf install rpm-build make +make rpm +``` + +## ライセンス + +Apache License 2.0 diff --git a/firewalld-cloudflare-http.spec b/firewalld-cloudflare-http.spec new file mode 100644 index 0000000..1c06985 --- /dev/null +++ b/firewalld-cloudflare-http.spec @@ -0,0 +1,65 @@ +Name: firewalld-cloudflare-http +Version: 1.0.0 +Release: 1%{?dist} +Summary: Firewalld rules to allow HTTP/HTTPS only from Cloudflare IPs +License: Apache-2.0 +URL: https://github.com/39ff/firewalld-cloudflare-http-rules + +Source0: %{name}-%{version}.tar.gz + +BuildArch: noarch +BuildRequires: systemd-rpm-macros +Requires: firewalld +Requires: curl +Requires: systemd + +%description +Manages firewalld ipsets and rich rules to allow HTTP (port 80) and +HTTPS (port 443) traffic only from Cloudflare IP addresses. + +Cloudflare IP ranges are fetched automatically from +https://www.cloudflare.com/ips-v4 and https://www.cloudflare.com/ips-v6 +and kept up-to-date via a weekly systemd timer. + +%prep +%setup -q + +%install +install -Dm 0755 src/firewalld-cloudflare-http-update \ + %{buildroot}%{_libexecdir}/firewalld-cloudflare-http/update + +install -Dm 0644 src/firewalld-cloudflare-http-update.service \ + %{buildroot}%{_unitdir}/firewalld-cloudflare-http-update.service + +install -Dm 0644 src/firewalld-cloudflare-http-update.timer \ + %{buildroot}%{_unitdir}/firewalld-cloudflare-http-update.timer + +%post +%systemd_post firewalld-cloudflare-http-update.timer + +if systemctl is-active --quiet firewalld; then + %{_libexecdir}/firewalld-cloudflare-http/update setup || : +fi + +systemctl enable --now firewalld-cloudflare-http-update.timer || : + +%preun +%systemd_preun firewalld-cloudflare-http-update.timer +%systemd_preun firewalld-cloudflare-http-update.service + +if [ "$1" -eq 0 ] && systemctl is-active --quiet firewalld; then + %{_libexecdir}/firewalld-cloudflare-http/update remove || : +fi + +%postun +%systemd_postun_with_restart firewalld-cloudflare-http-update.timer + +%files +%license LICENSE +%{_libexecdir}/firewalld-cloudflare-http/update +%{_unitdir}/firewalld-cloudflare-http-update.service +%{_unitdir}/firewalld-cloudflare-http-update.timer + +%changelog +* Tue Feb 03 2026 39ff - 1.0.0-1 +- Initial release diff --git a/src/firewalld-cloudflare-http-update b/src/firewalld-cloudflare-http-update new file mode 100644 index 0000000..0c0d88d --- /dev/null +++ b/src/firewalld-cloudflare-http-update @@ -0,0 +1,175 @@ +#!/bin/bash +# firewalld-cloudflare-http-update +# Fetch Cloudflare IP ranges and update firewalld ipsets +# +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +CLOUDFLARE_IPV4_URL="${CLOUDFLARE_IPV4_URL:-https://www.cloudflare.com/ips-v4}" +CLOUDFLARE_IPV6_URL="${CLOUDFLARE_IPV6_URL:-https://www.cloudflare.com/ips-v6}" +IPSET_DIR="${IPSET_DIR:-/etc/firewalld/ipsets}" +IPSET_IPV4="${IPSET_IPV4:-cloudflare-ipv4}" +IPSET_IPV6="${IPSET_IPV6:-cloudflare-ipv6}" + +log() { + echo "$(date '+%Y-%m-%d %H:%M:%S') [firewalld-cloudflare-http] $*" +} + +die() { + log "ERROR: $*" >&2 + exit 1 +} + +fetch_ips() { + local url="$1" + local tmp + tmp=$(mktemp) + if ! curl -sf --max-time 30 -o "$tmp" "$url"; then + rm -f "$tmp" + die "Failed to fetch $url" + fi + # Validate: each line should be a CIDR notation + while IFS= read -r line; do + line=$(echo "$line" | tr -d '[:space:]') + [[ -z "$line" ]] && continue + if ! [[ "$line" =~ ^[0-9a-fA-F.:]+/[0-9]+$ ]]; then + rm -f "$tmp" + die "Invalid CIDR entry: $line" + fi + done < "$tmp" + cat "$tmp" + rm -f "$tmp" +} + +write_ipset_xml() { + local name="$1" + local family="$2" + local ips="$3" + local file="${IPSET_DIR}/${name}.xml" + + mkdir -p "$IPSET_DIR" + + cat > "$file" < + + ${name} + Cloudflare ${family} addresses - auto-managed by firewalld-cloudflare-http +XMLHEADER + + if [[ "$family" == "inet6" ]]; then + echo ' " >> "$file" + log "Updated ipset: ${name} (${file})" +} + +setup_rich_rules() { + local zone="${1:-$(firewall-cmd --get-default-zone)}" + + local -a rules=( + "rule family=\"ipv4\" source ipset=\"${IPSET_IPV4}\" port port=\"80\" protocol=\"tcp\" accept" + "rule family=\"ipv4\" source ipset=\"${IPSET_IPV4}\" port port=\"443\" protocol=\"tcp\" accept" + "rule family=\"ipv6\" source ipset=\"${IPSET_IPV6}\" port port=\"80\" protocol=\"tcp\" accept" + "rule family=\"ipv6\" source ipset=\"${IPSET_IPV6}\" port port=\"443\" protocol=\"tcp\" accept" + ) + + for rule in "${rules[@]}"; do + if ! firewall-cmd --permanent --zone="$zone" --query-rich-rule="$rule" &>/dev/null; then + firewall-cmd --permanent --zone="$zone" --add-rich-rule="$rule" + log "Added rich rule to zone ${zone}: ${rule}" + fi + done +} + +remove_rich_rules() { + local zone="${1:-$(firewall-cmd --get-default-zone)}" + + local -a rules=( + "rule family=\"ipv4\" source ipset=\"${IPSET_IPV4}\" port port=\"80\" protocol=\"tcp\" accept" + "rule family=\"ipv4\" source ipset=\"${IPSET_IPV4}\" port port=\"443\" protocol=\"tcp\" accept" + "rule family=\"ipv6\" source ipset=\"${IPSET_IPV6}\" port port=\"80\" protocol=\"tcp\" accept" + "rule family=\"ipv6\" source ipset=\"${IPSET_IPV6}\" port port=\"443\" protocol=\"tcp\" accept" + ) + + for rule in "${rules[@]}"; do + if firewall-cmd --permanent --zone="$zone" --query-rich-rule="$rule" &>/dev/null; then + firewall-cmd --permanent --zone="$zone" --remove-rich-rule="$rule" + log "Removed rich rule from zone ${zone}: ${rule}" + fi + done +} + +cmd_update() { + log "Fetching Cloudflare IPv4 ranges..." + local ipv4 + ipv4=$(fetch_ips "$CLOUDFLARE_IPV4_URL") + + log "Fetching Cloudflare IPv6 ranges..." + local ipv6 + ipv6=$(fetch_ips "$CLOUDFLARE_IPV6_URL") + + write_ipset_xml "$IPSET_IPV4" "inet" "$ipv4" + write_ipset_xml "$IPSET_IPV6" "inet6" "$ipv6" + + firewall-cmd --reload + log "firewalld reloaded successfully" +} + +cmd_setup() { + local zone="${1:-}" + cmd_update + setup_rich_rules "$zone" + firewall-cmd --reload + log "Setup complete" +} + +cmd_remove() { + local zone="${1:-}" + remove_rich_rules "$zone" + rm -f "${IPSET_DIR}/${IPSET_IPV4}.xml" + rm -f "${IPSET_DIR}/${IPSET_IPV6}.xml" + firewall-cmd --reload + log "Removed all Cloudflare firewalld rules and ipsets" +} + +usage() { + cat < [options] + +Commands: + update Fetch Cloudflare IPs and update firewalld ipsets + setup [zone] Update ipsets and add rich rules (default: default zone) + remove [zone] Remove rich rules and ipsets + help Show this help + +EOF +} + +# Allow sourcing for tests without executing main logic +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + case "${1:-help}" in + update) + cmd_update + ;; + setup) + cmd_setup "${2:-}" + ;; + remove) + cmd_remove "${2:-}" + ;; + help|--help|-h) + usage + ;; + *) + die "Unknown command: $1" + ;; + esac +fi diff --git a/src/firewalld-cloudflare-http-update.service b/src/firewalld-cloudflare-http-update.service new file mode 100644 index 0000000..79648b8 --- /dev/null +++ b/src/firewalld-cloudflare-http-update.service @@ -0,0 +1,9 @@ +[Unit] +Description=Update firewalld Cloudflare HTTP ipsets +Wants=network-online.target +After=network-online.target firewalld.service +Requires=firewalld.service + +[Service] +Type=oneshot +ExecStart=/usr/libexec/firewalld-cloudflare-http/update update diff --git a/src/firewalld-cloudflare-http-update.timer b/src/firewalld-cloudflare-http-update.timer new file mode 100644 index 0000000..ac55734 --- /dev/null +++ b/src/firewalld-cloudflare-http-update.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Weekly update of firewalld Cloudflare HTTP ipsets + +[Timer] +OnCalendar=weekly +RandomizedDelaySec=3600 +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/tests/test_integration.sh b/tests/test_integration.sh new file mode 100755 index 0000000..00d648a --- /dev/null +++ b/tests/test_integration.sh @@ -0,0 +1,212 @@ +#!/bin/bash +# Integration test: verify firewalld Cloudflare rules with network namespaces +# +# Requires: root, firewalld, iproute2, python3, curl +# Creates network namespaces to simulate traffic from Cloudflare and +# non-Cloudflare source IPs, then verifies connectivity. +# +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +UPDATE_SCRIPT="$PROJECT_DIR/src/firewalld-cloudflare-http-update" + +# ── Config ─────────────────────────────────────────────────── +# Cloudflare IP range (real): 173.245.48.0/20 +CF_HOST_IP="173.245.48.1" +CF_NS_IP="173.245.48.2" +CF_CIDR="173.245.48.0/20" +CF_LINK_PREFIX="24" + +# Non-Cloudflare IP range +EXT_HOST_IP="198.51.100.1" +EXT_NS_IP="198.51.100.2" +EXT_LINK_PREFIX="24" + +NS_CF="ns-cloudflare-test" +NS_EXT="ns-external-test" +VETH_CF="veth-cf" +VETH_CF_BR="veth-cf-br" +VETH_EXT="veth-ext" +VETH_EXT_BR="veth-ext-br" + +HTTP_PORT=80 +HTTP_PID="" + +PASS=0 +FAIL=0 +pass() { PASS=$((PASS + 1)); echo " PASS: $1"; } +fail() { FAIL=$((FAIL + 1)); echo " FAIL: $1" >&2; } + +# ── Cleanup ────────────────────────────────────────────────── +cleanup() { + echo "=== Cleanup ===" + [[ -n "$HTTP_PID" ]] && kill "$HTTP_PID" 2>/dev/null || true + ip netns del "$NS_CF" 2>/dev/null || true + ip netns del "$NS_EXT" 2>/dev/null || true + ip link del "$VETH_CF_BR" 2>/dev/null || true + ip link del "$VETH_EXT_BR" 2>/dev/null || true + + # Restore firewalld: remove rich rules and ipsets + source "$UPDATE_SCRIPT" + remove_rich_rules "" 2>/dev/null || true + rm -f /etc/firewalld/ipsets/cloudflare-ipv4.xml + rm -f /etc/firewalld/ipsets/cloudflare-ipv6.xml + firewall-cmd --reload 2>/dev/null || true +} +trap cleanup EXIT + +# ── Preflight checks ──────────────────────────────────────── +echo "=== Preflight ===" + +if [[ $EUID -ne 0 ]]; then + echo "ERROR: Must run as root" >&2 + exit 1 +fi + +for cmd in firewall-cmd ip python3 curl; do + if ! command -v "$cmd" &>/dev/null; then + echo "ERROR: $cmd not found" >&2 + exit 1 + fi +done + +if ! systemctl is-active --quiet firewalld; then + echo "Starting firewalld..." + systemctl start firewalld + sleep 1 +fi + +DEFAULT_ZONE=$(firewall-cmd --get-default-zone) +echo "Default zone: $DEFAULT_ZONE" + +# ── Step 1: Create ipsets and rich rules ───────────────────── +echo "=== Setting up firewalld rules ===" + +source "$UPDATE_SCRIPT" + +# Write a minimal ipset with just the test CIDR +write_ipset_xml "$IPSET_IPV4" "inet" "$CF_CIDR" +# IPv6 ipset (needed for rich rule references) +write_ipset_xml "$IPSET_IPV6" "inet6" "2400:cb00::/32" + +firewall-cmd --reload +echo "Ipsets loaded:" +firewall-cmd --get-ipsets + +# Add rich rules +setup_rich_rules "" +firewall-cmd --reload + +echo "Rich rules:" +firewall-cmd --list-rich-rules + +# Remove generic http/https services so only rich rules control access +firewall-cmd --permanent --zone="$DEFAULT_ZONE" --remove-service=http 2>/dev/null || true +firewall-cmd --permanent --zone="$DEFAULT_ZONE" --remove-service=https 2>/dev/null || true +firewall-cmd --reload + +# ── Step 2: Create network namespaces ─────────────────────── +echo "=== Setting up network namespaces ===" + +# Cloudflare namespace +ip netns add "$NS_CF" +ip link add "$VETH_CF" type veth peer name "$VETH_CF_BR" +ip link set "$VETH_CF" netns "$NS_CF" +ip addr add "${CF_HOST_IP}/${CF_LINK_PREFIX}" dev "$VETH_CF_BR" +ip link set "$VETH_CF_BR" up +ip netns exec "$NS_CF" ip addr add "${CF_NS_IP}/${CF_LINK_PREFIX}" dev "$VETH_CF" +ip netns exec "$NS_CF" ip link set "$VETH_CF" up +ip netns exec "$NS_CF" ip link set lo up +ip netns exec "$NS_CF" ip route add default via "$CF_HOST_IP" + +# External (non-Cloudflare) namespace +ip netns add "$NS_EXT" +ip link add "$VETH_EXT" type veth peer name "$VETH_EXT_BR" +ip link set "$VETH_EXT" netns "$NS_EXT" +ip addr add "${EXT_HOST_IP}/${EXT_LINK_PREFIX}" dev "$VETH_EXT_BR" +ip link set "$VETH_EXT_BR" up +ip netns exec "$NS_EXT" ip addr add "${EXT_NS_IP}/${EXT_LINK_PREFIX}" dev "$VETH_EXT" +ip netns exec "$NS_EXT" ip link set "$VETH_EXT" up +ip netns exec "$NS_EXT" ip link set lo up +ip netns exec "$NS_EXT" ip route add default via "$EXT_HOST_IP" + +# Assign veth bridge ends to default firewalld zone +firewall-cmd --zone="$DEFAULT_ZONE" --add-interface="$VETH_CF_BR" 2>/dev/null || true +firewall-cmd --zone="$DEFAULT_ZONE" --add-interface="$VETH_EXT_BR" 2>/dev/null || true + +echo "Namespaces:" +echo " $NS_CF -> ${CF_NS_IP} (Cloudflare range)" +echo " $NS_EXT -> ${EXT_NS_IP} (non-Cloudflare)" + +# ── Step 3: Start HTTP server ─────────────────────────────── +echo "=== Starting HTTP server on port $HTTP_PORT ===" + +python3 -c " +import http.server, socketserver, threading, signal, sys +handler = http.server.SimpleHTTPRequestHandler +httpd = socketserver.TCPServer(('0.0.0.0', $HTTP_PORT), handler) +signal.signal(signal.SIGTERM, lambda *a: sys.exit(0)) +httpd.serve_forever() +" &>/dev/null & +HTTP_PID=$! +sleep 1 + +if ! kill -0 "$HTTP_PID" 2>/dev/null; then + echo "ERROR: HTTP server failed to start" >&2 + exit 1 +fi +echo "HTTP server PID: $HTTP_PID" + +# ── Step 4: Connectivity tests ────────────────────────────── +echo "=== Connectivity tests ===" + +# Test 1: Cloudflare IP should reach port 80 +echo "--- Test: Cloudflare IP -> port $HTTP_PORT ---" +if ip netns exec "$NS_CF" curl -sf --max-time 5 -o /dev/null "http://${CF_HOST_IP}:${HTTP_PORT}/"; then + pass "Cloudflare IP (${CF_NS_IP}) can reach port ${HTTP_PORT}" +else + fail "Cloudflare IP (${CF_NS_IP}) was blocked from port ${HTTP_PORT}" +fi + +# Test 2: Non-Cloudflare IP should be BLOCKED on port 80 +echo "--- Test: External IP -> port $HTTP_PORT ---" +if ip netns exec "$NS_EXT" curl -sf --max-time 5 -o /dev/null "http://${EXT_HOST_IP}:${HTTP_PORT}/"; then + fail "External IP (${EXT_NS_IP}) reached port ${HTTP_PORT} (should be blocked)" +else + pass "External IP (${EXT_NS_IP}) blocked from port ${HTTP_PORT}" +fi + +# Test 3: Verify ipsets contain expected entries +echo "--- Test: ipset contents ---" +IPV4_INFO=$(firewall-cmd --info-ipset=cloudflare-ipv4 2>&1) +if echo "$IPV4_INFO" | grep -qF "$CF_CIDR"; then + pass "cloudflare-ipv4 ipset contains $CF_CIDR" +else + fail "cloudflare-ipv4 ipset missing $CF_CIDR" +fi + +# Test 4: Rich rules are present +echo "--- Test: rich rules ---" +RICH_RULES=$(firewall-cmd --list-rich-rules) +if echo "$RICH_RULES" | grep -q 'source ipset="cloudflare-ipv4".*port.*80'; then + pass "Rich rule for port 80 with cloudflare-ipv4 exists" +else + fail "Rich rule for port 80 with cloudflare-ipv4 missing" +fi + +if echo "$RICH_RULES" | grep -q 'source ipset="cloudflare-ipv4".*port.*443'; then + pass "Rich rule for port 443 with cloudflare-ipv4 exists" +else + fail "Rich rule for port 443 with cloudflare-ipv4 missing" +fi + +# ── Summary ────────────────────────────────────────────────── +echo "" +echo "=== Results: $PASS passed, $FAIL failed ===" + +if [[ $FAIL -gt 0 ]]; then + exit 1 +fi diff --git a/tests/test_unit.sh b/tests/test_unit.sh new file mode 100755 index 0000000..543260f --- /dev/null +++ b/tests/test_unit.sh @@ -0,0 +1,222 @@ +#!/bin/bash +# Unit tests for firewalld-cloudflare-http-update +# No root, firewalld, or network access required. +# +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +PASS=0 +FAIL=0 + +pass() { PASS=$((PASS + 1)); echo " PASS: $1"; } +fail() { FAIL=$((FAIL + 1)); echo " FAIL: $1" >&2; } + +assert_eq() { + local desc="$1" expected="$2" actual="$3" + if [[ "$expected" == "$actual" ]]; then + pass "$desc" + else + fail "$desc (expected: '$expected', got: '$actual')" + fi +} + +assert_contains() { + local desc="$1" needle="$2" haystack="$3" + if echo "$haystack" | grep -qF "$needle"; then + pass "$desc" + else + fail "$desc (expected to contain: '$needle')" + fi +} + +assert_not_contains() { + local desc="$1" needle="$2" haystack="$3" + if ! echo "$haystack" | grep -qF "$needle"; then + pass "$desc" + else + fail "$desc (expected NOT to contain: '$needle')" + fi +} + +assert_file_exists() { + local desc="$1" file="$2" + if [[ -f "$file" ]]; then + pass "$desc" + else + fail "$desc (file not found: $file)" + fi +} + +# ── Setup ──────────────────────────────────────────────────── +TMPDIR_ROOT=$(mktemp -d) +trap 'rm -rf "$TMPDIR_ROOT"' EXIT + +# Source the script (guard prevents main from executing) +export IPSET_DIR="$TMPDIR_ROOT/ipsets" +source "$PROJECT_DIR/src/firewalld-cloudflare-http-update" + +# ── Test: write_ipset_xml generates valid XML ──────────────── +echo "=== write_ipset_xml ===" + +IPS_V4="173.245.48.0/20 +103.21.244.0/22 +104.16.0.0/13" + +write_ipset_xml "cloudflare-ipv4" "inet" "$IPS_V4" + +assert_file_exists "ipset XML file created" "$IPSET_DIR/cloudflare-ipv4.xml" + +XML_CONTENT=$(cat "$IPSET_DIR/cloudflare-ipv4.xml") + +assert_contains "XML header present" '' "$XML_CONTENT" +assert_contains "ipset type is hash:net" '' "$XML_CONTENT" +assert_contains "short name present" 'cloudflare-ipv4' "$XML_CONTENT" +assert_contains "description present" 'auto-managed by firewalld-cloudflare-http' "$XML_CONTENT" +assert_not_contains "no inet6 family option for IPv4" 'option name="family"' "$XML_CONTENT" +assert_contains "entry 173.245.48.0/20" '173.245.48.0/20' "$XML_CONTENT" +assert_contains "entry 103.21.244.0/22" '103.21.244.0/22' "$XML_CONTENT" +assert_contains "entry 104.16.0.0/13" '104.16.0.0/13' "$XML_CONTENT" +assert_contains "closing tag" '' "$XML_CONTENT" + +# ── Test: write_ipset_xml with IPv6 ───────────────────────── +echo "=== write_ipset_xml IPv6 ===" + +IPS_V6="2400:cb00::/32 +2606:4700::/32" + +write_ipset_xml "cloudflare-ipv6" "inet6" "$IPS_V6" + +assert_file_exists "IPv6 ipset XML file created" "$IPSET_DIR/cloudflare-ipv6.xml" + +XML6=$(cat "$IPSET_DIR/cloudflare-ipv6.xml") + +assert_contains "IPv6 family option present" '