Add purge authorization codes for protected domains (myvexx.com).
Root generates single-use codes in Infra with root password; Serviços purge requires code plus root password for PURGE_EXTRA_AUTH_DOMAINS.
This commit is contained in:
parent
0ee4845818
commit
a39618afb8
8 changed files with 446 additions and 8 deletions
|
|
@ -21,6 +21,7 @@ from app import crm_leads, integration_health
|
||||||
from app.cloudflare_dns import fetch_domain_dns
|
from app.cloudflare_dns import fetch_domain_dns
|
||||||
from app.modules.routes import router as modules_router
|
from app.modules.routes import router as modules_router
|
||||||
from app.vm112_domains_routes import router as vm112_domains_router
|
from app.vm112_domains_routes import router as vm112_domains_router
|
||||||
|
from app.purge_auth_routes import router as purge_auth_router
|
||||||
from app.carbonio_release_routes import router as carbonio_release_router
|
from app.carbonio_release_routes import router as carbonio_release_router
|
||||||
from app.migration.router import router as migration_router
|
from app.migration.router import router as migration_router
|
||||||
from app.billing_routes import router as billing_router
|
from app.billing_routes import router as billing_router
|
||||||
|
|
@ -123,6 +124,7 @@ app.include_router(assist_router)
|
||||||
app.include_router(crm_router)
|
app.include_router(crm_router)
|
||||||
app.include_router(modules_router)
|
app.include_router(modules_router)
|
||||||
app.include_router(vm112_domains_router)
|
app.include_router(vm112_domains_router)
|
||||||
|
app.include_router(purge_auth_router)
|
||||||
app.include_router(security_router)
|
app.include_router(security_router)
|
||||||
app.include_router(carbonio_release_router)
|
app.include_router(carbonio_release_router)
|
||||||
app.include_router(migration_router)
|
app.include_router(migration_router)
|
||||||
|
|
@ -175,8 +177,10 @@ def init_db():
|
||||||
migration_store.init_schema(conn)
|
migration_store.init_schema(conn)
|
||||||
billing_store.init_schema(conn)
|
billing_store.init_schema(conn)
|
||||||
from app.vm112_purge_jobs import init_purge_jobs_schema
|
from app.vm112_purge_jobs import init_purge_jobs_schema
|
||||||
|
from app.purge_auth_codes import init_purge_auth_schema
|
||||||
|
|
||||||
init_purge_jobs_schema(conn)
|
init_purge_jobs_schema(conn)
|
||||||
|
init_purge_auth_schema(conn)
|
||||||
conn.execute("PRAGMA journal_mode=WAL")
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
conn.execute("PRAGMA busy_timeout=30000")
|
conn.execute("PRAGMA busy_timeout=30000")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
|
||||||
196
projects/ops-desk/api/app/purge_auth_codes.py
Normal file
196
projects/ops-desk/api/app/purge_auth_codes.py
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
"""Códigos de autorização purge — domínios protegidos (Spec 017 extensão)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import sqlite3
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app import auth, vm112_domains
|
||||||
|
|
||||||
|
DEFAULT_TTL_HOURS = int(__import__("os").getenv("PURGE_AUTH_CODE_TTL_HOURS", "24"))
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso() -> str:
|
||||||
|
return _now().isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def init_purge_auth_schema(conn: sqlite3.Connection) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS purge_auth_codes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
code_hash TEXT NOT NULL,
|
||||||
|
note TEXT,
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
used_at TEXT,
|
||||||
|
used_by TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purge_auth_codes_domain_active
|
||||||
|
ON purge_auth_codes(domain, expires_at, used_at)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_code(code: str) -> str:
|
||||||
|
return code.upper().replace("-", "").replace(" ", "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def format_code_display(raw: str) -> str:
|
||||||
|
raw = normalize_code(raw)
|
||||||
|
if len(raw) == 8:
|
||||||
|
return f"{raw[:4]}-{raw[4:]}"
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def generate_raw_code() -> str:
|
||||||
|
return secrets.token_hex(4).upper()
|
||||||
|
|
||||||
|
|
||||||
|
def requires_extra_auth(domain: str) -> bool:
|
||||||
|
return domain.lower().strip() in vm112_domains.PURGE_EXTRA_AUTH_DOMAINS
|
||||||
|
|
||||||
|
|
||||||
|
def create_code(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
domain: str,
|
||||||
|
root_password: str,
|
||||||
|
created_by: str,
|
||||||
|
note: str = "",
|
||||||
|
ttl_hours: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
if not vm112_domains.verify_root_password(conn, root_password):
|
||||||
|
raise ValueError("Senha Root incorrecta")
|
||||||
|
if domain in vm112_domains.PURGE_BLOCKLIST:
|
||||||
|
raise ValueError(f"Domínio {domain} está na blocklist absoluta — purge proibido")
|
||||||
|
if not requires_extra_auth(domain):
|
||||||
|
raise ValueError(f"Domínio {domain} não exige código extra de autorização")
|
||||||
|
|
||||||
|
ttl = ttl_hours if ttl_hours is not None else DEFAULT_TTL_HOURS
|
||||||
|
ttl = max(1, min(int(ttl), 168))
|
||||||
|
raw = generate_raw_code()
|
||||||
|
display = format_code_display(raw)
|
||||||
|
now = _now()
|
||||||
|
expires = now + timedelta(hours=ttl)
|
||||||
|
code_id = uuid.uuid4().hex[:16]
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO purge_auth_codes (
|
||||||
|
id, domain, code_hash, note, created_by, created_at, expires_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
code_id,
|
||||||
|
domain,
|
||||||
|
auth.hash_password(normalize_code(raw)),
|
||||||
|
(note or "").strip() or None,
|
||||||
|
created_by,
|
||||||
|
now.isoformat(),
|
||||||
|
expires.isoformat(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"id": code_id,
|
||||||
|
"domain": domain,
|
||||||
|
"code": display,
|
||||||
|
"expires_at": expires.isoformat(),
|
||||||
|
"ttl_hours": ttl,
|
||||||
|
"note": note or None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_and_consume(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
domain: str,
|
||||||
|
code: str,
|
||||||
|
used_by: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
if not requires_extra_auth(domain):
|
||||||
|
return True
|
||||||
|
normalized = normalize_code(code or "")
|
||||||
|
if len(normalized) < 6:
|
||||||
|
return False
|
||||||
|
|
||||||
|
now = _now_iso()
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, code_hash FROM purge_auth_codes
|
||||||
|
WHERE domain = ? AND used_at IS NULL AND expires_at > ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
""",
|
||||||
|
(domain, now),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
if auth.verify_password(normalized, row["code_hash"]):
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE purge_auth_codes
|
||||||
|
SET used_at = ?, used_by = ?
|
||||||
|
WHERE id = ? AND used_at IS NULL
|
||||||
|
""",
|
||||||
|
(now, used_by, row["id"]),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def list_codes(conn: sqlite3.Connection, active_only: bool = True, limit: int = 50) -> list[dict[str, Any]]:
|
||||||
|
limit = max(1, min(int(limit), 200))
|
||||||
|
now = _now_iso()
|
||||||
|
if active_only:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, domain, note, created_by, created_at, expires_at, used_at, used_by
|
||||||
|
FROM purge_auth_codes
|
||||||
|
WHERE used_at IS NULL AND expires_at > ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(now, limit),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, domain, note, created_by, created_at, expires_at, used_at, used_by
|
||||||
|
FROM purge_auth_codes
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": row["id"],
|
||||||
|
"domain": row["domain"],
|
||||||
|
"note": row["note"],
|
||||||
|
"created_by": row["created_by"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"expires_at": row["expires_at"],
|
||||||
|
"used_at": row["used_at"],
|
||||||
|
"used_by": row["used_by"],
|
||||||
|
"active": row["used_at"] is None and row["expires_at"] > now,
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
75
projects/ops-desk/api/app/purge_auth_routes.py
Normal file
75
projects/ops-desk/api/app/purge_auth_routes.py
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
"""Rotas Infra — códigos autorização purge (root / super_admin)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app import auth, purge_auth_codes, vm112_domains
|
||||||
|
from app.permissions import can_manage_users
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/infra", tags=["infra-purge-auth"])
|
||||||
|
|
||||||
|
|
||||||
|
class PurgeAuthCodeCreate(BaseModel):
|
||||||
|
domain: str = Field(..., min_length=3)
|
||||||
|
root_password: str = Field(..., min_length=1)
|
||||||
|
note: str = Field("", max_length=500)
|
||||||
|
ttl_hours: int = Field(24, ge=1, le=168)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_root_admin(user: auth.DeskUser = Depends(auth.get_current_user)) -> auth.DeskUser:
|
||||||
|
if not can_manage_users(user.role):
|
||||||
|
raise HTTPException(403, "Apenas super_admin (root) pode gerar códigos de purge")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/purge-auth-codes")
|
||||||
|
def list_purge_auth_codes(
|
||||||
|
active_only: bool = Query(True),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
user: auth.DeskUser = Depends(_require_root_admin),
|
||||||
|
):
|
||||||
|
conn = auth.db()
|
||||||
|
try:
|
||||||
|
purge_auth_codes.init_purge_auth_schema(conn)
|
||||||
|
codes = purge_auth_codes.list_codes(conn, active_only=active_only, limit=limit)
|
||||||
|
return {
|
||||||
|
"codes": codes,
|
||||||
|
"extra_auth_domains": sorted(vm112_domains.PURGE_EXTRA_AUTH_DOMAINS),
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/purge-auth-domains")
|
||||||
|
def list_purge_auth_domains(
|
||||||
|
user: auth.DeskUser = Depends(auth.get_current_user),
|
||||||
|
):
|
||||||
|
return {
|
||||||
|
"domains": sorted(vm112_domains.PURGE_EXTRA_AUTH_DOMAINS),
|
||||||
|
"can_generate": can_manage_users(user.role),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/purge-auth-codes")
|
||||||
|
def create_purge_auth_code(
|
||||||
|
body: PurgeAuthCodeCreate,
|
||||||
|
user: auth.DeskUser = Depends(_require_root_admin),
|
||||||
|
):
|
||||||
|
conn = auth.db()
|
||||||
|
try:
|
||||||
|
purge_auth_codes.init_purge_auth_schema(conn)
|
||||||
|
result = purge_auth_codes.create_code(
|
||||||
|
conn,
|
||||||
|
body.domain,
|
||||||
|
body.root_password,
|
||||||
|
user.username,
|
||||||
|
body.note,
|
||||||
|
ttl_hours=body.ttl_hours,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
@ -16,6 +16,9 @@ VM112_ADMIN_API_KEY = os.getenv("VM112_ADMIN_API_KEY", "ibytera-corp-api-key-cha
|
||||||
|
|
||||||
PURGE_BLOCKLIST = frozenset({"ligbox.com.br", "itecnologys.com"})
|
PURGE_BLOCKLIST = frozenset({"ligbox.com.br", "itecnologys.com"})
|
||||||
|
|
||||||
|
# Exige código de autorização gerado em Infra (root) — além de senha Root no purge.
|
||||||
|
PURGE_EXTRA_AUTH_DOMAINS = frozenset({"myvexx.com"})
|
||||||
|
|
||||||
VM112_PURGE_STEP_LABELS = (
|
VM112_PURGE_STEP_LABELS = (
|
||||||
"Contas Carbonio (zmprov da)",
|
"Contas Carbonio (zmprov da)",
|
||||||
"Domínio Carbonio (zmprov dd)",
|
"Domínio Carbonio (zmprov dd)",
|
||||||
|
|
@ -48,6 +51,10 @@ def verify_root_password(conn: sqlite3.Connection, password: str) -> bool:
|
||||||
return auth.verify_password(password, row["password_hash"])
|
return auth.verify_password(password, row["password_hash"])
|
||||||
|
|
||||||
|
|
||||||
|
def requires_purge_extra_auth(domain: str) -> bool:
|
||||||
|
return domain.lower().strip() in PURGE_EXTRA_AUTH_DOMAINS
|
||||||
|
|
||||||
|
|
||||||
def delete_carbonio_account(email: str) -> dict[str, Any]:
|
def delete_carbonio_account(email: str) -> dict[str, Any]:
|
||||||
"""Remove uma conta Carbonio (zmprov da) — Spec 022."""
|
"""Remove uma conta Carbonio (zmprov da) — Spec 022."""
|
||||||
email = email.lower().strip()
|
email = email.lower().strip()
|
||||||
|
|
@ -92,7 +99,11 @@ def get_domain(domain: str) -> dict[str, Any]:
|
||||||
headers=_vm112_headers(),
|
headers=_vm112_headers(),
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
data = r.json()
|
||||||
|
if isinstance(data, dict):
|
||||||
|
data["purge_extra_auth_required"] = requires_purge_extra_auth(domain)
|
||||||
|
data["purge_blocked"] = domain in PURGE_BLOCKLIST
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ router = APIRouter(prefix="/api/v1/vm112", tags=["vm112-domains"])
|
||||||
class DomainPurgeRequest(BaseModel):
|
class DomainPurgeRequest(BaseModel):
|
||||||
confirm_domain: str = Field(..., min_length=3)
|
confirm_domain: str = Field(..., min_length=3)
|
||||||
root_password: str = Field(..., min_length=1)
|
root_password: str = Field(..., min_length=1)
|
||||||
|
purge_auth_code: str = Field("", max_length=32)
|
||||||
|
|
||||||
|
|
||||||
def _require_admin(user: auth.DeskUser = Depends(auth.get_current_user)) -> auth.DeskUser:
|
def _require_admin(user: auth.DeskUser = Depends(auth.get_current_user)) -> auth.DeskUser:
|
||||||
|
|
@ -33,12 +34,36 @@ def _require_admin(user: auth.DeskUser = Depends(auth.get_current_user)) -> auth
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def _validate_purge_request(domain: str, body: DomainPurgeRequest) -> str:
|
def _validate_purge_request(
|
||||||
|
domain: str,
|
||||||
|
body: DomainPurgeRequest,
|
||||||
|
conn: sqlite3.Connection | None = None,
|
||||||
|
username: str | None = None,
|
||||||
|
) -> str:
|
||||||
domain = domain.lower().strip()
|
domain = domain.lower().strip()
|
||||||
if domain in vm112_domains.PURGE_BLOCKLIST:
|
if domain in vm112_domains.PURGE_BLOCKLIST:
|
||||||
raise HTTPException(400, f"Domínio {domain} está protegido contra purge")
|
raise HTTPException(400, f"Domínio {domain} está protegido contra purge")
|
||||||
if body.confirm_domain.lower().strip() != domain:
|
if body.confirm_domain.lower().strip() != domain:
|
||||||
raise HTTPException(400, "Confirmação do domínio não coincide")
|
raise HTTPException(400, "Confirmação do domínio não coincide")
|
||||||
|
if vm112_domains.requires_purge_extra_auth(domain):
|
||||||
|
if not body.purge_auth_code or not body.purge_auth_code.strip():
|
||||||
|
raise HTTPException(
|
||||||
|
403,
|
||||||
|
f"Domínio {domain} exige código de autorização — gere em Infra (root) e use na conferência",
|
||||||
|
)
|
||||||
|
from app import purge_auth_codes
|
||||||
|
|
||||||
|
db = conn or auth.db()
|
||||||
|
close_conn = conn is None
|
||||||
|
try:
|
||||||
|
purge_auth_codes.init_purge_auth_schema(db)
|
||||||
|
if not purge_auth_codes.validate_and_consume(
|
||||||
|
db, domain, body.purge_auth_code, used_by=username
|
||||||
|
):
|
||||||
|
raise HTTPException(403, "Código de autorização purge inválido ou expirado")
|
||||||
|
finally:
|
||||||
|
if close_conn:
|
||||||
|
db.close()
|
||||||
return domain
|
return domain
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -70,7 +95,7 @@ def purge_vm112_domain(
|
||||||
body: DomainPurgeRequest,
|
body: DomainPurgeRequest,
|
||||||
user: auth.DeskUser = Depends(_require_admin),
|
user: auth.DeskUser = Depends(_require_admin),
|
||||||
):
|
):
|
||||||
domain = _validate_purge_request(domain, body)
|
domain = _validate_purge_request(domain, body, username=user.username)
|
||||||
conn = auth.db()
|
conn = auth.db()
|
||||||
try:
|
try:
|
||||||
if not vm112_domains.verify_root_password(conn, body.root_password):
|
if not vm112_domains.verify_root_password(conn, body.root_password):
|
||||||
|
|
@ -104,7 +129,7 @@ def purge_vm112_domain_stream(
|
||||||
user: auth.DeskUser = Depends(_require_admin),
|
user: auth.DeskUser = Depends(_require_admin),
|
||||||
):
|
):
|
||||||
"""SSE — progresso purge em tempo real (Fase 2 Spec 017)."""
|
"""SSE — progresso purge em tempo real (Fase 2 Spec 017)."""
|
||||||
domain = _validate_purge_request(domain, body)
|
domain = _validate_purge_request(domain, body, username=user.username)
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
purge_sse_generator(domain, body.root_password, user.username),
|
purge_sse_generator(domain, body.root_password, user.username),
|
||||||
media_type="text/event-stream",
|
media_type="text/event-stream",
|
||||||
|
|
@ -123,7 +148,7 @@ def start_purge_job(
|
||||||
user: auth.DeskUser = Depends(_require_admin),
|
user: auth.DeskUser = Depends(_require_admin),
|
||||||
):
|
):
|
||||||
"""Inicia purge em background; consultar GET /purge/jobs/{id} (recomendado via Traefik)."""
|
"""Inicia purge em background; consultar GET /purge/jobs/{id} (recomendado via Traefik)."""
|
||||||
domain = _validate_purge_request(domain, body)
|
domain = _validate_purge_request(domain, body, username=user.username)
|
||||||
try:
|
try:
|
||||||
job_id = start_job(domain, body.root_password, user.username)
|
job_id = start_job(domain, body.root_password, user.username)
|
||||||
except PurgeJobConflictError as exc:
|
except PurgeJobConflictError as exc:
|
||||||
|
|
|
||||||
|
|
@ -3855,6 +3855,11 @@ async function renderInfra() {
|
||||||
</div>
|
</div>
|
||||||
<p class="health-card-hint">Alerta se gap > ${health.webhook_gap_alert_minutes || 15} min sem eventos VM112.</p>
|
<p class="health-card-hint">Alerta se gap > ${health.webhook_gap_alert_minutes || 15} min sem eventos VM112.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Códigos autorização purge</h3>
|
||||||
|
<p class="health-card-hint">Domínios protegidos (ex.: <code>myvexx.com</code>) exigem código único gerado aqui pelo <strong>root</strong> — válido para conferência / purge em Serviços.</p>
|
||||||
|
<div id="purge-auth-infra-panel"><p class="loading">A carregar…</p></div>
|
||||||
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>OpenPanel API — Re-engenharia Ligbox</h3>
|
<h3>OpenPanel API — Re-engenharia Ligbox</h3>
|
||||||
<p class="health-card-hint">Spec 028 · VM123 bridge :18087 · multidomínio · conta temporária com cleanup automático.</p>
|
<p class="health-card-hint">Spec 028 · VM123 bridge :18087 · multidomínio · conta temporária com cleanup automático.</p>
|
||||||
|
|
@ -3883,11 +3888,97 @@ async function renderInfra() {
|
||||||
document.getElementById('btn-refresh-health')?.addEventListener('click', () => renderInfra());
|
document.getElementById('btn-refresh-health')?.addEventListener('click', () => renderInfra());
|
||||||
document.getElementById('btn-test-webhook')?.addEventListener('click', () => runWebhookIntegrationTest('infra'));
|
document.getElementById('btn-test-webhook')?.addEventListener('click', () => runWebhookIntegrationTest('infra'));
|
||||||
document.getElementById('btn-test-openpanel-api')?.addEventListener('click', () => runOpenPanelApiTest());
|
document.getElementById('btn-test-openpanel-api')?.addEventListener('click', () => runOpenPanelApiTest());
|
||||||
|
await renderPurgeAuthInfraPanel();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
el.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
el.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function renderPurgeAuthInfraPanel() {
|
||||||
|
const panel = document.getElementById('purge-auth-infra-panel');
|
||||||
|
if (!panel) return;
|
||||||
|
try {
|
||||||
|
const meta = await api('/v1/infra/purge-auth-domains');
|
||||||
|
const domains = (meta.domains || []).map((d) => `<code>${esc(d)}</code>`).join(', ') || '—';
|
||||||
|
const canGen = meta.can_generate && typeof canManageUsers === 'function' && canManageUsers();
|
||||||
|
let codesHtml = '';
|
||||||
|
if (canGen) {
|
||||||
|
const data = await api('/v1/infra/purge-auth-codes?limit=20');
|
||||||
|
const rows = (data.codes || []).map((c) => `
|
||||||
|
<tr>
|
||||||
|
<td>${esc(c.domain)}</td>
|
||||||
|
<td>${esc(c.note || '—')}</td>
|
||||||
|
<td>${fmtDate(c.expires_at)}</td>
|
||||||
|
<td>${esc(c.created_by || '—')}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
codesHtml = `
|
||||||
|
<form id="purge-auth-generate-form" class="purge-auth-form">
|
||||||
|
<label>Domínio</label>
|
||||||
|
<input type="text" id="purge-auth-domain" class="cf-select" placeholder="myvexx.com" required />
|
||||||
|
<label>Nota (conferência / ticket)</label>
|
||||||
|
<input type="text" id="purge-auth-note" class="cf-select" placeholder="Autorizado em call com Roger" />
|
||||||
|
<label>Validade (horas)</label>
|
||||||
|
<input type="number" id="purge-auth-ttl" class="cf-select" value="24" min="1" max="168" />
|
||||||
|
<label>Senha Root</label>
|
||||||
|
<input type="password" id="purge-auth-root-pwd" class="cf-select" autocomplete="current-password" required />
|
||||||
|
<button type="submit" class="btn secondary">Gerar código</button>
|
||||||
|
</form>
|
||||||
|
<p id="purge-auth-gen-msg" class="ticket-meta"></p>
|
||||||
|
<div id="purge-auth-generated" class="purge-auth-generated hidden"></div>
|
||||||
|
<h4 style="margin-top:1rem">Códigos activos</h4>
|
||||||
|
<table class="purge-history-table">
|
||||||
|
<thead><tr><th>Domínio</th><th>Nota</th><th>Expira</th><th>Por</th></tr></thead>
|
||||||
|
<tbody>${rows || '<tr><td colspan="4">Nenhum código activo</td></tr>'}</tbody>
|
||||||
|
</table>`;
|
||||||
|
} else {
|
||||||
|
codesHtml = '<p class="ticket-meta">Apenas <strong>super_admin (root)</strong> gera códigos. Peça o código ao root antes do purge em Serviços.</p>';
|
||||||
|
}
|
||||||
|
panel.innerHTML = `
|
||||||
|
<p><strong>Domínios protegidos:</strong> ${domains}</p>
|
||||||
|
${codesHtml}`;
|
||||||
|
const form = panel.querySelector('#purge-auth-generate-form');
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const msg = panel.querySelector('#purge-auth-gen-msg');
|
||||||
|
const out = panel.querySelector('#purge-auth-generated');
|
||||||
|
const domain = panel.querySelector('#purge-auth-domain')?.value?.trim() || '';
|
||||||
|
const note = panel.querySelector('#purge-auth-note')?.value?.trim() || '';
|
||||||
|
const ttl = parseInt(panel.querySelector('#purge-auth-ttl')?.value || '24', 10);
|
||||||
|
const rootPwd = panel.querySelector('#purge-auth-root-pwd')?.value || '';
|
||||||
|
if (!domain || !rootPwd) {
|
||||||
|
if (msg) msg.textContent = 'Preencha domínio e senha Root.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg) msg.textContent = 'A gerar…';
|
||||||
|
try {
|
||||||
|
const res = await api('/v1/infra/purge-auth-codes', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
domain,
|
||||||
|
root_password: rootPwd,
|
||||||
|
note,
|
||||||
|
ttl_hours: ttl,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (msg) msg.textContent = 'Código gerado — copie agora (não será mostrado de novo).';
|
||||||
|
if (out) {
|
||||||
|
out.classList.remove('hidden');
|
||||||
|
out.innerHTML = `
|
||||||
|
<p><strong>Código:</strong> <code class="purge-auth-code-display">${esc(res.code)}</code></p>
|
||||||
|
<p class="ticket-meta">Domínio ${esc(res.domain)} · expira ${fmtDate(res.expires_at)}</p>`;
|
||||||
|
}
|
||||||
|
panel.querySelector('#purge-auth-root-pwd').value = '';
|
||||||
|
} catch (e) {
|
||||||
|
if (msg) msg.textContent = e.message || 'Falha ao gerar código';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
panel.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function refresh(options = {}) {
|
async function refresh(options = {}) {
|
||||||
const { poll = false } = options;
|
const { poll = false } = options;
|
||||||
await loadHealth();
|
await loadHealth();
|
||||||
|
|
|
||||||
|
|
@ -550,10 +550,14 @@ const DeskServices = (() => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollPurgeJob(domain, confirmDomain, rootPassword) {
|
async function pollPurgeJob(domain, confirmDomain, rootPassword, purgeAuthCode = '') {
|
||||||
const start = await apiFetch(`/v1/vm112/domains/${encodeURIComponent(domain)}/purge/jobs`, {
|
const start = await apiFetch(`/v1/vm112/domains/${encodeURIComponent(domain)}/purge/jobs`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ confirm_domain: confirmDomain, root_password: rootPassword }),
|
body: JSON.stringify({
|
||||||
|
confirm_domain: confirmDomain,
|
||||||
|
root_password: rootPassword,
|
||||||
|
purge_auth_code: purgeAuthCode || '',
|
||||||
|
}),
|
||||||
}, 60000);
|
}, 60000);
|
||||||
const jobId = start.job_id;
|
const jobId = start.job_id;
|
||||||
if (!jobId) throw new Error('Job purge não iniciado');
|
if (!jobId) throw new Error('Job purge não iniciado');
|
||||||
|
|
@ -731,17 +735,23 @@ const DeskServices = (() => {
|
||||||
const msg = document.getElementById('vm112-purge-msg');
|
const msg = document.getElementById('vm112-purge-msg');
|
||||||
const confirmDomain = document.getElementById('vm112-purge-confirm')?.value?.trim() || '';
|
const confirmDomain = document.getElementById('vm112-purge-confirm')?.value?.trim() || '';
|
||||||
const rootPassword = document.getElementById('vm112-purge-root-pwd')?.value || '';
|
const rootPassword = document.getElementById('vm112-purge-root-pwd')?.value || '';
|
||||||
|
const purgeAuthCode = document.getElementById('vm112-purge-auth-code')?.value?.trim() || '';
|
||||||
|
const needsAuthCode = Boolean(document.getElementById('vm112-purge-auth-code'));
|
||||||
if (!confirmDomain || !rootPassword) {
|
if (!confirmDomain || !rootPassword) {
|
||||||
if (msg) msg.textContent = 'Preencha domínio e senha Root.';
|
if (msg) msg.textContent = 'Preencha domínio e senha Root.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (needsAuthCode && !purgeAuthCode) {
|
||||||
|
if (msg) msg.textContent = 'Este domínio exige código de autorização — gere em Infra (root).';
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!window.confirm(`PURGE definitivo de ${domain}?`)) return;
|
if (!window.confirm(`PURGE definitivo de ${domain}?`)) return;
|
||||||
const btn = document.getElementById('vm112-purge-btn');
|
const btn = document.getElementById('vm112-purge-btn');
|
||||||
if (btn) { btn.disabled = true; btn.textContent = 'A apagar…'; }
|
if (btn) { btn.disabled = true; btn.textContent = 'A apagar…'; }
|
||||||
if (msg) { msg.textContent = 'A executar purge…'; msg.classList.remove('vm112-purge-success'); }
|
if (msg) { msg.textContent = 'A executar purge…'; msg.classList.remove('vm112-purge-success'); }
|
||||||
initPurgeTimelineRunning();
|
initPurgeTimelineRunning();
|
||||||
try {
|
try {
|
||||||
const done = await pollPurgeJob(domain, confirmDomain, rootPassword);
|
const done = await pollPurgeJob(domain, confirmDomain, rootPassword, purgeAuthCode);
|
||||||
stopPurgeElapsed();
|
stopPurgeElapsed();
|
||||||
showPurgeSuccess(done, domain);
|
showPurgeSuccess(done, domain);
|
||||||
return;
|
return;
|
||||||
|
|
@ -811,6 +821,14 @@ const DeskServices = (() => {
|
||||||
.map((a) => `<li>${esc(a)}</li>`).join('') || '<li class="muted">Nenhuma</li>';
|
.map((a) => `<li>${esc(a)}</li>`).join('') || '<li class="muted">Nenhuma</li>';
|
||||||
const cf = d.cloudflare_zone;
|
const cf = d.cloudflare_zone;
|
||||||
const cfTxt = cf?.name ? `Zona ${cf.name} (${cf.status || '—'})` : 'Sem zona Cloudflare Ibytera';
|
const cfTxt = cf?.name ? `Zona ${cf.name} (${cf.status || '—'})` : 'Sem zona Cloudflare Ibytera';
|
||||||
|
const extraAuth = Boolean(d.purge_extra_auth_required);
|
||||||
|
const authCodeField = extraAuth
|
||||||
|
? `
|
||||||
|
<p class="vm112-purge-warn">Domínio protegido — exige <strong>código de autorização</strong> gerado em Infra (root).</p>
|
||||||
|
<label>Código autorização purge</label>
|
||||||
|
<input type="text" id="vm112-purge-auth-code" class="vm112-purge-input" placeholder="XXXX-XXXX" autocomplete="off" spellcheck="false"/>
|
||||||
|
`
|
||||||
|
: '';
|
||||||
body.innerHTML = `
|
body.innerHTML = `
|
||||||
<div class="modal-section">
|
<div class="modal-section">
|
||||||
<h4>Serviço: E-mail Tenant</h4>
|
<h4>Serviço: E-mail Tenant</h4>
|
||||||
|
|
@ -835,6 +853,7 @@ const DeskServices = (() => {
|
||||||
<input type="text" id="vm112-purge-confirm" class="vm112-purge-input" placeholder="${esc(domain)}" autocomplete="off"/>
|
<input type="text" id="vm112-purge-confirm" class="vm112-purge-input" placeholder="${esc(domain)}" autocomplete="off"/>
|
||||||
<label>Senha Root</label>
|
<label>Senha Root</label>
|
||||||
<input type="password" id="vm112-purge-root-pwd" class="vm112-purge-input" autocomplete="current-password"/>
|
<input type="password" id="vm112-purge-root-pwd" class="vm112-purge-input" autocomplete="current-password"/>
|
||||||
|
${authCodeField}
|
||||||
<button type="button" class="btn btn-danger" id="vm112-purge-btn">Apagar domínio e todos os dados</button>
|
<button type="button" class="btn btn-danger" id="vm112-purge-btn">Apagar domínio e todos os dados</button>
|
||||||
<p id="vm112-purge-msg" class="ticket-meta"></p>
|
<p id="vm112-purge-msg" class="ticket-meta"></p>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
|
||||||
|
|
@ -3888,6 +3888,23 @@ button.health-card {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
|
.purge-auth-generated {
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: rgba(46, 125, 50, 0.12);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.purge-auth-generated.hidden { display: none; }
|
||||||
|
.purge-auth-code-display {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.purge-auth-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
max-width: 28rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
.purge-history-removed {
|
.purge-history-removed {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--muted, #6b7280);
|
color: var(--muted, #6b7280);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue