diff --git a/.github/workflows/build-ova.yml b/.github/workflows/build-ova.yml index 3ce52bd..2a2cfdf 100644 --- a/.github/workflows/build-ova.yml +++ b/.github/workflows/build-ova.yml @@ -12,6 +12,10 @@ on: gateway_tag: description: "defguard gateway image tag" required: true + publish_latest: + description: "Also overwrite defguard-latest.ova (off = test build)" + type: boolean + default: false jobs: build: @@ -62,9 +66,9 @@ jobs: env: PACKER_LOG: 1 run: | - CORE_TAG="${{ github.event.inputs.core_tag }}" - PROXY_TAG="${{ github.event.inputs.proxy_tag }}" - GATEWAY_TAG="${{ github.event.inputs.gateway_tag }}" + CORE_TAG="${{ github.event.inputs.core_tag || '2' }}" + PROXY_TAG="${{ github.event.inputs.proxy_tag || '2' }}" + GATEWAY_TAG="${{ github.event.inputs.gateway_tag || '2' }}" packer build \ -var "iso_url=file://$PWD/ubuntu-24.04.4-live-server-amd64.iso" \ -var "core_tag=${CORE_TAG}" \ @@ -77,14 +81,23 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: eu-central-1 - CORE_TAG: ${{ github.event.inputs.core_tag }} - PROXY_TAG: ${{ github.event.inputs.proxy_tag }} - GATEWAY_TAG: ${{ github.event.inputs.gateway_tag }} + CORE_TAG: ${{ github.event.inputs.core_tag || '2' }} + PROXY_TAG: ${{ github.event.inputs.proxy_tag || '2' }} + GATEWAY_TAG: ${{ github.event.inputs.gateway_tag || '2' }} run: | TIMESTAMP=$(date +%Y%m%d-%H%M%S) FILENAME="defguard_${TIMESTAMP}_core-${CORE_TAG}_edge-${PROXY_TAG}_gateway-${GATEWAY_TAG}.ova" ls -lh output/defguard/defguard.ova aws s3 cp output/defguard/defguard.ova "s3://defguard-downloads/ova/${FILENAME}" echo "Uploaded: s3://defguard-downloads/ova/${FILENAME}" + + - name: Publish as latest + if: ${{ inputs.publish_latest }} + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: eu-central-1 + run: | aws s3 cp output/defguard/defguard.ova "s3://defguard-downloads/ova/defguard-latest.ova" \ --cache-control "no-cache" + echo "Updated: s3://defguard-downloads/ova/defguard-latest.ova" diff --git a/.github/workflows/test-ova.yml b/.github/workflows/test-ova.yml new file mode 100644 index 0000000..367a560 --- /dev/null +++ b/.github/workflows/test-ova.yml @@ -0,0 +1,58 @@ +name: Test OVA scripts + +on: + push: + branches: + - main + paths: + - "ova/**" + - ".github/workflows/test-ova.yml" + pull_request: + branches: + - main + paths: + - "ova/**" + - ".github/workflows/test-ova.yml" + +jobs: + logic: + name: Logic + compose config + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Install bats + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends bats + + - name: Run logic and config tests + run: bats ova/tests/start.bats ova/tests/generate-env.bats ova/tests/config.bats ova/tests/firewall.bats + + integration: + name: Real bring-up + runs-on: [self-hosted, Linux, X64] + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Install bats + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends bats + + - name: Login to GitHub container registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Run integration tests + env: + RUN_INTEGRATION: "1" + CORE_TAG: "2" + PROXY_TAG: "2" + GATEWAY_TAG: "2" + run: bats ova/tests/integration.bats diff --git a/flake.nix b/flake.nix index 15868e0..5f2d05d 100644 --- a/flake.nix +++ b/flake.nix @@ -21,6 +21,7 @@ kubectl kubernetes-helm kubeconform + bats ]; }; }); diff --git a/ova/defguard.pkr.hcl b/ova/defguard.pkr.hcl index 20ceb38..c7f3eaa 100644 --- a/ova/defguard.pkr.hcl +++ b/ova/defguard.pkr.hcl @@ -92,6 +92,16 @@ build { destination = "/tmp/defguard-init.service" } + provisioner "file" { + source = "files/defguard-firewall.sh" + destination = "/tmp/defguard-firewall.sh" + } + + provisioner "file" { + source = "files/defguard-firewall.service" + destination = "/tmp/defguard-firewall.service" + } + provisioner "shell" { inline = [ "sudo bash /tmp/docker-setup.sh", @@ -107,8 +117,13 @@ build { "echo 'DEFGUARD_GATEWAY_TAG=${var.gateway_tag}' | sudo tee -a /opt/stacks/defguard/.image-tags > /dev/null", "sudo mv /tmp/99-defguard.cfg /etc/cloud/cloud.cfg.d/99-defguard.cfg", "sudo mv /tmp/defguard-init.service /etc/systemd/system/defguard-init.service", + "sudo mv /tmp/defguard-firewall.sh /opt/stacks/defguard/defguard-firewall.sh", + "sudo chmod +x /opt/stacks/defguard/defguard-firewall.sh", + "sudo mv /tmp/defguard-firewall.service /etc/systemd/system/defguard-firewall.service", "sudo systemctl daemon-reload", "sudo systemctl enable docker.service", + "sudo systemctl enable defguard-init.service", + "sudo systemctl enable defguard-firewall.service", "sudo chown -R ubuntu:ubuntu /opt/stacks/defguard", "sudo rm -f /etc/netplan/00-installer-config.yaml /etc/netplan/50-cloud-init.yaml", "sudo cloud-init clean --logs", diff --git a/ova/files/defguard-firewall.service b/ova/files/defguard-firewall.service new file mode 100644 index 0000000..6913e26 --- /dev/null +++ b/ova/files/defguard-firewall.service @@ -0,0 +1,13 @@ +[Unit] +Description=DefGuard host forwarding rules for the WireGuard gateway +After=docker.service +Wants=docker.service + +[Service] +Type=oneshot +StandardOutput=append:/var/log/defguard-startup.log +StandardError=append:/var/log/defguard-startup.log +ExecStart=/bin/bash /opt/stacks/defguard/defguard-firewall.sh + +[Install] +WantedBy=multi-user.target diff --git a/ova/files/defguard-firewall.sh b/ova/files/defguard-firewall.sh new file mode 100755 index 0000000..a1a0b03 --- /dev/null +++ b/ova/files/defguard-firewall.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# Docker sets the FORWARD policy to DROP and only accepts its own bridges, which +# drops the gateway's decrypted WireGuard traffic before it can be forwarded. + +set -u + +SYSCTL_DIR="${DEFGUARD_SYSCTL_DIR:-/etc/sysctl.d}" + +# Docker enables net.ipv4.ip_forward itself, but not IPv6 forwarding. +cat > "$SYSCTL_DIR/99-defguard-forward.conf" </dev/null + +# DOCKER-USER only exists once dockerd has set up its chains; this may race +# docker.service. Wait briefly, then bail cleanly (next boot re-applies). +for _ in $(seq 1 30); do + if iptables -n -L DOCKER-USER >/dev/null 2>&1; then + break + fi + sleep 1 +done + +if ! iptables -n -L DOCKER-USER >/dev/null 2>&1; then + echo "DefGuard: DOCKER-USER chain not present; skipping (Docker not ready)." + exit 0 +fi + +for ipt in iptables ip6tables; do + for dir in "-i" "-o"; do + "$ipt" -C DOCKER-USER "$dir" wg+ -j ACCEPT 2>/dev/null \ + || "$ipt" -I DOCKER-USER "$dir" wg+ -j ACCEPT + done +done + +echo "DefGuard: wg+ forwarding whitelisted in DOCKER-USER." diff --git a/ova/files/defguard-init.service b/ova/files/defguard-init.service index f7ed544..b759004 100644 --- a/ova/files/defguard-init.service +++ b/ova/files/defguard-init.service @@ -10,3 +10,6 @@ StandardOutput=append:/var/log/defguard-startup.log StandardError=append:/var/log/defguard-startup.log ExecStart=/bin/bash /opt/stacks/defguard/generate-env.sh ExecStart=/bin/bash /opt/stacks/defguard/start.sh + +[Install] +WantedBy=multi-user.target diff --git a/ova/files/generate-env.sh b/ova/files/generate-env.sh index 1de8c52..be6634f 100644 --- a/ova/files/generate-env.sh +++ b/ova/files/generate-env.sh @@ -2,7 +2,8 @@ # Generates /opt/stacks/defguard/.env with random secrets on first boot. # If .env already exists (e.g. provided via cloud-init), this script does nothing. -ENV_FILE="/opt/stacks/defguard/.env" +STACK_DIR="${DEFGUARD_STACK_DIR:-/opt/stacks/defguard}" +ENV_FILE="$STACK_DIR/.env" if [ -f "$ENV_FILE" ]; then echo "DefGuard: .env already exists, skipping generation." @@ -13,8 +14,8 @@ echo "DefGuard: generating .env with random secrets..." DB_PASSWORD=$(openssl rand -hex 16) -if [ -f "/opt/stacks/defguard/.image-tags" ]; then - source "/opt/stacks/defguard/.image-tags" +if [ -f "$STACK_DIR/.image-tags" ]; then + source "$STACK_DIR/.image-tags" fi : "${DEFGUARD_CORE_TAG:?DEFGUARD_CORE_TAG is required}" diff --git a/ova/files/start.sh b/ova/files/start.sh index bacd81e..9f5a1e8 100644 --- a/ova/files/start.sh +++ b/ova/files/start.sh @@ -11,8 +11,9 @@ # - path: /opt/stacks/defguard/enable-docker-management # content: "" -PROFILES_FILE="/opt/stacks/defguard/active-profiles" -ENABLE_DOCKER_MGMT_FILE="/opt/stacks/defguard/enable-docker-management" +STACK_DIR="${DEFGUARD_STACK_DIR:-/opt/stacks/defguard}" +PROFILES_FILE="$STACK_DIR/active-profiles" +ENABLE_DOCKER_MGMT_FILE="$STACK_DIR/enable-docker-management" # Append the dockge profile if the opt-in flag file is present _maybe_add_dockge() { @@ -33,7 +34,7 @@ if [ ! -f "$PROFILES_FILE" ]; then if [ -n "$COMPOSE_PROFILES" ]; then export COMPOSE_PROFILES fi - docker compose -f /opt/stacks/defguard/docker-compose.yaml up -d + docker compose -f "$STACK_DIR/docker-compose.yaml" up -d else COMPOSE_PROFILES=$(tr '[:space:]' ',' < "$PROFILES_FILE" | tr -s ',' | sed 's/,$//') if [ -z "$COMPOSE_PROFILES" ]; then @@ -44,10 +45,10 @@ else else unset COMPOSE_PROFILES fi - docker compose -f /opt/stacks/defguard/docker-compose.yaml up -d + docker compose -f "$STACK_DIR/docker-compose.yaml" up -d else COMPOSE_PROFILES=$(_maybe_add_dockge "$COMPOSE_PROFILES") export COMPOSE_PROFILES - docker compose -f /opt/stacks/defguard/docker-compose.standalone.yaml up -d + docker compose -f "$STACK_DIR/docker-compose.standalone.yaml" up -d fi fi diff --git a/ova/tests/config.bats b/ova/tests/config.bats new file mode 100644 index 0000000..0e80c79 --- /dev/null +++ b/ova/tests/config.bats @@ -0,0 +1,49 @@ +#!/usr/bin/env bats +# `docker compose config` evaluates profiles without pulling images, so the +# expected service set per profile combination can be checked offline. + +load helpers + +setup() { + command -v docker >/dev/null 2>&1 || skip "docker not installed" + docker compose version >/dev/null 2>&1 || skip "docker compose v2 not available" + make_stack + write_env +} + +teardown() { + teardown_stack +} + +# sorted, space-joined active services for a given file ($1) and profiles ($2) +services_for() { + COMPOSE_PROFILES="$2" docker compose -f "$STACK_DIR/$1" config --services 2>/dev/null | sort | xargs +} + +@test "all-in-one without profiles -> core db edge gateway" { + [ "$(services_for docker-compose.yaml "")" = "core db edge gateway" ] +} + +@test "all-in-one with dockge -> core db dockge edge gateway" { + [ "$(services_for docker-compose.yaml "dockge")" = "core db dockge edge gateway" ] +} + +@test "standalone core -> core db" { + [ "$(services_for docker-compose.standalone.yaml "core")" = "core db" ] +} + +@test "standalone core,gateway -> core db gateway" { + [ "$(services_for docker-compose.standalone.yaml "core,gateway")" = "core db gateway" ] +} + +@test "standalone core,edge,gateway -> core db edge gateway" { + [ "$(services_for docker-compose.standalone.yaml "core,edge,gateway")" = "core db edge gateway" ] +} + +@test "standalone core,edge,gateway,dockge -> core db dockge edge gateway" { + [ "$(services_for docker-compose.standalone.yaml "core,edge,gateway,dockge")" = "core db dockge edge gateway" ] +} + +@test "standalone with no profiles -> no services" { + [ "$(services_for docker-compose.standalone.yaml "")" = "" ] +} diff --git a/ova/tests/firewall.bats b/ova/tests/firewall.bats new file mode 100644 index 0000000..33bb54c --- /dev/null +++ b/ova/tests/firewall.bats @@ -0,0 +1,58 @@ +#!/usr/bin/env bats +# iptables/ip6tables/sysctl/sleep are stubbed so only defguard-firewall.sh's +# rule logic is exercised, with no effect on the host firewall. + +load helpers + +setup() { + BIN="$(mktemp -d)" + cp "$STUB_DIR/iptables-stub" "$BIN/iptables" + cp "$STUB_DIR/iptables-stub" "$BIN/ip6tables" + cp "$STUB_DIR/noop" "$BIN/sysctl" + cp "$STUB_DIR/noop" "$BIN/sleep" + chmod +x "$BIN"/* + export PATH="$BIN:$PATH" + export IPT_STUB_LOG="$BIN/ipt.log" + : > "$IPT_STUB_LOG" + export DEFGUARD_SYSCTL_DIR="$BIN/sysctl.d" + mkdir "$DEFGUARD_SYSCTL_DIR" +} + +teardown() { + rm -rf "$BIN" +} + +inserts() { + grep -c -- '-I DOCKER-USER' "$IPT_STUB_LOG" +} + +@test "whitelists wg+ in/out for both iptables and ip6tables" { + run bash "$FILES_DIR/defguard-firewall.sh" + [ "$status" -eq 0 ] + [ "$(inserts)" -eq 4 ] + grep -q -- 'iptables -I DOCKER-USER -i wg+ -j ACCEPT' "$IPT_STUB_LOG" + grep -q -- 'iptables -I DOCKER-USER -o wg+ -j ACCEPT' "$IPT_STUB_LOG" + grep -q -- 'ip6tables -I DOCKER-USER -i wg+ -j ACCEPT' "$IPT_STUB_LOG" + grep -q -- 'ip6tables -I DOCKER-USER -o wg+ -j ACCEPT' "$IPT_STUB_LOG" +} + +@test "writes the forwarding sysctl drop-in" { + run bash "$FILES_DIR/defguard-firewall.sh" + [ "$status" -eq 0 ] + conf="$DEFGUARD_SYSCTL_DIR/99-defguard-forward.conf" + [ -f "$conf" ] + grep -qx 'net.ipv4.ip_forward = 1' "$conf" + grep -qx 'net.ipv6.conf.all.forwarding = 1' "$conf" +} + +@test "idempotent: no inserts when the rules already exist" { + IPT_RULE_EXISTS=1 run bash "$FILES_DIR/defguard-firewall.sh" + [ "$status" -eq 0 ] + [ "$(inserts)" -eq 0 ] +} + +@test "exits cleanly without inserts when DOCKER-USER is absent" { + IPT_CHAIN_EXISTS=0 run bash "$FILES_DIR/defguard-firewall.sh" + [ "$status" -eq 0 ] + [ "$(inserts)" -eq 0 ] +} diff --git a/ova/tests/generate-env.bats b/ova/tests/generate-env.bats new file mode 100644 index 0000000..2575aea --- /dev/null +++ b/ova/tests/generate-env.bats @@ -0,0 +1,52 @@ +#!/usr/bin/env bats + +load helpers + +setup() { + make_stack +} + +teardown() { + teardown_stack +} + +@test "generates .env with tags sourced from .image-tags" { + write_image_tags aaa bbb ccc + run bash "$FILES_DIR/generate-env.sh" + [ "$status" -eq 0 ] + [ -f "$STACK_DIR/.env" ] + grep -qx 'DEFGUARD_CORE_TAG=aaa' "$STACK_DIR/.env" + grep -qx 'DEFGUARD_PROXY_TAG=bbb' "$STACK_DIR/.env" + grep -qx 'DEFGUARD_GATEWAY_TAG=ccc' "$STACK_DIR/.env" +} + +@test "db and postgres passwords match and are non-empty" { + write_image_tags + bash "$FILES_DIR/generate-env.sh" + dbp=$(grep '^DEFGUARD_DB_PASSWORD=' "$STACK_DIR/.env" | cut -d= -f2) + pgp=$(grep '^POSTGRES_PASSWORD=' "$STACK_DIR/.env" | cut -d= -f2) + [ -n "$dbp" ] + [ "$dbp" = "$pgp" ] +} + +@test ".env is created with 600 permissions" { + write_image_tags + bash "$FILES_DIR/generate-env.sh" + perm=$(stat -c '%a' "$STACK_DIR/.env" 2>/dev/null || stat -f '%Lp' "$STACK_DIR/.env") + [ "$perm" = "600" ] +} + +@test "existing .env is left untouched (idempotent)" { + echo "SENTINEL=keep-me" > "$STACK_DIR/.env" + write_image_tags + run bash "$FILES_DIR/generate-env.sh" + [ "$status" -eq 0 ] + grep -qx 'SENTINEL=keep-me' "$STACK_DIR/.env" +} + +@test "fails and writes nothing when .image-tags is missing" { + run bash "$FILES_DIR/generate-env.sh" + [ "$status" -ne 0 ] + [[ "$output" == *"DEFGUARD_CORE_TAG is required"* ]] + [ ! -f "$STACK_DIR/.env" ] +} diff --git a/ova/tests/helpers.bash b/ova/tests/helpers.bash new file mode 100644 index 0000000..a92775f --- /dev/null +++ b/ova/tests/helpers.bash @@ -0,0 +1,45 @@ +# shellcheck shell=bash + +OVA_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +FILES_DIR="$OVA_DIR/files" +STUB_DIR="$OVA_DIR/tests/stub" + +# DEFGUARD_STACK_DIR redirects the scripts at this temp dir; compose files are +# copied in so `docker compose config` and real `up` see the actual definitions. +make_stack() { + STACK_DIR="$(mktemp -d)" + export DEFGUARD_STACK_DIR="$STACK_DIR" + cp "$FILES_DIR/docker-compose.yaml" "$STACK_DIR/" + cp "$FILES_DIR/docker-compose.standalone.yaml" "$STACK_DIR/" +} + +teardown_stack() { + [ -n "${STACK_DIR:-}" ] && rm -rf "$STACK_DIR" + return 0 +} + +# Bake image tags as the Packer build does; generate-env.sh sources this. +write_image_tags() { + cat > "$STACK_DIR/.image-tags" < "$STACK_DIR/.env" < "$DOCKER_STUB_LOG" +} + +teardown() { + teardown_stack +} + +@test "no active-profiles, no dockge -> all-in-one, profiles unset" { + run bash "$FILES_DIR/start.sh" + [ "$status" -eq 0 ] + [ "$(last_compose_file)" = "docker-compose.yaml" ] + [ "$(last_profiles)" = "" ] +} + +@test "no active-profiles, dockge enabled -> all-in-one, dockge profile" { + touch "$STACK_DIR/enable-docker-management" + run bash "$FILES_DIR/start.sh" + [ "$status" -eq 0 ] + [ "$(last_compose_file)" = "docker-compose.yaml" ] + [ "$(last_profiles)" = "dockge" ] +} + +@test "active-profiles=core -> standalone, core profile" { + echo "core" > "$STACK_DIR/active-profiles" + run bash "$FILES_DIR/start.sh" + [ "$status" -eq 0 ] + [ "$(last_compose_file)" = "docker-compose.standalone.yaml" ] + [ "$(last_profiles)" = "core" ] +} + +@test "active-profiles='core gateway' (space separated) -> core,gateway" { + printf 'core gateway\n' > "$STACK_DIR/active-profiles" + run bash "$FILES_DIR/start.sh" + [ "$status" -eq 0 ] + [ "$(last_compose_file)" = "docker-compose.standalone.yaml" ] + [ "$(last_profiles)" = "core,gateway" ] +} + +@test "active-profiles multiline + dockge -> profiles plus dockge appended" { + printf 'core\nedge\ngateway\n' > "$STACK_DIR/active-profiles" + touch "$STACK_DIR/enable-docker-management" + run bash "$FILES_DIR/start.sh" + [ "$status" -eq 0 ] + [ "$(last_compose_file)" = "docker-compose.standalone.yaml" ] + [ "$(last_profiles)" = "core,edge,gateway,dockge" ] +} + +@test "empty/whitespace active-profiles -> falls back to all-in-one" { + printf ' \n' > "$STACK_DIR/active-profiles" + run bash "$FILES_DIR/start.sh" + [ "$status" -eq 0 ] + [ "$(last_compose_file)" = "docker-compose.yaml" ] + [ "$(last_profiles)" = "" ] +} + +@test "empty active-profiles + dockge -> all-in-one with dockge" { + printf '\n' > "$STACK_DIR/active-profiles" + touch "$STACK_DIR/enable-docker-management" + run bash "$FILES_DIR/start.sh" + [ "$status" -eq 0 ] + [ "$(last_compose_file)" = "docker-compose.yaml" ] + [ "$(last_profiles)" = "dockge" ] +} diff --git a/ova/tests/stub/docker b/ova/tests/stub/docker new file mode 100755 index 0000000..2549f92 --- /dev/null +++ b/ova/tests/stub/docker @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Records `docker compose` invocations instead of running them, so start.sh's +# profile selection can be asserted without a daemon. +log="${DOCKER_STUB_LOG:?DOCKER_STUB_LOG must be set}" +{ + printf 'compose_profiles=%s\n' "${COMPOSE_PROFILES-}" + printf 'args=%s\n' "$*" +} >> "$log" +exit 0 diff --git a/ova/tests/stub/iptables-stub b/ova/tests/stub/iptables-stub new file mode 100755 index 0000000..7dc3f9d --- /dev/null +++ b/ova/tests/stub/iptables-stub @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Records iptables/ip6tables calls without touching the host. Copy onto PATH as +# both `iptables` and `ip6tables` (it reports $0). Toggle simulated state with: +# IPT_CHAIN_EXISTS=1 -> `-L DOCKER-USER` probe succeeds (default) +# IPT_RULE_EXISTS=0 -> `-C` rule check fails, i.e. rule absent (default) +name="$(basename "$0")" +log="${IPT_STUB_LOG:?IPT_STUB_LOG must be set}" +printf '%s %s\n' "$name" "$*" >> "$log" + +case "$*" in + *"-L DOCKER-USER"*) [ "${IPT_CHAIN_EXISTS:-1}" = "1" ] && exit 0 || exit 1 ;; + *"-C "*) [ "${IPT_RULE_EXISTS:-0}" = "1" ] && exit 0 || exit 1 ;; +esac +exit 0 diff --git a/ova/tests/stub/noop b/ova/tests/stub/noop new file mode 100755 index 0000000..c5441de --- /dev/null +++ b/ova/tests/stub/noop @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# On PATH as `sysctl`/`sleep`: swallow the call so tests neither touch the host +# nor wait on the retry loop. +exit 0