ops: apply Traefik office allowlist for git and n8n UIs

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-21 18:20:25 +02:00
parent 73b3693eeb
commit 5d448c0e71
5 changed files with 297 additions and 8 deletions

26
.gitignore vendored
View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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())

View File

@@ -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())