diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 4d73724..e250c34 100644 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -97,16 +97,79 @@ resolve_existing_web_proxy_subnet() { fi local project_name="${COMPOSE_PROJECT_NAME:-$(basename "$PWD")}" network_name subnet + local labelled_networks labelled_network_id labelled_project network_name="${project_name}_web_proxy" if ! docker network inspect "$network_name" >/dev/null 2>&1; then - return 0 + # Compose projects may have been created from a differently named checkout. + # A unique network carrying Compose's logical `web_proxy` label is the same + # production fact even when its generated resource name differs. + labelled_networks="" + if [ -z "${COMPOSE_PROJECT_NAME:-}" ]; then + labelled_networks="$(docker network ls --filter label=com.docker.compose.network=web_proxy -q 2>/dev/null || true)" + fi + if [ "$(printf '%s\n' "$labelled_networks" | sed '/^$/d' | wc -l | tr -d '[:space:]')" = "1" ]; then + labelled_network_id="$(printf '%s\n' "$labelled_networks" | sed '/^$/d')" + labelled_project="$(docker network inspect -f '{{index .Labels "com.docker.compose.project"}}' "$labelled_network_id" 2>/dev/null || true)" + if [[ "$labelled_project" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]]; then + export COMPOSE_PROJECT_NAME="$labelled_project" + network_name="$labelled_network_id" + else + network_name="" + fi + else + network_name="" + fi fi - subnet="$(docker network inspect -f '{{range .IPAM.Config}}{{println .Subnet}}{{end}}' "$network_name" 2>/dev/null | head -n 1 | tr -d '[:space:]')" + subnet="" + if [ -n "$network_name" ]; then + subnet="$(docker network inspect -f '{{range .IPAM.Config}}{{println .Subnet}}{{end}}' "$network_name" 2>/dev/null | head -n 1 | tr -d '[:space:]')" + fi if [[ "$subnet" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/([0-9]|[12][0-9]|3[0-2])$ ]]; then export WEB_PROXY_SUBNET="$subnet" echo "Reusing WEB_PROXY_SUBNET from the existing ${network_name} Docker network." + return 0 fi + + # First creation of the isolated proxy network: use the documented default + # only after proving it does not overlap any network currently on this host. + local candidate="172.31.250.0/29" existing_output existing + local candidate_start candidate_end existing_start existing_end + local -a network_ids=() + mapfile -t network_ids < <(docker network ls -q 2>/dev/null) + if [ "${#network_ids[@]}" -eq 0 ]; then + return 0 + fi + if ! existing_output="$(docker network inspect -f '{{range .IPAM.Config}}{{println .Subnet}}{{end}}' "${network_ids[@]}" 2>/dev/null)"; then + return 0 + fi + + read -r candidate_start candidate_end < <(ipv4_cidr_bounds "$candidate") || return 0 + while IFS= read -r existing; do + [[ "$existing" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/([0-9]|[12][0-9]|3[0-2])$ ]] || continue + read -r existing_start existing_end < <(ipv4_cidr_bounds "$existing") || continue + if (( candidate_start <= existing_end && existing_start <= candidate_end )); then + return 0 + fi + done <<< "$existing_output" + + export WEB_PROXY_SUBNET="$candidate" + echo "Using non-overlapping WEB_PROXY_SUBNET ${candidate} after Docker network inventory." +} + +ipv4_cidr_bounds() { + local cidr="$1" address prefix a b c d value ip size start + address="${cidr%/*}" + prefix="${cidr#*/}" + IFS='.' read -r a b c d <<< "$address" + for value in "$a" "$b" "$c" "$d"; do + [[ "$value" =~ ^[0-9]{1,3}$ ]] && ((10#$value <= 255)) || return 1 + done + [[ "$prefix" =~ ^[0-9]+$ ]] && ((10#$prefix <= 32)) || return 1 + ip=$(( (10#$a << 24) + (10#$b << 16) + (10#$c << 8) + 10#$d )) + size=$(( 1 << (32 - 10#$prefix) )) + start=$(( ip & ~(size - 1) )) + printf '%s %s\n' "$start" "$((start + size - 1))" } validate_deploy_config() { diff --git a/docs/verification/sec-002-ingress-compose.md b/docs/verification/sec-002-ingress-compose.md index c30ed93..39de697 100644 --- a/docs/verification/sec-002-ingress-compose.md +++ b/docs/verification/sec-002-ingress-compose.md @@ -32,7 +32,7 @@ No image was pulled and no production or persistent service was changed. Ephemer ## Limitations and production gates - No Traefik configuration exists in this repository. Verify its exact `Host()` rule, TLS route, replacement of forwarding headers, selected Docker network, and hostile-Host rejection on the operator host. -- `WEB_PROXY_SUBNET` must be chosen after production Docker-network inventory; the example value is not a production fact. Deploys may recover a missing value only from the existing Compose `web_proxy` network, preserving the already-running production CIDR without guessing a new one. +- `WEB_PROXY_SUBNET` must be chosen after production Docker-network inventory. Deploys recover a missing value from a uniquely labelled existing Compose `web_proxy` network. On its first creation, the documented `172.31.250.0/29` candidate is accepted only after a complete Docker network inventory proves it does not overlap; unreadable or overlapping inventory remains fail-closed. - Host firewall and `docker ps`/published-port state are unverified. - The exact `nginx:1.29.8-alpine` base image was not installed locally. Syntax was checked with the existing local nginx frontend image; approved CI must build the pinned Dockerfile. - A complete local proxy/browser smoke was not run because rebuilding the pinned container would require an unavailable base image/package access. No browser claim is made.