From 5d448c0e71868be0c4109052ffacb187ccbcaac7 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Mon, 21 Sep 2026 18:20:25 +0200 Subject: [PATCH] ops: apply Traefik office allowlist for git and n8n UIs Co-authored-by: Cursor --- .gitignore | 26 ++++ SECURITY.md | 20 ++- docs/RUNBOOK.md | 5 +- scripts/dokploy_apply_traefik_harden.py | 164 ++++++++++++++++++++++++ scripts/dokploy_rls_inventory.py | 90 +++++++++++++ 5 files changed, 297 insertions(+), 8 deletions(-) create mode 100644 scripts/dokploy_apply_traefik_harden.py create mode 100644 scripts/dokploy_rls_inventory.py diff --git a/.gitignore b/.gitignore index 5570520..e58c4e6 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,32 @@ dist/ # local ops noise scripts/dokploy-shots/ +# one-shot cutover / debug (kept locally; not for main) +scripts/dokploy_cleanup_and_sql.py +scripts/dokploy_configure_deploy_portal.py +scripts/dokploy_create_and_deploy_portal.py +scripts/dokploy_cutover_portal.py +scripts/dokploy_deploy_portal_now.py +scripts/dokploy_env_reveal_save.py +scripts/dokploy_finalize_portal.py +scripts/dokploy_find_app_host.py +scripts/dokploy_find_supabase.py +scripts/dokploy_inspect_env.py +scripts/dokploy_inspect_portal.py +scripts/dokploy_inventory_domains.py +scripts/dokploy_redeploy_portal.py +scripts/dokploy_retarget_traefik.py +scripts/dokploy_run_creatives_sql.py +scripts/dokploy_set_env_and_ops.py +scripts/dokploy_sql_terminal_b64.py +scripts/dokploy_sql_via_terminal.py +scripts/dokploy_stop_orphan.py +scripts/dokploy_traefik_cm.py +scripts/dokploy_traefik_expand.py +scripts/dokploy_traefik_insert.py +scripts/dokploy_traefik_only.py +scripts/dokploy_traefik_unlock.py +scripts/dokploy_view_deploy_error.py tsconfig.tsbuildinfo # debug / OS diff --git a/SECURITY.md b/SECURITY.md index 8532bcd..2d58a90 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,11 +5,17 @@ El PDF “Agencia 2030” y el antiguo `.env.example` del repo contenían **JWTs reales** (anon + service_role) y passwords de demo. -1. En Supabase Studio → Settings → API: **regenerar** `anon` y `service_role`. -2. Actualizar secrets en Dokploy / env del contenedor `app` y de n8n. -3. Cambiar passwords de `admin@…` y clientes demo; invalidar sesiones. -4. Rotar `ANTHROPIC_API_KEY` si estuvo en chats/docs. -5. Confirmar que `.env.example` solo tiene placeholders (ya corregido en este repo). +### Rotación (checklist — hacerlo en ventana corta) + +1. Supabase Studio → Settings → API → **regenerar** `anon` y `service_role`. +2. Dokploy → client-portal Environment: pegar anon + service_role nuevos → Save → Deploy. +3. n8n (credenciales Supabase / HTTP headers): actualizar API keys. +4. Local: actualizar `.env.local` (gitignored); no tocar `.env.example` (placeholders). +5. Invalidar sesiones: Auth → Users → sign-out / cambiar password `marketing@4ventures.es` si hubo leak de demo. +6. Smoke: `curl /api/health` → `serviceRoleConfigured true`; `python3 scripts/smoke_operator_login.py`. +7. Rotar `ANTHROPIC_API_KEY` si estuvo en chats/docs (opcional, independiente). + +**No automatizar la regeneración** desde scripts: hace falta confirmación en Studio. ## Superficies a restringir (amenaza) @@ -20,8 +26,8 @@ El PDF “Agencia 2030” y el antiguo `.env.example` del repo contenían **JWTs | `supabase` Studio | Solo red interna / SSO admin | | `app` | Público OK; APIs tras sesión (middleware) | -Patch Traefik listo en `.ops/HARDEN_SURFACES.md` (**no aplicado**). -Candidata egress oficina (2026-09-21): `90.161.248.78/32` — confirmar si es fija antes de allowlist. +Patch Traefik **aplicado** 2026-09-21 (ver `.ops/HARDEN_SURFACES.md`). +Allowlist oficina: `90.161.248.78/32` — UI git/n8n; webhooks abiertos. Si el ISP cambia la IP, actualizar middleware `office-allowlist`. ## Controles en código (este repo) diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index 255b064..372b8ae 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -40,7 +40,10 @@ UI `/dashboard/agencia` (roles admin/team) o RPC `provision_client`. ## Endurecer git / n8n -Ver `.ops/HARDEN_SURFACES.md` (IP allowlist; no aplicado hasta tener IPs). +Aplicado 2026-09-21: IP allowlist `90.161.248.78/32` en UI de git + n8n; webhooks `/webhook` abiertos. +Detalle / revert: `.ops/HARDEN_SURFACES.md`. Re-aplicar: `python3 scripts/dokploy_apply_traefik_harden.py`. + +`app.4ventures.es` y Dokploy **sin** allowlist. ## Incidentes diff --git a/scripts/dokploy_apply_traefik_harden.py b/scripts/dokploy_apply_traefik_harden.py new file mode 100644 index 0000000..fc41b28 --- /dev/null +++ b/scripts/dokploy_apply_traefik_harden.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Apply Traefik harden patches (office IP allowlist). Does NOT touch portal.yml / app / supabase.""" +from __future__ import annotations + +import re +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + +from playwright.sync_api import sync_playwright + +BPS = Path("/Users/4ventures/Projects/bps-facturacion") +ROOT = Path("/Users/4ventures/4ventures-client-portal") +PATCH = ROOT / ".ops" / "traefik-patches" +SHOT = ROOT / "scripts" / "dokploy-shots" +BASE = "https://dokploy.4ventures.es" + +# (ui label in Traefik file list, local patch file) +FILES = [ + ("middleware-hardening.yml", "middleware-hardening.yml"), + ("gitea.yml", "gitea.yml"), + ("n8n-hardened.yml", "n8n-hardened.yml"), + ("rutas-manuales.yml", "rutas-manuales.yml"), +] + + +def env(key: str) -> str: + import os + + v = os.environ.get(key, "").strip() + if v: + return v + p = BPS / ".env" + if not p.is_file(): + return "" + for line in p.read_text(encoding="utf-8").splitlines(): + if line.startswith(f"{key}="): + return line.split("=", 1)[1].strip().strip('"').strip("'") + return "" + + +def set_monaco(page, val: str) -> str: + mode = page.evaluate( + """(val) => { + const m = window.monaco; + if (m && m.editor) { + const eds = m.editor.getEditors(); + if (eds && eds[0]) { eds[0].setValue(val); return 'monaco'; } + } + const cm = document.querySelector('.cm-content'); + if (cm) { + cm.focus(); + return 'cm'; + } + return 'none'; + }""", + val, + ) + if mode == "cm": + page.locator(".cm-content").first.click(force=True) + page.keyboard.press("Meta+A") + page.keyboard.insert_text(val) + return mode + + +def probe(url: str) -> tuple[int, str]: + try: + req = urllib.request.Request(url, method="GET", headers={"User-Agent": "4v-harden-probe"}) + with urllib.request.urlopen(req, timeout=15) as r: + return r.status, (r.read(80) or b"").decode("utf-8", "replace") + except urllib.error.HTTPError as e: + return e.code, (e.read(80) or b"").decode("utf-8", "replace") + except Exception as e: + return 0, type(e).__name__ + + +def main() -> int: + email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es" + password = env("DOKPLOY_PASSWORD") + if not password: + print("NO_DOKPLOY_PASSWORD", flush=True) + return 2 + SHOT.mkdir(parents=True, exist_ok=True) + + for label, fname in FILES: + if not (PATCH / fname).is_file(): + print("MISSING_PATCH", fname, flush=True) + return 3 + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1600, "height": 1200}) + page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000) + page.wait_for_timeout(700) + if "/dashboard" not in page.url: + page.locator('input[type="email"], input[name="email"]').first.fill(email) + page.locator('input[type="password"]').first.fill(password) + page.locator('button[type="submit"]').first.click() + page.wait_for_url(re.compile(r"/dashboard"), timeout=90000) + print("login ok", flush=True) + + page.goto(f"{BASE}/dashboard/traefik", wait_until="networkidle", timeout=120000) + page.wait_for_timeout(1000) + page.get_by_text(re.compile(r"^dynamic$", re.I)).first.click() + page.wait_for_timeout(900) + + for label, fname in FILES: + yaml = (PATCH / fname).read_text(encoding="utf-8") + # find file in list + loc = page.get_by_text(re.compile(rf"^{re.escape(label)}$", re.I)) + if not loc.count(): + loc = page.locator(f"text={label}") + if not loc.count(): + print("FILE_MISSING_IN_UI", label, flush=True) + page.screenshot(path=str(SHOT / f"harden_missing_{fname}.png"), full_page=True) + browser.close() + return 4 + loc.first.dblclick() + page.wait_for_timeout(1200) + unlock = page.get_by_role("button", name=re.compile(r"^Unlock$", re.I)) + if unlock.count(): + unlock.first.click() + page.wait_for_timeout(800) + mode = set_monaco(page, yaml) + print("set", label, mode, "chars", len(yaml), flush=True) + upd = page.get_by_role("button", name=re.compile(r"^Update$", re.I)) + if not upd.count(): + upd = page.get_by_role("button", name=re.compile(r"Update|Save", re.I)) + upd.first.click() + page.wait_for_timeout(2000) + page.screenshot(path=str(SHOT / f"harden_applied_{fname.replace('.','_')}.png"), full_page=True) + # back to file list + page.goto(f"{BASE}/dashboard/traefik", wait_until="networkidle", timeout=120000) + page.wait_for_timeout(700) + page.get_by_text(re.compile(r"^dynamic$", re.I)).first.click() + page.wait_for_timeout(700) + + browser.close() + + time.sleep(3) + checks = [ + ("git", "https://git.4ventures.es/"), + ("n8n_ui", "https://n8n.4ventures.es/"), + ("n8n_agencia_ui", "https://n8n-agencia.4ventures.es/"), + ("app", "https://app.4ventures.es/api/health"), + ("dokploy", "https://dokploy.4ventures.es/"), + ] + ok = True + for name, url in checks: + code, head = probe(url) + print(f"probe {name} {code} {head[:60]!r}", flush=True) + # From allowlisted IP: git/n8n should NOT be 403 + if name in ("git", "n8n_ui", "n8n_agencia_ui") and code == 403: + ok = False + if name == "app" and code != 200: + ok = False + print("SUCCESS" if ok else "CHECK_FAILED", flush=True) + return 0 if ok else 5 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/dokploy_rls_inventory.py b/scripts/dokploy_rls_inventory.py new file mode 100644 index 0000000..9adf148 --- /dev/null +++ b/scripts/dokploy_rls_inventory.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Run RLS checklist query in supabase-db; print table/RLS summary.""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +from playwright.sync_api import sync_playwright + +ROOT = Path("/Users/4ventures/4ventures-client-portal") +BPS = Path("/Users/4ventures/Projects/bps-facturacion") +SHOT = ROOT / "scripts/dokploy-shots" +BASE = "https://dokploy.4ventures.es" +OUT = ROOT / ".ops" / "rls_inventory.txt" + +QUERY = r""" +SELECT c.relname AS table_name, c.relrowsecurity AS rls_enabled +FROM pg_class c +JOIN pg_namespace n ON n.oid = c.relnamespace +WHERE n.nspname = 'public' AND c.relkind = 'r' +ORDER BY 1; +""" + + +def env(key: str) -> str: + import os + + v = os.environ.get(key, "").strip() + if v: + return v + p = BPS / ".env" + if not p.is_file(): + return "" + for line in p.read_text(encoding="utf-8").splitlines(): + if line.startswith(f"{key}="): + return line.split("=", 1)[1].strip().strip('"').strip("'") + return "" + + +def main() -> int: + email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es" + password = env("DOKPLOY_PASSWORD") + # compact to one line for psql -c + q = " ".join(QUERY.split()) + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1600, "height": 1200}) + page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000) + page.wait_for_timeout(700) + if "/dashboard" not in page.url: + page.locator('input[type="email"], input[name="email"]').first.fill(email) + page.locator('input[type="password"]').first.fill(password) + page.locator('button[type="submit"]').first.click() + page.wait_for_url(re.compile(r"/dashboard"), timeout=90000) + + page.goto(f"{BASE}/dashboard/docker", wait_until="networkidle", timeout=120000) + page.wait_for_timeout(1000) + page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill("supabase-db") + page.wait_for_timeout(1000) + page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click() + page.wait_for_timeout(500) + page.get_by_text(re.compile(r"^Terminal$", re.I)).first.click() + page.wait_for_timeout(3000) + + helper = page.locator("textarea.xterm-helper-textarea") + if helper.count(): + helper.first.click() + cmd = f"psql -U postgres -d postgres -c \"{q}\" ; echo RLS_DONE" + page.keyboard.type(cmd, delay=2) + page.keyboard.press("Enter") + page.wait_for_timeout(6000) + page.screenshot(path=str(SHOT / "rls_term.png"), full_page=True) + text = page.evaluate( + """() => { + const rows = document.querySelector('.xterm-rows'); + return rows ? rows.innerText : ''; + }""" + ) + OUT.parent.mkdir(exist_ok=True) + OUT.write_text(text or "", encoding="utf-8") + print(text[-2500:] if text else "NO_TEXT", flush=True) + browser.close() + print("wrote", OUT, flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())