[object Object]

← back to Dw Yolo Loop

deploy-vendor.sh: gate -internal hosts by default (TK-11391 root cause)

4c101204851ed08de08a543f76fa7df71f21a68e · 2026-09-10 11:27:14 -0700 · Steve Abrams

This template emitted an nginx vhost with NO auth_basic at all, so every microsite it
deployed came up publicly browsable. That is how 65 "-internal" hosts ended up ungated —
including phillip-jeffries-internal, which served 4,377 SKUs of a SHOWROOM-ONLY line to
anyone. Gating the 64 by hand fixes the instances; this fixes the source.

Gate is chosen from the fleet's own naming convention: an "-internal" host is a trade/spec
viewer and gets auth_basic + /etc/nginx/.htpasswd-dwvendors (same realm as the other ~453);
a public vendor landing (artmura, grasscloth) does not. GATE=1 / GATE=0 override either way.

Refuses to deploy an internal host at all if the htpasswd file is missing on the target,
rather than silently shipping it ungated. Adds a post-deploy edge check that warns loudly
if a host meant to be gated answers anything but 401/403.

Verified by rendering the template for all three cases: -internal gated, public landing
open, GATE=1 forces gating on a public name. bash -n clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Files touched

Diff

commit 4c101204851ed08de08a543f76fa7df71f21a68e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 10 11:27:14 2026 -0700

    deploy-vendor.sh: gate -internal hosts by default (TK-11391 root cause)
    
    This template emitted an nginx vhost with NO auth_basic at all, so every microsite it
    deployed came up publicly browsable. That is how 65 "-internal" hosts ended up ungated —
    including phillip-jeffries-internal, which served 4,377 SKUs of a SHOWROOM-ONLY line to
    anyone. Gating the 64 by hand fixes the instances; this fixes the source.
    
    Gate is chosen from the fleet's own naming convention: an "-internal" host is a trade/spec
    viewer and gets auth_basic + /etc/nginx/.htpasswd-dwvendors (same realm as the other ~453);
    a public vendor landing (artmura, grasscloth) does not. GATE=1 / GATE=0 override either way.
    
    Refuses to deploy an internal host at all if the htpasswd file is missing on the target,
    rather than silently shipping it ungated. Adds a post-deploy edge check that warns loudly
    if a host meant to be gated answers anything but 401/403.
    
    Verified by rendering the template for all three cases: -internal gated, public landing
    open, GATE=1 forces gating on a public name. bash -n clean.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---
 artmura-site/deploy-vendor.sh | 34 +++++++++++++++++++++++++++++++++-
 1 file changed, 33 insertions(+), 1 deletion(-)

diff --git a/artmura-site/deploy-vendor.sh b/artmura-site/deploy-vendor.sh
index 5818de1..38f2626 100644
--- a/artmura-site/deploy-vendor.sh
+++ b/artmura-site/deploy-vendor.sh
@@ -47,13 +47,36 @@ rsync -az "$DATA" "$HANDLES" "$KAM:$APP/_data/"
 echo "→ install + pm2 (BUNDLE=1, VENDOR=$VENDOR, port $PORT)"
 ssh "$KAM" "cd $APP && npm install --omit=dev --silent; pm2 delete $PM2 2>/dev/null; cd $APP && VENDOR=$VENDOR BUNDLE=1 PORT=$PORT pm2 start server.js --name $PM2 && pm2 save"
 
+# TK-11391 — gate by default on the fleet's own naming convention. An "-internal" host is
+# a trade/spec viewer and must sit behind the same Basic Auth as the other ~453 of them; a
+# public vendor LANDING (artmura, grasscloth) must not. This template previously emitted NO
+# auth_basic at all, which is how 65 "-internal" hosts ended up publicly browsable — including
+# phillip-jeffries-internal, a showroom line. Override either way with GATE=1 / GATE=0.
+HTPASSWD="${HTPASSWD:-/etc/nginx/.htpasswd-dwvendors}"
+REALM="${REALM:-Designer Wallcoverings — Trade Lines (Restricted)}"
+case "${GATE:-auto}" in
+  1) DO_GATE=1 ;;
+  0) DO_GATE=0 ;;
+  *) case "$DOMAIN" in *-internal.*) DO_GATE=1 ;; *) DO_GATE=0 ;; esac ;;
+esac
+if [ "$DO_GATE" = 1 ]; then
+  ssh "$KAM" "[ -f '$HTPASSWD' ]" || { echo "ABORT: $HTPASSWD missing on $KAM — refusing to deploy an internal host ungated"; exit 1; }
+  AUTH_BLOCK="        auth_basic \"$REALM\";
+        auth_basic_user_file $HTPASSWD;
+"
+  echo "  gate: ON  (auth_basic via $HTPASSWD)"
+else
+  AUTH_BLOCK=""
+  echo "  gate: off (public landing)"
+fi
+
 echo "→ nginx vhost"
 ssh "$KAM" "cat > /etc/nginx/sites-available/$DOMAIN <<NGINX
 server {
     listen 80;
     server_name $DOMAIN;
     location / {
-        proxy_pass http://127.0.0.1:$PORT;
+$AUTH_BLOCK        proxy_pass http://127.0.0.1:$PORT;
         proxy_set_header Host \\\$host;
         proxy_set_header X-Real-IP \\\$remote_addr;
         proxy_set_header X-Forwarded-For \\\$proxy_add_x_forwarded_for;
@@ -69,4 +92,13 @@ ssh "$KAM" "certbot --nginx -d $DOMAIN --non-interactive --agree-tos -m steve@de
 
 echo "→ smoke test"
 ssh "$KAM" "curl -s -o /dev/null -w 'local pm2 → %{http_code}\n' http://127.0.0.1:$PORT/api/config"
+if [ "$DO_GATE" = 1 ]; then
+  echo "→ gate check (public edge must be 401, not 200)"
+  code=$(curl -s -o /dev/null -w '%{http_code}' -m 20 "https://$DOMAIN/" 2>/dev/null)
+  case "$code" in
+    401|403) echo "  OK — $DOMAIN is gated ($code)" ;;
+    000)     echo "  (edge not reachable yet — DNS/cert pending; re-check after certbot)" ;;
+    *)       echo "  !! WARNING — $DOMAIN returned $code, expected 401. An internal host is PUBLIC." ;;
+  esac
+fi
 echo "DONE. Ensure Cloudflare DNS: A $DOMAIN → 45.61.58.125 (proxied)."

← 5b0f860 Guard the vendor-microsite generator against showroom-only l  ·  back to Dw Yolo Loop  ·  TK-11860: sync showroom-vendors snapshot to canonical fix-li 0efe7cc →