Skip to content

Instantly share code, notes, and snippets.

@rkoster
Last active June 1, 2026 12:44
Show Gist options
  • Select an option

  • Save rkoster/851d1183d685900426e2945dc253a884 to your computer and use it in GitHub Desktop.

Select an option

Save rkoster/851d1183d685900426e2945dc253a884 to your computer and use it in GitHub Desktop.
PoC: DNS-based domain blocking in Cloud Foundry using BOSH DNS handlers

DNS-Based Domain Blocking in Cloud Foundry

A proof-of-concept demonstrating that Cloud Foundry's existing BOSH DNS handler system can be used to block domains — no code changes required.

How it works

CF apps on Noble stemcells resolve DNS via:

App container → systemd-resolved (127.0.0.53) → BOSH DNS (169.254.0.2)

BOSH DNS loads handler configs from /var/vcap/jobs/*/dns/handlers.json. By dropping a handlers.json that registers a domain with a dead recursor (127.0.0.1:1), BOSH DNS will fail to resolve that domain — effectively blocking it for all apps on that cell.

[
  {
    "domain": "example.com.",
    "source": {
      "type": "dns",
      "recursors": ["127.0.0.1:1"]
    }
  }
]

What it looks like on the cell

compute/005b2125:~$ cat /var/vcap/jobs/dns-blocklist/dns/handlers.json
[
  {
    "domain": "example.com.",
    "source": {
      "type": "dns",
      "recursors": ["127.0.0.1:1"]
    }
  }
]

compute/005b2125:~$ tail -5 /etc/resolv.conf
# operation for /etc/resolv.conf.

nameserver 127.0.0.53
options edns0 trust-ad
search apps.internal example.com bosh ...

compute/005b2125:~$ curl -I example.com
curl: (6) Could not resolve host: example.com

Results

BEFORE: dig example.com +short → 172.66.147.243, 104.20.23.154
AFTER:  dig example.com +short → (empty)
        curl example.com       → exit 6 "Could not resolve host"

Why it works (even with disable_recursors: true)

On Noble stemcells, BOSH DNS runs with disable_recursors: true and configure_systemd_resolved: true. This means:

  • systemd-resolved is the system resolver (127.0.0.53)
  • BOSH DNS binds to a dummy bosh-dns interface at 169.254.0.2
  • systemd-resolved routes queries to BOSH DNS

Custom handlers from handlers_files_glob are still loaded and registered in the mux, regardless of the disable_recursors setting. When a query hits BOSH DNS for a domain with a registered handler, that handler runs — even if general recursion is disabled.

The "dead recursor" trick (127.0.0.1:1) causes the ForwardHandler to immediately fail, returning an empty response (no A/AAAA records). The app sees a DNS failure and cannot connect.

Usage

./poc-dns-block.sh example.com          # block example.com
./poc-dns-block.sh evil.com my-app      # block evil.com, use existing app

Cleanup

bosh -d cf ssh <cell-instance> -c \
  'sudo rm -rf /var/vcap/jobs/dns-blocklist && sudo /var/vcap/bosh/bin/monit restart bosh-dns'

Toward production

For a proper implementation, BOSH DNS should gain a native "type": "deny" handler (~15 lines of Go) that returns NXDOMAIN immediately instead of relying on a timeout to a dead recursor. The blocklist could be managed via BOSH properties or a config file watched by the existing systemdresolvedupdater.

#!/run/current-system/sw/bin/bash
# PoC: DNS-based domain blocking in Cloud Foundry via BOSH DNS handlers
#
# Demonstrates blocking a domain by injecting a handlers.json into the
# BOSH DNS handlers_files_glob path on a Diego cell, then proving it
# via cf ssh from an app container.
#
# Prerequisites:
# - cf CLI logged in and targeting an org/space
# - An app already pushed (or this script will push one)
#
# Usage:
# ./poc-dns-block.sh [domain-to-block]
# ./poc-dns-block.sh neverssl.com
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/bosh.env"
DOMAIN_TO_BLOCK="${1:-neverssl.com}"
APP_NAME="${2:-dns-block-test}"
DEPLOYMENT="${BOSH_DEPLOYMENT:-cf}"
echo "=== CF DNS Blocking PoC ==="
echo "Domain to block: $DOMAIN_TO_BLOCK"
echo "App name: $APP_NAME"
echo ""
# --- Step 1: Ensure we have a test app ---
echo "[1/6] Ensuring test app '$APP_NAME' exists..."
if ! cf app "$APP_NAME" &>/dev/null; then
echo " Pushing a simple test app..."
APP_DIR=$(mktemp -d)
cat > "$APP_DIR/main.go" <<'EOF'
package main
import (
"fmt"
"net/http"
"os"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "dns-block-test running")
})
port := os.Getenv("PORT")
if port == "" { port = "8080" }
http.ListenAndServe(":"+port, nil)
}
EOF
cat > "$APP_DIR/go.mod" <<'EOF'
module dns-block-test
go 1.21
EOF
cf push "$APP_NAME" -p "$APP_DIR" -b go_buildpack -m 64M -k 256M
rm -rf "$APP_DIR"
else
echo " App already exists."
fi
echo ""
# --- Step 2: Baseline — prove the domain resolves BEFORE blocking ---
echo "[2/6] Baseline test: resolving '$DOMAIN_TO_BLOCK' from app container..."
echo " Running: dig $DOMAIN_TO_BLOCK +short"
BEFORE=$(cf ssh "$APP_NAME" -c "dig $DOMAIN_TO_BLOCK +short 2>/dev/null || nslookup $DOMAIN_TO_BLOCK 2>&1 | grep -A2 'Name:'" 2>&1 | grep -v "Stats server") || true
echo " BEFORE blocking:"
echo " $BEFORE"
if [ -z "$BEFORE" ]; then
echo " NOTE: dig returned empty, trying curl..."
BEFORE=$(cf ssh "$APP_NAME" -c "curl -s -o /dev/null -w '%{http_code}' --connect-timeout 5 http://$DOMAIN_TO_BLOCK/" 2>&1 | grep -v "Stats server") || true
echo " curl http_code: $BEFORE"
fi
echo ""
# --- Step 3: Find the Diego cell hosting our app ---
echo "[3/6] Finding Diego cell for app..."
APP_GUID=$(cf app "$APP_NAME" --guid)
# Try v3 stats endpoint
CELL_IP=$(cf curl "/v3/apps/$APP_GUID/processes/web/stats" 2>/dev/null | \
jq -r '(.resources // [])[0].host // empty' 2>/dev/null) || true
if [ -z "$CELL_IP" ]; then
# v3 didn't work, try v2
CELL_IP=$(cf curl "/v2/apps/$APP_GUID/stats" 2>/dev/null | \
jq -r '.["0"].stats.host // empty' 2>/dev/null) || true
fi
if [ -z "$CELL_IP" ]; then
echo " WARNING: Could not determine cell IP from app stats. Using first compute/diego cell..."
DIEGO_CELL=$(bosh -d "$DEPLOYMENT" vms --json 2>/dev/null | \
sed -n '/^{/,$p' | \
jq -r '.Tables[0].Rows[] | select(.instance | test("compute|diego.cell|diego-cell")) | .instance' 2>/dev/null | head -1) || true
else
echo " App is on cell with IP: $CELL_IP"
DIEGO_CELL=$(bosh -d "$DEPLOYMENT" vms --json 2>/dev/null | \
sed -n '/^{/,$p' | \
jq -r --arg ip "$CELL_IP" '.Tables[0].Rows[] | select(.ips | contains($ip)) | .instance' 2>/dev/null | head -1) || true
fi
if [ -z "$DIEGO_CELL" ]; then
echo " ERROR: Could not find Diego cell. Aborting."
exit 1
fi
echo " BOSH instance: $DIEGO_CELL"
echo ""
# --- Step 4: Inject the blocking handler on the Diego cell ---
echo "[4/6] Injecting DNS block handler on Diego cell via bosh ssh..."
echo " Creating /var/vcap/jobs/dns-blocklist/dns/handlers.json"
echo " with domain: $DOMAIN_TO_BLOCK"
# The handlers_files_glob is /var/vcap/jobs/*/dns/handlers.json
# We create a fake job directory that matches this glob.
# The handler uses type "dns" pointing to a dead recursor (port 1 on loopback).
# This causes BOSH DNS to return NXDOMAIN after failing to reach the recursor.
BLOCK_SCRIPT=$(cat <<SCRIPT
#!/bin/bash
set -e
# Create fake job dir matching handlers_files_glob
sudo mkdir -p /var/vcap/jobs/dns-blocklist/dns
# Write the blocking handler config
sudo tee /var/vcap/jobs/dns-blocklist/dns/handlers.json > /dev/null <<'HANDLER'
[
{
"domain": "${DOMAIN_TO_BLOCK}.",
"source": {
"type": "dns",
"recursors": ["127.0.0.1:1"]
}
}
]
HANDLER
echo "Handler file written:"
cat /var/vcap/jobs/dns-blocklist/dns/handlers.json
# Check current bosh-dns config to see if disable_recursors is set
echo ""
echo "Current bosh-dns config (relevant fields):"
sudo grep -E '"(disable_recursors|configure_systemd_resolved|handlers_files_glob)"' /var/vcap/jobs/bosh-dns/config/config.json || true
# Restart bosh-dns to pick up the new handler
echo ""
echo "Restarting bosh-dns..."
sudo /var/vcap/bosh/bin/monit restart bosh-dns
# Wait for bosh-dns to come back
echo "Waiting for bosh-dns to restart..."
sleep 3
for i in {1..10}; do
if dig @169.254.0.2 +short +time=1 health.bosh-dns. | grep -q "127.0.0.1"; then
echo " bosh-dns is back up (attempt \$i)"
break
fi
sleep 1
done
SCRIPT
)
bosh -d "$DEPLOYMENT" ssh "$DIEGO_CELL" -c "$BLOCK_SCRIPT"
echo ""
# --- Step 5: Test — prove the domain is now BLOCKED ---
echo "[5/6] Testing: resolving '$DOMAIN_TO_BLOCK' from app container AFTER blocking..."
sleep 2
AFTER=$(cf ssh "$APP_NAME" -c "dig $DOMAIN_TO_BLOCK +short +time=3 2>/dev/null; echo EXIT_CODE=\$?" 2>&1 | grep -v "Stats server") || true
echo " AFTER blocking (dig):"
echo " $AFTER"
# Also test with curl to show the practical effect
echo ""
echo " Testing curl to http://$DOMAIN_TO_BLOCK/ ..."
CURL_AFTER=$(cf ssh "$APP_NAME" -c "curl -s -o /dev/null -w 'http_code=%{http_code} time=%{time_total}s' --connect-timeout 5 http://$DOMAIN_TO_BLOCK/ 2>&1; echo ' exit='\$?" 2>&1 | grep -v "Stats server") || true
echo " $CURL_AFTER"
echo ""
# --- Step 6: Summary ---
echo "[6/6] Results summary"
echo "============================================"
echo "BEFORE blocking:"
echo " $BEFORE"
echo ""
echo "AFTER blocking:"
echo " dig: $AFTER"
echo " curl: $CURL_AFTER"
echo "============================================"
echo ""
echo "=== Cleanup ==="
echo "To remove the block:"
echo " bosh -d $DEPLOYMENT ssh $DIEGO_CELL -c 'sudo rm -rf /var/vcap/jobs/dns-blocklist && sudo /var/vcap/bosh/bin/monit restart bosh-dns'"
echo ""
echo "To delete the test app:"
echo " cf delete $APP_NAME -f -r"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment