#!/bin/sh
# Copyright (c) 2026 Progralink. Licensed under the DevCage License — see LICENSE.
#
# DinD LAN isolation — blocks RFC 1918 traffic from containers spawned
# inside the Docker-in-Docker sidecar while preserving internet access.
#
# Injected via: compose exec -T dind sh -s < dind-lan-block.sh
# Runs inside the DinD sidecar (Alpine-based, has iptables).
#
# Waits for dockerd to create the DOCKER-USER chain, then inserts:
#   1. conntrack ESTABLISHED,RELATED → RETURN  (return traffic from internet)
#   2. Docker-internal subnet whitelist → RETURN (inter-container traffic)
#   3. REJECT rules for private/link-local subnets (scoped to external interface)
#   4. RETURN (allow everything else — public internet)
#
# Block rules use "-o <ext_iface>" so they only match traffic leaving the
# DinD container to the outside network.  Return traffic from the internet
# enters via the external interface but exits via Docker bridge interfaces
# (docker0, br-*), so it naturally bypasses the block rules without needing
# conntrack.  Conntrack is still installed as defense-in-depth.
#
# POSIX sh compatible — no bash arrays or process substitution.
#
set -eu

# Determine best rejection action: REJECT --reject-with > plain REJECT > DROP
# The nf_tables backend (Alpine/newer kernels) may not support --reject-with.
# Sets globals: _ACTION_CMD (REJECT|DROP), _ACTION_FLAG, _ACTION_VALUE
_probe_reject_action() {
    # Probe using TEST-NET-1 (RFC 5737) — non-routable, safe for ephemeral rules
    if iptables -A DOCKER-USER -d 192.0.2.0/24 -j REJECT --reject-with icmp-net-unreachable 2>/dev/null; then
        iptables -D DOCKER-USER -d 192.0.2.0/24 -j REJECT --reject-with icmp-net-unreachable 2>/dev/null
        _ACTION_CMD="REJECT"; _ACTION_FLAG="--reject-with"; _ACTION_VALUE="icmp-net-unreachable"
    elif iptables -A DOCKER-USER -d 192.0.2.0/24 -j REJECT 2>/dev/null; then
        iptables -D DOCKER-USER -d 192.0.2.0/24 -j REJECT 2>/dev/null
        _ACTION_CMD="REJECT"; _ACTION_FLAG=""; _ACTION_VALUE=""
    else
        _ACTION_CMD="DROP"; _ACTION_FLAG=""; _ACTION_VALUE=""
    fi
}

# Apply a block rule: _block4 <chain> <subnet>
# When _EXT_IFACE is set, adds "-o $_EXT_IFACE" so the rule only matches
# traffic leaving the DinD container to the outside network.
_block4() {
    if [ "${_EXT_IFACE:-}" != "" ]; then
        if [ "$_ACTION_FLAG" != "" ]; then
            iptables -A "$1" -o "$_EXT_IFACE" -d "$2" -j "$_ACTION_CMD" "$_ACTION_FLAG" "$_ACTION_VALUE"
        else
            iptables -A "$1" -o "$_EXT_IFACE" -d "$2" -j "$_ACTION_CMD"
        fi
    else
        if [ "$_ACTION_FLAG" != "" ]; then
            iptables -A "$1" -d "$2" -j "$_ACTION_CMD" "$_ACTION_FLAG" "$_ACTION_VALUE"
        else
            iptables -A "$1" -d "$2" -j "$_ACTION_CMD"
        fi
    fi
}

# Wait for dockerd to be ready (creates DOCKER-USER chain)
_waited=0
while ! iptables -L DOCKER-USER -n >/dev/null 2>&1; do
    sleep 1
    _waited=$((_waited + 1))
    if [ "$_waited" -ge 60 ]; then
        echo "dind-lan-block: ERROR: DOCKER-USER chain not found after 60s" >&2
        exit 1
    fi
done

# Flush any existing rules (idempotent on re-run)
iptables -F DOCKER-USER

# Detect available rejection action (nf_tables backend may lack REJECT module)
_probe_reject_action

# ── Detect the external (non-bridge) network interface ──────────────
# Block rules are scoped to this interface so they only match traffic
# leaving the DinD container toward the outside network.  Return traffic
# from the internet exits via Docker bridge interfaces (docker0, br-*)
# and naturally bypasses the block rules — no conntrack needed for that.
_EXT_IFACE=""
_EXT_IFACE=$(ip -4 route | awk '/^default/ {print $5; exit}')
if [ "$_EXT_IFACE" = "" ]; then
    echo "dind-lan-block: WARNING: could not detect external interface — block rules will not be interface-scoped" >&2
fi

# ── Allow return traffic for outbound connections (defense-in-depth) ──
# The "-o ext_iface" scoping on block rules already preserves internet
# access, but conntrack provides an explicit allow for established
# connections as an additional safety layer.
# Try loading the conntrack kernel module (DinD is privileged).
modprobe nf_conntrack 2>/dev/null || true
modprobe xt_conntrack 2>/dev/null || true

if iptables -A DOCKER-USER -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN 2>/dev/null; then
    : # conntrack module available
elif iptables -A DOCKER-USER -m state --state ESTABLISHED,RELATED -j RETURN 2>/dev/null; then
    : # legacy state module fallback
else
    if [ "$_EXT_IFACE" = "" ]; then
        echo "dind-lan-block: WARNING: no conntrack/state module and no external interface detected — internet may not work inside DinD containers" >&2
    fi
    # When _EXT_IFACE is set, block rules are interface-scoped and internet
    # works without conntrack.  Only warn if both safeguards are missing.
fi

# ── Allow Docker-internal subnets (inter-container traffic) ──────────
# Detect bridge subnets from routing table and whitelist them so that
# new connections between containers on the same bridge are not blocked
# by the RFC 1918 rules.  Only accept /16 or more specific to prevent
# overly broad ACCEPT rules.
ip -4 route | awk '!/default/ && /src/ {print $1}' | while IFS= read -r _cidr; do
    case "$_cidr" in
        */*)
            _prefix="${_cidr##*/}"
            if [ "$_prefix" -ge 16 ] 2>/dev/null; then
                iptables -A DOCKER-USER -d "$_cidr" -j RETURN
            else
                echo "dind-lan-block: WARNING: skipping broad subnet ${_cidr} (prefix < /16)" >&2
            fi
            ;;
    esac
done

# ── Block RFC 1918 private ranges ────────────────────────────────────
# Scoped to "-o $_EXT_IFACE" when available, so only outbound traffic
# toward the outside network is blocked.  Return traffic from the
# internet exits via Docker bridge interfaces and is not matched.
_block4 DOCKER-USER 10.0.0.0/8
_block4 DOCKER-USER 172.16.0.0/12
_block4 DOCKER-USER 192.168.0.0/16

# Block link-local
_block4 DOCKER-USER 169.254.0.0/16

# Block Carrier-Grade NAT (100.64.0.0/10) — used by Tailscale, WireGuard, CGNAT
_block4 DOCKER-USER 100.64.0.0/10
# Block benchmarking range (198.18.0.0/15, RFC 2544) — should never carry real traffic
_block4 DOCKER-USER 198.18.0.0/15

# RETURN so Docker's own rules still process allowed traffic
iptables -A DOCKER-USER -j RETURN

# Info messages only in verbose mode; errors/warnings always go to stderr
if [ "${VERBOSE:-0}" = "1" ]; then echo "dind-lan-block: LAN isolation rules installed in DOCKER-USER chain (action: ${_ACTION_CMD}${_ACTION_FLAG:+ $_ACTION_FLAG $_ACTION_VALUE}, iface: ${_EXT_IFACE:-any})"; fi

# ── IPv6 LAN blocking ──────────────────────────────────────────────
# Docker does not create a DOCKER-USER chain for ip6tables, so we
# insert rules into the FORWARD chain directly.  Rules use -I (insert
# at position 1) so they are evaluated before Docker's own FORWARD rules
# (which would ACCEPT the traffic).  Each -I pushes previous inserts down,
# so the conntrack/interface rules are inserted LAST to end up first.
#
# Block rules are scoped to "-o $_EXT_IFACE6" (when detected) so return
# traffic from the internet is not affected.
if command -v ip6tables >/dev/null 2>&1; then
    # Detect IPv6 external interface (may differ from IPv4)
    _EXT_IFACE6=""
    _EXT_IFACE6=$(ip -6 route 2>/dev/null | awk '/^default/ {print $5; exit}')
    # Fall back to IPv4 external interface if no IPv6 default route
    : "${_EXT_IFACE6:=$_EXT_IFACE}"

    # Probe for REJECT support (may not be available with nf_tables backend)
    _v6_action="DROP"
    if ip6tables -A FORWARD -d fc00::/7 -j REJECT 2>/dev/null; then
        ip6tables -D FORWARD -d fc00::/7 -j REJECT 2>/dev/null
        _v6_action="REJECT"
    fi

    # _block6 <subnet> — insert IPv6 block rule, scoped to external interface when available
    _block6() {
        if [ "$_EXT_IFACE6" != "" ]; then
            ip6tables -I FORWARD -o "$_EXT_IFACE6" -d "$1" -j "$_v6_action" 2>/dev/null || true
        else
            ip6tables -I FORWARD -d "$1" -j "$_v6_action" 2>/dev/null || true
        fi
    }

    # Insert block rules (each -I pushes previous to next position)
    # Scoped to external interface when available.
    _block6 fc00::/7
    _block6 fe80::/10
    _block6 ::ffff:10.0.0.0/104
    _block6 ::ffff:172.16.0.0/108
    _block6 ::ffff:192.168.0.0/112
    _block6 ::ffff:169.254.0.0/112
    _block6 ::ffff:100.64.0.0/106
    _block6 ::ffff:198.18.0.0/111
    # Insert conntrack LAST so it ends up at position 1 (before all block rules)
    ip6tables -I FORWARD -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN 2>/dev/null \
        || ip6tables -I FORWARD -m state --state ESTABLISHED,RELATED -j RETURN 2>/dev/null \
        || true
    if [ "${VERBOSE:-0}" = "1" ]; then echo "dind-lan-block: IPv6 LAN rules installed in FORWARD chain (action: ${_v6_action}, iface: ${_EXT_IFACE6:-any})"; fi
else
    echo "dind-lan-block: WARNING: ip6tables not available — IPv6 LAN traffic not blocked" >&2
fi
