docs(vm112/vm122): espelho fixes wizard /admin + Desk VM001 + KB 2026-06-19
- deploy/vm112-wizard: main-wizard DomainAdmin route, clientSettings, FinishToolbar - deploy/vm122-desk: card Ligbox Datacenter Node VM001, audit sync - Spec 025: secao Passo Concluido CTAs - KB: Portal de gerenciamento reabria wizard Concluido
This commit is contained in:
parent
c9930dc8f8
commit
4248e3694c
13 changed files with 7280 additions and 0 deletions
31
ligbox-ops-platform/deploy/vm112-wizard/README.md
Normal file
31
ligbox-ops-platform/deploy/vm112-wizard/README.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# Deploy mirror — Wizard VM112 (2026-06-19)
|
||||
|
||||
Espelho dos ficheiros alterados em produção na **VM112** (`/opt/ligbox-wizard`).
|
||||
|
||||
## Alterações desta entrega
|
||||
|
||||
| Ficheiro | Alteração |
|
||||
|----------|-----------|
|
||||
| `frontend/src/main-wizard.jsx` | **Fix P0:** rota `/admin` → `DomainAdmin` (build wizard-only) |
|
||||
| `frontend/src/clientSettings.js` | Normaliza `smtp_note` legado (remove IP partilhado / Ibytera em cache) |
|
||||
| `frontend/src/FinishToolbar.jsx` | CTAs passo Concluído: webmail, portal, finalizar |
|
||||
| `backend/routers/onboarding.py` | `smtp_note` canónico; strings Ibytera → Ligbox |
|
||||
|
||||
## Aplicar na VM112
|
||||
|
||||
```bash
|
||||
cd /opt/ligbox-wizard
|
||||
cp deploy/vm112-wizard/frontend/src/main-wizard.jsx frontend/src/
|
||||
cp deploy/vm112-wizard/frontend/src/clientSettings.js frontend/src/
|
||||
# ... demais ficheiros conforme diff
|
||||
cd frontend && npm run build:wizard
|
||||
systemctl restart ligbox-wizard
|
||||
```
|
||||
|
||||
## Spec / KB
|
||||
|
||||
- Spec 025 — secção «Passo Concluído — CTAs»
|
||||
- KB: `docs/anais-referencia/20260619_WIZARD_PORTAL_ADMIN_ROUTE_FIX.md`
|
||||
- Funcional: `docs/vms/vm112/SPEC_FUNCIONAL_VM112_LIGBOX_ONBOARD.md` §12.3
|
||||
|
||||
**Roger · VM130 spec-hub · 2026-06-19**
|
||||
|
|
@ -0,0 +1,777 @@
|
|||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
from app.config import cloudflare_token_path, settings
|
||||
from app.deps import bind_onboarding_session, get_session_from_request
|
||||
from app.services import activity_log, carbonio, dns_verify, domain_registry, notifications, onboard_handoff
|
||||
from app.services import infrastructure, ops_webhook, support_tickets
|
||||
from app.services.cloudflare import CloudflareDNS, CloudflareError, is_cloudflare_sandbox, mail_dns_records, wizard_nameservers
|
||||
from app.services.domain_format import cloudflare_error_http_detail, invalid_domain_http_detail, normalize_domain, validate_primary_domain
|
||||
from app.services.mail_aliases import sanitize_mail_aliases
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/onboarding",
|
||||
tags=["onboarding"],
|
||||
dependencies=[Depends(bind_onboarding_session)],
|
||||
)
|
||||
|
||||
|
||||
def _cloudflare_token_missing_error() -> str:
|
||||
return (
|
||||
"Token Cloudflare não configurado. Na VM112: "
|
||||
f"{cloudflare_token_path()} (ver secrets/README.txt)"
|
||||
)
|
||||
|
||||
|
||||
class DomainRequest(BaseModel):
|
||||
domain: str = Field(..., min_length=3, max_length=253)
|
||||
|
||||
|
||||
class ValidateDomainRequest(BaseModel):
|
||||
domain: str = Field(..., min_length=3, max_length=253)
|
||||
mail_aliases: list[str] = Field(default_factory=list, max_length=5)
|
||||
|
||||
|
||||
class CreateAccountRequest(BaseModel):
|
||||
domain: str
|
||||
local_part: str = "admin"
|
||||
password: str | None = Field(None, min_length=8)
|
||||
use_server_password: bool = False
|
||||
display_name: str | None = None
|
||||
notify_email: EmailStr | None = None
|
||||
send_welcome: bool = True
|
||||
dns_mode: str | None = None
|
||||
mail_aliases: list[str] = Field(default_factory=list, max_length=5)
|
||||
|
||||
|
||||
class CloudflareApplyRequest(BaseModel):
|
||||
domain: str
|
||||
zone_id: str | None = None
|
||||
use_cloudflare: bool = True
|
||||
mail_aliases: list[str] = Field(default_factory=list, max_length=5)
|
||||
|
||||
|
||||
class SupportTicketRequest(BaseModel):
|
||||
domain: str = Field(..., min_length=3, max_length=253)
|
||||
account_email: str | None = None
|
||||
client_note: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
def _client_settings(domain: str, email: str) -> dict:
|
||||
mail = f"mail.{domain}"
|
||||
return {
|
||||
"imap": {"host": mail, "port": 993, "security": "SSL/TLS"},
|
||||
"smtp": {"host": mail, "port": 465, "security": "SSL/TLS"},
|
||||
"pop3": {"host": mail, "port": 995, "security": "SSL/TLS"},
|
||||
"webmail": f"https://{mail}/",
|
||||
"email": email,
|
||||
"smtp_note": "Use a porta 465 com SSL/TLS para envio. Não use a porta 587.",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health():
|
||||
return {"status": "ok", "service": "ibytera-mail-portal"}
|
||||
|
||||
|
||||
@router.post("/session/reset")
|
||||
def reset_session(
|
||||
request: Request,
|
||||
session_id: str = Depends(bind_onboarding_session),
|
||||
domain: str | None = None,
|
||||
):
|
||||
"""Limpa logs da sessão actual e, opcionalmente, histórico unificado do domínio."""
|
||||
get_session_from_request(request)
|
||||
cleared_sessions: list[str] = []
|
||||
if session_id:
|
||||
activity_log.clear(session_id)
|
||||
cleared_sessions.append(session_id)
|
||||
domain_q = (domain or "").strip().lower()
|
||||
if domain_q and len(domain_q) >= 3:
|
||||
cleared_sessions.extend(activity_log.clear_for_domain(domain_q))
|
||||
cleared_sessions = sorted(set(cleared_sessions))
|
||||
activity_log.info("Sessão reiniciada — cache de logs limpo", source="portal")
|
||||
return {
|
||||
"ok": True,
|
||||
"cleared_sessions": cleared_sessions,
|
||||
"message": "Cache da sessão limpo. Recarregue a página.",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/support/ticket")
|
||||
def open_support_ticket(
|
||||
body: SupportTicketRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
session_id: str = Depends(bind_onboarding_session),
|
||||
):
|
||||
"""Abre chamado de suporte (webmail/infra pendente) com link da sessão completa."""
|
||||
domain = body.domain.lower().strip()
|
||||
aliases = domain_registry.get_domain_record(domain)
|
||||
mail_aliases = (aliases or {}).get("mail_aliases") or []
|
||||
infra_status = infrastructure.get_status(domain, mail_aliases)
|
||||
|
||||
layman = support_tickets.build_layman_explanation(domain, body.account_email, infra_status)
|
||||
ticket, created = support_tickets.find_or_create_ticket(
|
||||
domain=domain,
|
||||
session_id=session_id or "",
|
||||
account_email=body.account_email,
|
||||
infra_status=infra_status,
|
||||
client_note=body.client_note,
|
||||
)
|
||||
|
||||
pending = [s.get("label", s.get("id")) for s in infra_status.get("steps", []) if not s.get("ok")]
|
||||
try:
|
||||
notifications.notify_support_ticket_opened(
|
||||
ticket_id=ticket["ticket_id"],
|
||||
domain=domain,
|
||||
account_email=body.account_email,
|
||||
support_url=ticket["support_url"],
|
||||
pending_summary="; ".join(pending) or "A verificar",
|
||||
session_id=session_id or "",
|
||||
client_note=body.client_note,
|
||||
)
|
||||
except Exception as exc:
|
||||
activity_log.warn(f"Notificação de chamado falhou (ticket criado): {exc}", source="portal")
|
||||
|
||||
if created:
|
||||
background_tasks.add_task(
|
||||
ops_webhook.emit_event,
|
||||
"onboarding.escalated",
|
||||
domain=domain,
|
||||
session_id=session_id or "",
|
||||
data={
|
||||
"reason": "client_support_request",
|
||||
"wizard_ticket_id": ticket["ticket_id"],
|
||||
"client_note": body.client_note,
|
||||
"source": "wizard_support_modal",
|
||||
},
|
||||
)
|
||||
|
||||
if created:
|
||||
activity_log.info(
|
||||
f"Cliente abriu chamado {ticket['ticket_id']} — suporte notificado",
|
||||
source="portal",
|
||||
)
|
||||
else:
|
||||
activity_log.info(
|
||||
f"Cliente reutilizou chamado {ticket['ticket_id']} — sem duplicar",
|
||||
source="portal",
|
||||
)
|
||||
|
||||
msg = (
|
||||
"Chamado registrado. Nossa equipe (assistente + suporte humano) recebeu o histórico desta sessão."
|
||||
if created
|
||||
else f"Já existe um chamado aberto ({ticket['ticket_id']}). Acompanhe o andamento abaixo."
|
||||
)
|
||||
|
||||
return {
|
||||
"ticket_id": ticket["ticket_id"],
|
||||
"support_url": ticket["support_url"],
|
||||
"tracking_url": ticket.get("tracking_url") or ticket["support_url"],
|
||||
"created": created,
|
||||
"layman": layman,
|
||||
"message": msg,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/support/ticket/by-session")
|
||||
def get_open_ticket_for_session(session_id: str = Depends(bind_onboarding_session)):
|
||||
"""Chamado OB- aberto para banner no wizard."""
|
||||
ticket = support_tickets.find_open_ticket_for_session(session_id or "")
|
||||
if not ticket:
|
||||
return {"ticket": None}
|
||||
return {
|
||||
"ticket": {
|
||||
"ticket_id": ticket.get("ticket_id"),
|
||||
"tracking_url": ticket.get("tracking_url") or ticket.get("support_url"),
|
||||
"status": ticket.get("status"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/support/ticket/{ticket_id}")
|
||||
def get_support_ticket(ticket_id: str):
|
||||
ticket = support_tickets.get_ticket(ticket_id)
|
||||
if not ticket:
|
||||
raise HTTPException(404, "Chamado não encontrado.")
|
||||
return ticket
|
||||
|
||||
|
||||
class SupportTicketNoteRequest(BaseModel):
|
||||
session_id: str = Field(..., min_length=8)
|
||||
message: str = Field(..., min_length=1, max_length=2000)
|
||||
|
||||
|
||||
@router.get("/support/ticket/{ticket_id}/public")
|
||||
def get_support_ticket_public(
|
||||
ticket_id: str,
|
||||
session_id: str = Query(..., min_length=8),
|
||||
):
|
||||
"""Portal cliente Spec 026 — requer par ticket + session."""
|
||||
from app.services import assist_session, infrastructure
|
||||
from app.services.domain_registry import get_domain_record
|
||||
|
||||
ticket = support_tickets.get_ticket(ticket_id.strip().upper())
|
||||
if not ticket:
|
||||
raise HTTPException(404, detail={"message": "Chamado não encontrado."})
|
||||
if (ticket.get("session_id") or "").strip() != session_id.strip():
|
||||
raise HTTPException(403, detail={"message": "Sessão não corresponde a este chamado."})
|
||||
|
||||
domain = ticket.get("domain") or ""
|
||||
rec = get_domain_record(domain) if domain else None
|
||||
aliases = (rec or {}).get("mail_aliases") or []
|
||||
infra_status = infrastructure.get_status(domain, aliases) if domain else {}
|
||||
ticket["infra_status"] = infra_status
|
||||
assist_state = assist_session.public_state(session_id.strip())
|
||||
return support_tickets.build_public_view(ticket, infra_status=infra_status, assist_state=assist_state)
|
||||
|
||||
|
||||
@router.post("/support/ticket/{ticket_id}/note")
|
||||
def add_support_ticket_note(
|
||||
ticket_id: str,
|
||||
body: SupportTicketNoteRequest,
|
||||
session_id: str = Depends(bind_onboarding_session),
|
||||
):
|
||||
sid = (body.session_id or session_id or "").strip()
|
||||
updated = support_tickets.add_client_note(ticket_id.strip().upper(), sid, body.message)
|
||||
if not updated:
|
||||
raise HTTPException(404, detail={"message": "Chamado não encontrado ou sessão inválida."})
|
||||
from app.services import assist_session, infrastructure
|
||||
from app.services.domain_registry import get_domain_record
|
||||
|
||||
domain = updated.get("domain") or ""
|
||||
rec = get_domain_record(domain) if domain else None
|
||||
aliases = (rec or {}).get("mail_aliases") or []
|
||||
infra_status = infrastructure.get_status(domain, aliases) if domain else {}
|
||||
assist_state = assist_session.public_state(sid)
|
||||
return support_tickets.build_public_view(updated, infra_status=infra_status, assist_state=assist_state)
|
||||
|
||||
|
||||
@router.get("/activity-log")
|
||||
def get_activity_log(
|
||||
session_id: str = Depends(bind_onboarding_session),
|
||||
domain: str | None = None,
|
||||
):
|
||||
domain_q = (domain or "").strip().lower()
|
||||
if domain_q and len(domain_q) >= 3:
|
||||
entries, sessions_merged = activity_log.get_entries_for_domain(domain_q)
|
||||
return {
|
||||
"session_id": session_id or None,
|
||||
"domain": domain_q,
|
||||
"sessions_merged": sessions_merged,
|
||||
"entries": entries,
|
||||
}
|
||||
if not session_id:
|
||||
return {"entries": [], "session_id": None}
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"entries": activity_log.get_entries(session_id),
|
||||
"sessions_merged": [session_id],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/infrastructure/status/{domain}")
|
||||
def infrastructure_status(domain: str):
|
||||
return infrastructure.get_status(domain.lower().strip())
|
||||
|
||||
|
||||
@router.post("/infrastructure/provision")
|
||||
def infrastructure_provision(
|
||||
body: DomainRequest,
|
||||
request: Request,
|
||||
step: str | None = Query(None),
|
||||
):
|
||||
get_session_from_request(request)
|
||||
domain = body.domain.lower().strip()
|
||||
activity_log.info(f"Provisionar infra: {domain}", source="traefik")
|
||||
try:
|
||||
return infrastructure.provision(domain, step_id=step)
|
||||
except Exception as e:
|
||||
activity_log.error(str(e), source="traefik")
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
@router.post("/validate-domain")
|
||||
def validate_domain(body: ValidateDomainRequest, request: Request):
|
||||
get_session_from_request(request)
|
||||
domain = normalize_domain(body.domain)
|
||||
try:
|
||||
validate_primary_domain(domain)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=invalid_domain_http_detail()) from e
|
||||
aliases = sanitize_mail_aliases(body.mail_aliases, domain)
|
||||
if body.mail_aliases:
|
||||
domain_registry.save_mail_aliases(domain, aliases)
|
||||
activity_log.info(
|
||||
f"Alias registados: {', '.join(aliases) if aliases else 'nenhum'}",
|
||||
source="portal",
|
||||
)
|
||||
activity_log.info(f"Validar domínio: {domain}", source="portal")
|
||||
exists = carbonio.domain_exists(domain)
|
||||
if exists:
|
||||
activity_log.ok(f"Carbonio: domínio {domain} já existe", source="vm112")
|
||||
else:
|
||||
activity_log.info(
|
||||
f"Carbonio: domínio {domain} ainda não existe (normal — será criado ao «Criar conta agora»)",
|
||||
source="vm112",
|
||||
)
|
||||
cf_auto = False
|
||||
if settings.cloudflare_api_token:
|
||||
if is_cloudflare_sandbox():
|
||||
activity_log.info(
|
||||
"Cloudflare sandbox: token de teste activo (sem chamadas à API real)",
|
||||
source="cloudflare",
|
||||
)
|
||||
cf_auto = True
|
||||
else:
|
||||
try:
|
||||
cf = CloudflareDNS()
|
||||
activity_log.info("Verificar zona Cloudflare…", source="cloudflare")
|
||||
cf_auto = cf.get_zone_by_name(domain) is not None
|
||||
activity_log.ok(
|
||||
f"Cloudflare: zona {'encontrada' if cf_auto else 'ainda não na conta Ligbox'}",
|
||||
source="cloudflare",
|
||||
)
|
||||
except CloudflareError as e:
|
||||
activity_log.warn(f"Cloudflare: {e}", source="cloudflare")
|
||||
else:
|
||||
activity_log.warn("Token Cloudflare não configurado", source="cloudflare")
|
||||
|
||||
return {
|
||||
"domain": domain,
|
||||
"mail_aliases": aliases,
|
||||
"exists_in_carbonio": exists,
|
||||
"carbonio_will_create_domain": not exists,
|
||||
"carbonio_provisioning": True,
|
||||
"cloudflare_auto_dns": cf_auto,
|
||||
"message": (
|
||||
f"O domínio {'já existe' if exists else 'será criado'} no Carbonio ao criar a conta. "
|
||||
f"{'Cloudflare: DNS automático OK.' if cf_auto else 'Cloudflare: configure DNS no passo seguinte.'}"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _cloudflare_zone_status(domain: str) -> dict:
|
||||
domain = domain.lower().strip()
|
||||
if not settings.cloudflare_api_token:
|
||||
return {
|
||||
"domain": domain,
|
||||
"cloudflare_configured": False,
|
||||
"zone_in_account": False,
|
||||
"can_apply_mail_records": False,
|
||||
"zone_status": None,
|
||||
"nameservers": [],
|
||||
"managed_zones": [],
|
||||
"portal_onboarding_required": True,
|
||||
"message": "API Cloudflare não configurada no servidor.",
|
||||
}
|
||||
cf = CloudflareDNS()
|
||||
cf.verify_token()
|
||||
managed = cf.list_zone_names()
|
||||
zone = cf.get_zone_by_name(domain)
|
||||
in_account = zone is not None
|
||||
zone_status = zone.get("status") if zone else None
|
||||
nameservers = wizard_nameservers(zone) if zone else []
|
||||
|
||||
return {
|
||||
"domain": domain,
|
||||
"cloudflare_configured": True,
|
||||
"zone_in_account": in_account,
|
||||
"can_apply_mail_records": in_account,
|
||||
"zone_id": zone["id"] if zone else None,
|
||||
"zone_status": zone_status,
|
||||
"nameservers": nameservers,
|
||||
"managed_zones": managed,
|
||||
"portal_onboarding_required": not in_account,
|
||||
"message": (
|
||||
f"Zona {domain} pronta na Cloudflare Ligbox ({zone_status})."
|
||||
if in_account
|
||||
else f"Adicione {domain} na Cloudflare Ligbox e altere os NS no registrador."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/dns/cloudflare/status/{domain}")
|
||||
def cloudflare_zone_status(domain: str):
|
||||
try:
|
||||
activity_log.info(f"Estado Cloudflare: {domain}", source="cloudflare")
|
||||
return _cloudflare_zone_status(domain.lower().strip())
|
||||
except CloudflareError as e:
|
||||
activity_log.error(str(e), source="cloudflare")
|
||||
raise HTTPException(502, f"Erro Cloudflare: {e}") from e
|
||||
|
||||
|
||||
def _portal_onboarding_payload(domain: str, zone_created: bool = False) -> dict:
|
||||
status = _cloudflare_zone_status(domain)
|
||||
nameservers = status.get("nameservers") or []
|
||||
in_account = status.get("zone_in_account")
|
||||
|
||||
if in_account:
|
||||
return {
|
||||
"domain": domain,
|
||||
"ready": True,
|
||||
"zone_created": zone_created,
|
||||
"steps": [
|
||||
{
|
||||
"order": 1,
|
||||
"title": "Domínio na Cloudflare Ligbox",
|
||||
"done": True,
|
||||
"detail": (
|
||||
f"Zona criada automaticamente. Estado: {status.get('zone_status', 'pending')}."
|
||||
if zone_created
|
||||
else f"Zona já existia ({status.get('zone_status', 'active')})."
|
||||
),
|
||||
},
|
||||
{
|
||||
"order": 2,
|
||||
"title": "Alterar nameservers no registrador",
|
||||
"done": status.get("zone_status") == "active",
|
||||
"detail": "Aponte o domínio para os nameservers Cloudflare listados abaixo.",
|
||||
},
|
||||
{
|
||||
"order": 3,
|
||||
"title": "Registos de email",
|
||||
"done": False,
|
||||
"detail": "Use «Criar apontamentos de email» para MX, A, SPF e DMARC.",
|
||||
},
|
||||
],
|
||||
"nameservers": nameservers,
|
||||
"status": status,
|
||||
}
|
||||
|
||||
return {
|
||||
"domain": domain,
|
||||
"ready": False,
|
||||
"zone_created": False,
|
||||
"steps": [
|
||||
{
|
||||
"order": 1,
|
||||
"title": "Criar zona na Cloudflare",
|
||||
"done": False,
|
||||
"detail": "Não foi possível criar a zona. Verifique o API token.",
|
||||
},
|
||||
],
|
||||
"nameservers": [],
|
||||
"status": status,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/dns/cloudflare/provision-zone")
|
||||
def provision_cloudflare_zone(body: DomainRequest, request: Request):
|
||||
get_session_from_request(request)
|
||||
domain = normalize_domain(body.domain)
|
||||
try:
|
||||
validate_primary_domain(domain)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=invalid_domain_http_detail()) from e
|
||||
if not settings.cloudflare_api_token:
|
||||
raise HTTPException(400, _cloudflare_token_missing_error())
|
||||
|
||||
cf = CloudflareDNS()
|
||||
try:
|
||||
activity_log.info(f"Criar zona Cloudflare: {domain}", source="cloudflare")
|
||||
cf.verify_token()
|
||||
result = cf.ensure_zone(domain)
|
||||
zone = result["zone"]
|
||||
created = result["created"]
|
||||
sandbox = is_cloudflare_sandbox()
|
||||
if sandbox:
|
||||
activity_log.info(
|
||||
"Cloudflare sandbox: zona simulada (token de teste — não cria zona real)",
|
||||
source="cloudflare",
|
||||
)
|
||||
activity_log.ok(
|
||||
f"Zona {domain} {'criada' if created else 'já existia'} (id {zone.get('id', '?')})",
|
||||
source="cloudflare",
|
||||
)
|
||||
payload = _portal_onboarding_payload(domain, zone_created=created)
|
||||
payload["zone_id"] = zone.get("id")
|
||||
payload["sandbox"] = sandbox
|
||||
payload["message"] = (
|
||||
f"Domínio {domain} {'criado' if created else 'já existia'} na Cloudflare Ligbox. "
|
||||
+ (
|
||||
"Ambiente de teste: apontamentos simulados automaticamente."
|
||||
if sandbox
|
||||
else "Altere os nameservers no registrador."
|
||||
)
|
||||
)
|
||||
if nameservers := wizard_nameservers(zone):
|
||||
payload["nameservers"] = nameservers
|
||||
payload["status"]["nameservers"] = nameservers
|
||||
if sandbox:
|
||||
zone_id = zone.get("id")
|
||||
applied = []
|
||||
for rec in mail_dns_records(domain, settings.mail_public_ip, []):
|
||||
upsert = cf.upsert_record(
|
||||
zone_id,
|
||||
rec["type"],
|
||||
rec["name"],
|
||||
rec["content"],
|
||||
priority=rec.get("priority"),
|
||||
proxied=rec.get("proxied", False),
|
||||
zone_name=domain,
|
||||
)
|
||||
applied.append({"type": rec["type"], "name": rec["name"], "id": upsert.get("id")})
|
||||
verification = dns_verify.verify_mail_dns(domain, settings.mail_public_ip)
|
||||
payload["applied"] = applied
|
||||
payload["verification"] = verification
|
||||
for step in payload.get("steps") or []:
|
||||
if step.get("order") == 3:
|
||||
step["done"] = True
|
||||
step["detail"] = "Apontamentos simulados (sandbox)."
|
||||
activity_log.ok("Cloudflare sandbox: apontamentos de email simulados", source="cloudflare")
|
||||
return payload
|
||||
except CloudflareError as e:
|
||||
activity_log.error(f"Provision zona falhou: {e}", source="cloudflare")
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=cloudflare_error_http_detail(e, domain),
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/dns/portal-onboarding/{domain}")
|
||||
def portal_dns_onboarding(domain: str):
|
||||
domain = domain.lower().strip()
|
||||
try:
|
||||
return _portal_onboarding_payload(domain, zone_created=False)
|
||||
except CloudflareError as e:
|
||||
activity_log.error(str(e), source="cloudflare")
|
||||
raise HTTPException(502, f"Erro Cloudflare: {e}") from e
|
||||
|
||||
|
||||
@router.get("/dns/instructions/{domain}")
|
||||
def dns_instructions(domain: str):
|
||||
domain = domain.lower().strip()
|
||||
mail_host = f"mail.{domain}"
|
||||
ip = settings.mail_public_ip
|
||||
activity_log.info(f"Instruções DNS para {domain}", source="portal")
|
||||
cf_status = None
|
||||
try:
|
||||
cf_status = _cloudflare_zone_status(domain)
|
||||
except CloudflareError:
|
||||
pass
|
||||
return {
|
||||
"domain": domain,
|
||||
"mail_public_ip": ip,
|
||||
"cloudflare": cf_status,
|
||||
"dns_paths": {
|
||||
"portal": "Trazer DNS para o portal (Cloudflare conta Ligbox)",
|
||||
"external": "Manter DNS no provedor atual (apontamentos manuais)",
|
||||
},
|
||||
"records": [
|
||||
{"type": "A", "name": mail_host, "value": ip, "note": "DNS only (sem proxy Cloudflare)"},
|
||||
{"type": "MX", "name": domain, "value": mail_host, "priority": 10},
|
||||
{"type": "TXT", "name": domain, "value": f"v=spf1 mx a:{mail_host} ip4:{ip} -all"},
|
||||
{
|
||||
"type": "TXT",
|
||||
"name": f"_dmarc.{domain}",
|
||||
"value": f"v=DMARC1; p=quarantine; rua=mailto:postmaster@{domain}",
|
||||
},
|
||||
{
|
||||
"type": "TXT",
|
||||
"name": f"default._domainkey.{domain}",
|
||||
"value": "(DKIM — Carbonio Admin após criar domínio)",
|
||||
},
|
||||
],
|
||||
"cloudflare_hint": "Escolha no passo DNS: trazer para o portal ou manter no provedor atual.",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/dns/verify/{domain}")
|
||||
def verify_dns(domain: str):
|
||||
domain = domain.lower().strip()
|
||||
activity_log.info(f"Verificar DNS público: {domain}", source="portal")
|
||||
result = dns_verify.verify_mail_dns(domain, settings.mail_public_ip)
|
||||
if result.get("ready"):
|
||||
activity_log.ok("DNS público: MX e A mail OK", source="portal")
|
||||
else:
|
||||
activity_log.warn("DNS público: ainda incompleto (MX ou A mail)", source="portal")
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/dns/cloudflare/apply")
|
||||
def apply_cloudflare_dns(body: CloudflareApplyRequest, request: Request):
|
||||
get_session_from_request(request)
|
||||
domain = body.domain.lower().strip()
|
||||
if not settings.cloudflare_api_token:
|
||||
raise HTTPException(400, _cloudflare_token_missing_error())
|
||||
|
||||
cf = CloudflareDNS()
|
||||
try:
|
||||
activity_log.info(f"Aplicar registos mail na Cloudflare: {domain}", source="cloudflare")
|
||||
cf.verify_token()
|
||||
except CloudflareError as e:
|
||||
activity_log.error(str(e), source="cloudflare")
|
||||
raise HTTPException(401, f"Token Cloudflare inválido: {e}") from e
|
||||
|
||||
zone_id = body.zone_id
|
||||
if not zone_id:
|
||||
zone = cf.get_zone_by_name(domain)
|
||||
if not zone:
|
||||
activity_log.error(f"Zona {domain} não está na conta Ligbox", source="cloudflare")
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={
|
||||
"code": "zone_not_in_account",
|
||||
"domain": domain,
|
||||
"message": f"Zona {domain} ainda não está na Cloudflare Ligbox.",
|
||||
"use_portal_onboarding": True,
|
||||
},
|
||||
)
|
||||
zone_id = zone["id"]
|
||||
|
||||
rec = domain_registry.get_domain_record(domain)
|
||||
aliases = sanitize_mail_aliases(body.mail_aliases, domain) if body.mail_aliases else []
|
||||
if not aliases and rec:
|
||||
aliases = list(rec.get("mail_aliases") or [])
|
||||
|
||||
applied = []
|
||||
for rec in mail_dns_records(domain, settings.mail_public_ip, aliases):
|
||||
result = cf.upsert_record(
|
||||
zone_id,
|
||||
rec["type"],
|
||||
rec["name"],
|
||||
rec["content"],
|
||||
priority=rec.get("priority"),
|
||||
proxied=rec.get("proxied", False),
|
||||
zone_name=domain,
|
||||
)
|
||||
applied.append({"type": rec["type"], "name": rec["name"], "id": result.get("id")})
|
||||
activity_log.ok(f"DNS {rec['type']} {rec['name']} → {rec['content']}", source="cloudflare")
|
||||
|
||||
verification = dns_verify.verify_mail_dns(domain, settings.mail_public_ip)
|
||||
return {"domain": domain, "zone_id": zone_id, "applied": applied, "verification": verification}
|
||||
|
||||
|
||||
@router.get("/session/password-status")
|
||||
def session_password_status(request: Request):
|
||||
"""Spec 016b — indica se a senha do cadastro está no vault servidor (sem expor valor)."""
|
||||
sid = get_session_from_request(request)
|
||||
if not sid:
|
||||
return {"ready": False, "source": None}
|
||||
ready = onboard_handoff.session_has_password(sid)
|
||||
return {"ready": ready, "source": "portal_handoff" if ready else None}
|
||||
|
||||
|
||||
@router.delete("/session/password")
|
||||
def clear_session_password(request: Request):
|
||||
"""Remove senha do vault (ex.: utilizador escolhe senha diferente no wizard)."""
|
||||
sid = get_session_from_request(request)
|
||||
if sid:
|
||||
onboard_handoff.clear_session_password(sid)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/account/create")
|
||||
def create_account(body: CreateAccountRequest, request: Request):
|
||||
sid = get_session_from_request(request)
|
||||
domain = body.domain.lower().strip()
|
||||
email = f"{body.local_part}@{domain}"
|
||||
webmail = f"https://mail.{domain}/"
|
||||
dns_mode = body.dns_mode or "não indicado"
|
||||
aliases = sanitize_mail_aliases(body.mail_aliases, domain)
|
||||
if aliases:
|
||||
domain_registry.save_mail_aliases(domain, aliases)
|
||||
|
||||
if body.use_server_password:
|
||||
password = onboard_handoff.pop_password_for_session(sid) if sid else None
|
||||
if not password:
|
||||
raise HTTPException(
|
||||
400,
|
||||
"Senha do cadastro expirou ou não está disponível. Defina a senha manualmente no passo Conta admin.",
|
||||
)
|
||||
else:
|
||||
password = (body.password or "").strip()
|
||||
if len(password) < 8:
|
||||
raise HTTPException(400, "A senha precisa de pelo menos 8 caracteres.")
|
||||
if sid:
|
||||
onboard_handoff.clear_session_password(sid)
|
||||
|
||||
activity_log.info(f"Iniciar criação de conta: {email}", source="portal")
|
||||
|
||||
try:
|
||||
out, account_reused = carbonio.ensure_onboarding_account(
|
||||
email, password, body.display_name
|
||||
)
|
||||
if account_reused:
|
||||
activity_log.info(
|
||||
f"Conta reutilizada (retry wizard): {email} — senha actualizada",
|
||||
source="vm112",
|
||||
)
|
||||
else:
|
||||
activity_log.ok(f"zmprov ca concluído: {out or 'OK'}", source="vm112")
|
||||
except carbonio.CarbonioError as e:
|
||||
activity_log.error(f"Criação falhou: {e}", source="vm112")
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
verified = carbonio.account_exists(email)
|
||||
if verified:
|
||||
activity_log.ok(f"Verificação: conta {email} existe no Carbonio", source="vm112")
|
||||
domain_registry.register_portal_admin(domain, email, mail_aliases=aliases)
|
||||
activity_log.ok(f"Painel admin: {email} autorizado para {domain}", source="portal")
|
||||
if aliases:
|
||||
carbonio.add_mail_alias_hostnames(domain, aliases)
|
||||
activity_log.ok(f"Alias Carbonio: {len(aliases)} hostname(s)", source="vm112")
|
||||
else:
|
||||
activity_log.error(
|
||||
f"ALERTA: zmprov ca executou mas conta {email} NÃO encontrada (ga)",
|
||||
source="vm112",
|
||||
)
|
||||
|
||||
verification = None
|
||||
try:
|
||||
verification = dns_verify.verify_mail_dns(domain, settings.mail_public_ip)
|
||||
except Exception as e:
|
||||
activity_log.warn(f"Verificação DNS: {e}", source="portal")
|
||||
|
||||
notifications_sent = {
|
||||
"admin": notifications.notify_admin_onboarding(
|
||||
domain,
|
||||
email,
|
||||
dns_mode,
|
||||
verified,
|
||||
verification.get("ready") if verification else None,
|
||||
),
|
||||
"client": False,
|
||||
"welcome": False,
|
||||
}
|
||||
|
||||
if body.notify_email:
|
||||
notifications_sent["client"] = notifications.notify_client_contact(
|
||||
str(body.notify_email),
|
||||
domain,
|
||||
email,
|
||||
webmail,
|
||||
verified,
|
||||
)
|
||||
|
||||
if body.send_welcome and verified:
|
||||
notifications_sent["welcome"] = notifications.send_welcome_to_mailbox(
|
||||
email,
|
||||
domain,
|
||||
webmail,
|
||||
body.display_name,
|
||||
)
|
||||
|
||||
activity_log.ok("Processo de onboarding finalizado", source="portal")
|
||||
|
||||
infra_status = None
|
||||
try:
|
||||
infra_status = infrastructure.provision(domain, mail_aliases=aliases)
|
||||
activity_log.ok("Provisionamento infra (Traefik/cert/SNI) concluído", source="traefik")
|
||||
except Exception as e:
|
||||
activity_log.warn(f"Infra automática: {e} — use a coluna Infraestrutura", source="traefik")
|
||||
|
||||
return {
|
||||
"email": email,
|
||||
"message": out,
|
||||
"account_reused": account_reused,
|
||||
"webmail": webmail,
|
||||
"account_verified": verified,
|
||||
"notifications_sent": notifications_sent,
|
||||
"client_settings": _client_settings(domain, email),
|
||||
"dns_verification": verification,
|
||||
"needs_review": not verified,
|
||||
"mail_aliases": aliases,
|
||||
"infrastructure": infra_status.get("status") if infra_status else infrastructure.get_status(domain, aliases),
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
export default function FinishToolbar({ webmailUrl, adminUrl, pending, onFinish }) {
|
||||
return (
|
||||
<div className="wcl-finish-actions">
|
||||
{pending && (
|
||||
<p className="wcl-finish-actions__hint" role="status">
|
||||
Ativando webmail em segundo plano…
|
||||
</p>
|
||||
)}
|
||||
<div className="wcl-finish-actions__row">
|
||||
<a
|
||||
href={webmailUrl}
|
||||
className="wcl-finish-chip wcl-finish-chip--primary"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Abrir webmail
|
||||
</a>
|
||||
<a
|
||||
href={adminUrl}
|
||||
className="wcl-finish-chip"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Portal de gerenciamento
|
||||
</a>
|
||||
<button type="button" className="wcl-finish-chip" onClick={onFinish}>
|
||||
Finalizar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
/** Texto canónico — não depender de sessões antigas em cache. */
|
||||
export const CLIENT_SMTP_NOTE =
|
||||
'Use a porta 465 com SSL/TLS para envio. Não use a porta 587.'
|
||||
|
||||
export function normalizeClientSettings(raw) {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const note = String(raw.smtp_note || '')
|
||||
const legacy =
|
||||
/partilhad/i.test(note) || /ibytera/i.test(note) || /587.*domínio/i.test(note)
|
||||
return legacy ? { ...raw, smtp_note: CLIENT_SMTP_NOTE } : raw
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import React from "react"
|
||||
import ReactDOM from "react-dom/client"
|
||||
import App from "./App"
|
||||
import ClientTicketPortal from "./ClientTicketPortal.jsx"
|
||||
import DomainAdmin from "./DomainAdmin.jsx"
|
||||
import ForgotDomainPassword from "./ForgotDomainPassword.jsx"
|
||||
import "./styles.css"
|
||||
import "./wizard-setup.css"
|
||||
import "./assist-asm.css"
|
||||
import "./client-ticket-portal.css"
|
||||
import { initTelemetry } from "./lib/telemetry"
|
||||
|
||||
initTelemetry()
|
||||
|
||||
function parseAssistPath() {
|
||||
const m = window.location.pathname.replace(/\/$/, "").match(/^\/assist\/([^/]+)/)
|
||||
return m ? m[1] : null
|
||||
}
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const ticketFromUrl = urlParams.get('ticket')?.trim()?.toUpperCase()
|
||||
const sessionFromUrl = urlParams.get('session')?.trim()
|
||||
const assistSessionId = parseAssistPath()
|
||||
const assistTakeoverToken = new URLSearchParams(window.location.search).get("token")
|
||||
|
||||
const rootEl = document.getElementById('root')
|
||||
const path = window.location.pathname.replace(/\/$/, '') || '/'
|
||||
|
||||
if (path === '/admin/forgot-password') {
|
||||
ReactDOM.createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<ForgotDomainPassword />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
} else if (path === '/admin' || path.startsWith('/admin/')) {
|
||||
ReactDOM.createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<DomainAdmin />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
} else if (ticketFromUrl && !assistSessionId) {
|
||||
ReactDOM.createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<ClientTicketPortal ticketId={ticketFromUrl} sessionId={sessionFromUrl || ''} />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
} else {
|
||||
ReactDOM.createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<App
|
||||
assistSessionId={assistSessionId}
|
||||
asmMode={Boolean(assistSessionId)}
|
||||
assistTakeoverToken={assistTakeoverToken}
|
||||
/>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
}
|
||||
21
ligbox-ops-platform/deploy/vm122-desk/README.md
Normal file
21
ligbox-ops-platform/deploy/vm122-desk/README.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Deploy mirror — Desk VM122 (2026-06-19)
|
||||
|
||||
Espelho parcial dos ficheiros alterados em produção na **VM122** (`/opt/ligbox-ops-platform`).
|
||||
|
||||
## Alterações desta entrega
|
||||
|
||||
| Ficheiro | Alteração |
|
||||
|----------|-----------|
|
||||
| `frontend/assets/app.js` | Card tenant: **Ligbox Datacenter — Node VM001** |
|
||||
| `api/app/audit_store.py` | Sync `audit_domains` alargado (eventos onboarding recentes) |
|
||||
| `api/app/main.py` | Upsert nome tenant no bootstrap; seed VM001 |
|
||||
|
||||
## Aplicar na VM122
|
||||
|
||||
```bash
|
||||
cd /opt/ligbox-ops-platform
|
||||
docker-compose -f docker-compose.mvp.yml build api frontend
|
||||
docker-compose -f docker-compose.mvp.yml up -d api frontend
|
||||
```
|
||||
|
||||
**Roger · VM130 spec-hub · 2026-06-19**
|
||||
518
ligbox-ops-platform/deploy/vm122-desk/api/app/audit_store.py
Normal file
518
ligbox-ops-platform/deploy/vm122-desk/api/app/audit_store.py
Normal file
|
|
@ -0,0 +1,518 @@
|
|||
"""SQLite persistence for audit domains and checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from app.collectors.base import CHECK_LABELS
|
||||
|
||||
ONBOARD_DOMAIN_EVENTS = frozenset({
|
||||
"onboarding.started",
|
||||
"domain.validated",
|
||||
"dns.applied",
|
||||
"account.created",
|
||||
"infra.synced",
|
||||
"onboarding.completed",
|
||||
"onboarding.escalated",
|
||||
"onboarding.failed",
|
||||
"webmail.released",
|
||||
})
|
||||
TENANT_ONBOARD = 1
|
||||
|
||||
TENANT_WEBHOOK_SOURCE = {
|
||||
1: "vm112-onboard",
|
||||
2: "wazuh",
|
||||
}
|
||||
|
||||
FUNNEL_EVENT_RANK = {
|
||||
"onboarding.started": 1,
|
||||
"domain.validated": 2,
|
||||
"dns.applied": 3,
|
||||
"account.created": 4,
|
||||
"infra.synced": 5,
|
||||
"onboarding.completed": 6,
|
||||
"company.validated": 7,
|
||||
"webmail.released": 8,
|
||||
"onboarding.failed": 99,
|
||||
}
|
||||
|
||||
FUNNEL_STAGE_BY_RANK = {
|
||||
1: "started",
|
||||
2: "domain_validated",
|
||||
3: "dns_applied",
|
||||
4: "account_created",
|
||||
5: "infra_synced",
|
||||
6: "completed",
|
||||
7: "company_validated",
|
||||
8: "webmail_released",
|
||||
99: "failed",
|
||||
}
|
||||
|
||||
FUNNEL_STAGE_LABELS = {
|
||||
"started": "Iniciado",
|
||||
"domain_validated": "Domínio OK",
|
||||
"dns_applied": "DNS aplicado",
|
||||
"account_created": "Conta criada",
|
||||
"infra_synced": "Infra sync",
|
||||
"completed": "Concluído",
|
||||
"company_validated": "Empresa validada",
|
||||
"webmail_released": "Webmail liberado",
|
||||
"failed": "Falhou",
|
||||
"registered": "Registado",
|
||||
"unknown": "Sem dados",
|
||||
}
|
||||
|
||||
STATUS_RANK = {"pass": 0, "skip": 1, "warn": 2, "error": 3, "fail": 4}
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _parse_payload(raw: str | None) -> dict:
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
|
||||
def init_audit_schema(conn: sqlite3.Connection) -> None:
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS audit_domains (
|
||||
id INTEGER PRIMARY KEY,
|
||||
tenant_id INTEGER NOT NULL,
|
||||
domain TEXT NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'onboarding',
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(tenant_id, domain)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS audit_checks (
|
||||
id INTEGER PRIMARY KEY,
|
||||
tenant_id INTEGER NOT NULL,
|
||||
domain TEXT NOT NULL,
|
||||
check_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
message TEXT,
|
||||
evidence TEXT,
|
||||
checked_at TEXT NOT NULL,
|
||||
UNIQUE(tenant_id, domain, check_id)
|
||||
);
|
||||
""")
|
||||
|
||||
|
||||
def sync_domains_from_webhooks(conn: sqlite3.Connection) -> int:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT event_type, payload FROM webhook_events
|
||||
WHERE source = 'vm112-onboard'
|
||||
ORDER BY id DESC LIMIT 500
|
||||
"""
|
||||
).fetchall()
|
||||
added = 0
|
||||
now = _now()
|
||||
seen: set[tuple[int, str]] = set()
|
||||
for row in rows:
|
||||
if row["event_type"] not in ONBOARD_DOMAIN_EVENTS:
|
||||
continue
|
||||
payload = _parse_payload(row["payload"])
|
||||
domain = (payload.get("domain") or "").strip().lower()
|
||||
if not domain or len(domain) < 3:
|
||||
continue
|
||||
key = (TENANT_ONBOARD, domain)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO audit_domains (tenant_id, domain, source, created_at)
|
||||
VALUES (?, ?, 'onboarding', ?)
|
||||
""",
|
||||
(TENANT_ONBOARD, domain, now),
|
||||
)
|
||||
if cur.rowcount:
|
||||
added += 1
|
||||
conn.commit()
|
||||
return added
|
||||
|
||||
|
||||
def list_audit_domains(conn: sqlite3.Connection, tenant_id: int | None = None) -> list[dict]:
|
||||
if tenant_id:
|
||||
rows = conn.execute(
|
||||
"SELECT tenant_id, domain, source, created_at FROM audit_domains WHERE tenant_id = ? ORDER BY domain",
|
||||
(tenant_id,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT tenant_id, domain, source, created_at FROM audit_domains ORDER BY tenant_id, domain"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def upsert_check(
|
||||
conn: sqlite3.Connection,
|
||||
tenant_id: int,
|
||||
domain: str,
|
||||
check_id: str,
|
||||
status: str,
|
||||
message: str,
|
||||
evidence: dict | None,
|
||||
checked_at: str | None = None,
|
||||
) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO audit_checks (tenant_id, domain, check_id, status, message, evidence, checked_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(tenant_id, domain, check_id) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
message = excluded.message,
|
||||
evidence = excluded.evidence,
|
||||
checked_at = excluded.checked_at
|
||||
""",
|
||||
(
|
||||
tenant_id,
|
||||
domain.lower(),
|
||||
check_id,
|
||||
status,
|
||||
message,
|
||||
json.dumps(evidence or {}),
|
||||
checked_at or _now(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_checks(conn: sqlite3.Connection, tenant_id: int, domain: str) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT check_id, status, message, evidence, checked_at
|
||||
FROM audit_checks WHERE tenant_id = ? AND domain = ?
|
||||
ORDER BY check_id
|
||||
""",
|
||||
(tenant_id, domain.lower()),
|
||||
).fetchall()
|
||||
out = []
|
||||
for row in rows:
|
||||
item = dict(row)
|
||||
item["label"] = CHECK_LABELS.get(item["check_id"], item["check_id"])
|
||||
item["evidence"] = _parse_payload(item.get("evidence"))
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def aggregate_score(checks: list[dict]) -> dict[str, Any]:
|
||||
total = len(CHECK_LABELS)
|
||||
counts = {"pass": 0, "warn": 0, "fail": 0, "error": 0, "skip": 0}
|
||||
worst = "pass"
|
||||
for c in checks:
|
||||
st = c.get("status") or "skip"
|
||||
counts[st] = counts.get(st, 0) + 1
|
||||
if STATUS_RANK.get(st, 0) > STATUS_RANK.get(worst, 0):
|
||||
worst = st
|
||||
if worst in ("fail", "error"):
|
||||
overall = "critical"
|
||||
elif worst == "warn":
|
||||
overall = "degraded"
|
||||
elif checks:
|
||||
overall = "healthy"
|
||||
else:
|
||||
overall = "unknown"
|
||||
return {
|
||||
"pass": counts.get("pass", 0),
|
||||
"warn": counts.get("warn", 0),
|
||||
"fail": counts.get("fail", 0),
|
||||
"error": counts.get("error", 0),
|
||||
"skip": counts.get("skip", 0),
|
||||
"total": total,
|
||||
"overall_status": overall,
|
||||
}
|
||||
|
||||
|
||||
def tenant_overview(conn: sqlite3.Connection, tenant_id: int, name: str, ip: str) -> dict:
|
||||
if tenant_id == 2:
|
||||
from app.modules import store as module_store
|
||||
|
||||
if module_store.is_module_enabled("wazuh-soc"):
|
||||
from app.wazuh_soc_store import wazuh_tenant_overview
|
||||
|
||||
return wazuh_tenant_overview(conn, tenant_id, name, ip)
|
||||
domains = list_audit_domains(conn, tenant_id)
|
||||
if not domains:
|
||||
return {
|
||||
"tenant_id": tenant_id,
|
||||
"name": name,
|
||||
"ip": ip,
|
||||
"status": "unknown",
|
||||
"score": {"pass": 0, "warn": 0, "fail": 0, "total": 8},
|
||||
"domains_count": 0,
|
||||
"last_audit_at": None,
|
||||
"top_issues": [],
|
||||
}
|
||||
|
||||
all_checks: list[dict] = []
|
||||
last_audit = None
|
||||
top_issues: list[dict] = []
|
||||
domain_scores: list[dict] = []
|
||||
for d in domains:
|
||||
checks = get_checks(conn, tenant_id, d["domain"])
|
||||
if not checks:
|
||||
continue
|
||||
all_checks.extend(checks)
|
||||
domain_scores.append(aggregate_score(checks))
|
||||
for c in checks:
|
||||
if c["checked_at"] and (not last_audit or c["checked_at"] > last_audit):
|
||||
last_audit = c["checked_at"]
|
||||
if c["status"] in ("fail", "error", "warn"):
|
||||
top_issues.append({
|
||||
"domain": d["domain"],
|
||||
"check_id": c["check_id"],
|
||||
"status": c["status"],
|
||||
"message": c.get("message"),
|
||||
})
|
||||
|
||||
if domain_scores:
|
||||
worst = max(domain_scores, key=lambda s: STATUS_RANK.get(s["overall_status"], 0))
|
||||
score = worst
|
||||
else:
|
||||
score = aggregate_score(all_checks)
|
||||
return {
|
||||
"tenant_id": tenant_id,
|
||||
"name": name,
|
||||
"ip": ip,
|
||||
"status": score["overall_status"],
|
||||
"score": {
|
||||
"pass": score["pass"],
|
||||
"warn": score["warn"],
|
||||
"fail": score["fail"] + score["error"],
|
||||
"total": score["total"],
|
||||
},
|
||||
"domains_count": len(domains),
|
||||
"last_audit_at": last_audit,
|
||||
"top_issues": top_issues[:5],
|
||||
}
|
||||
|
||||
|
||||
def build_overview(conn: sqlite3.Connection) -> dict:
|
||||
tenants = conn.execute("SELECT id, name, ip FROM tenants ORDER BY id").fetchall()
|
||||
return {
|
||||
"generated_at": _now(),
|
||||
"tenants": [tenant_overview(conn, t["id"], t["name"], t["ip"]) for t in tenants],
|
||||
}
|
||||
|
||||
|
||||
def scorecard(conn: sqlite3.Connection, tenant_id: int, domain: str) -> dict:
|
||||
domain = domain.lower().strip()
|
||||
checks = get_checks(conn, tenant_id, domain)
|
||||
score = aggregate_score(checks)
|
||||
return {
|
||||
"tenant_id": tenant_id,
|
||||
"domain": domain,
|
||||
"checked_at": max((c["checked_at"] for c in checks), default=None),
|
||||
"overall_status": score["overall_status"],
|
||||
"checks": checks,
|
||||
}
|
||||
|
||||
|
||||
def _extract_client_ip(payload: dict, data: dict | None = None) -> str | None:
|
||||
data = data or {}
|
||||
for key in ("client_ip", "user_ip", "remote_ip", "srcip", "ip", "agent_ip"):
|
||||
val = data.get(key) or payload.get(key)
|
||||
if val:
|
||||
return str(val)
|
||||
ingress = payload.get("ingress_client_ip")
|
||||
return str(ingress) if ingress else None
|
||||
|
||||
|
||||
def _funnel_stage_from_events(events: list[dict]) -> str:
|
||||
best_rank = 0
|
||||
for ev in events:
|
||||
rank = FUNNEL_EVENT_RANK.get(ev.get("event") or "", 0)
|
||||
if rank > best_rank:
|
||||
best_rank = rank
|
||||
if best_rank:
|
||||
return FUNNEL_STAGE_BY_RANK.get(best_rank, "unknown")
|
||||
return "registered"
|
||||
|
||||
|
||||
def _execution_status(events: list[dict]) -> str:
|
||||
types = {ev.get("event") for ev in events}
|
||||
if "onboarding.failed" in types:
|
||||
return "failed"
|
||||
if "onboarding.completed" in types:
|
||||
return "completed"
|
||||
if types & set(FUNNEL_EVENT_RANK):
|
||||
return "in_progress"
|
||||
if events:
|
||||
return "in_progress"
|
||||
return "registered"
|
||||
|
||||
|
||||
def _tickets_for_domain(conn: sqlite3.Connection, domain: str) -> list[dict]:
|
||||
dom = domain.lower().strip()
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, subject, status, session_id, payload, created_at
|
||||
FROM tickets ORDER BY id DESC LIMIT 500
|
||||
"""
|
||||
).fetchall()
|
||||
out = []
|
||||
for row in rows:
|
||||
payload = _parse_payload(row["payload"])
|
||||
if (payload.get("domain") or "").strip().lower() != dom:
|
||||
continue
|
||||
data = payload.get("data") or {}
|
||||
out.append({
|
||||
"ticket_id": row["id"],
|
||||
"status": row["status"],
|
||||
"subject": row["subject"],
|
||||
"session_id": row["session_id"] or payload.get("session_id"),
|
||||
"email": data.get("email") or payload.get("account_email"),
|
||||
"crm_track": payload.get("crm_track"),
|
||||
"created_at": row["created_at"],
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _domain_webhook_events(conn: sqlite3.Connection, source: str | None, domain: str) -> list[dict]:
|
||||
if not source:
|
||||
return []
|
||||
dom = domain.lower().strip()
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT event_type, payload, created_at FROM webhook_events
|
||||
WHERE source = ?
|
||||
ORDER BY created_at ASC
|
||||
""",
|
||||
(source,),
|
||||
).fetchall()
|
||||
events = []
|
||||
for row in rows:
|
||||
payload = _parse_payload(row["payload"])
|
||||
if (payload.get("domain") or "").strip().lower() != dom:
|
||||
continue
|
||||
data = payload.get("data") or {}
|
||||
client_ip = _extract_client_ip(payload, data)
|
||||
detail = data.get("step") or data.get("description") or data.get("agent")
|
||||
if source == "wazuh" and not client_ip:
|
||||
client_ip = data.get("agent_ip") or data.get("srcip")
|
||||
events.append({
|
||||
"event": row["event_type"],
|
||||
"at": row["created_at"],
|
||||
"session_id": payload.get("session_id"),
|
||||
"email": data.get("email"),
|
||||
"client_ip": client_ip,
|
||||
"detail": detail,
|
||||
})
|
||||
return events
|
||||
|
||||
|
||||
def _domain_detail(conn: sqlite3.Connection, tenant_id: int, domain_row: dict) -> dict:
|
||||
domain = domain_row["domain"]
|
||||
checks = get_checks(conn, tenant_id, domain)
|
||||
score = aggregate_score(checks)
|
||||
issues = [
|
||||
{
|
||||
"check_id": c["check_id"],
|
||||
"label": c.get("label") or CHECK_LABELS.get(c["check_id"], c["check_id"]),
|
||||
"status": c["status"],
|
||||
"message": c.get("message"),
|
||||
"checked_at": c.get("checked_at"),
|
||||
"evidence": c.get("evidence") or {},
|
||||
}
|
||||
for c in checks
|
||||
if c.get("status") in ("fail", "error", "warn")
|
||||
]
|
||||
source = TENANT_WEBHOOK_SOURCE.get(tenant_id)
|
||||
timeline = _domain_webhook_events(conn, source, domain)
|
||||
tickets = _tickets_for_domain(conn, domain)
|
||||
ticket = tickets[0] if tickets else None
|
||||
funnel_stage = _funnel_stage_from_events(timeline)
|
||||
execution_status = _execution_status(timeline)
|
||||
client_ips = sorted({ev["client_ip"] for ev in timeline if ev.get("client_ip")})
|
||||
last_event = timeline[-1] if timeline else None
|
||||
started_at = timeline[0]["at"] if timeline else domain_row.get("created_at")
|
||||
return {
|
||||
"domain": domain,
|
||||
"source": domain_row.get("source"),
|
||||
"registered_at": domain_row.get("created_at"),
|
||||
"email": (last_event or {}).get("email") or (ticket or {}).get("email"),
|
||||
"session_id": (last_event or {}).get("session_id") or (ticket or {}).get("session_id"),
|
||||
"client_ip": client_ips[-1] if client_ips else None,
|
||||
"client_ips": client_ips,
|
||||
"funnel_stage": funnel_stage,
|
||||
"funnel_stage_label": FUNNEL_STAGE_LABELS.get(funnel_stage, funnel_stage),
|
||||
"execution_status": execution_status,
|
||||
"last_event": (last_event or {}).get("event"),
|
||||
"last_event_at": (last_event or {}).get("at"),
|
||||
"started_at": started_at,
|
||||
"audit_status": score["overall_status"],
|
||||
"score": {
|
||||
"pass": score["pass"],
|
||||
"warn": score["warn"],
|
||||
"fail": score["fail"] + score["error"],
|
||||
"total": score["total"],
|
||||
},
|
||||
"issue_count": len(issues),
|
||||
"issues": issues,
|
||||
"ticket_id": (ticket or {}).get("ticket_id"),
|
||||
"ticket_status": (ticket or {}).get("status"),
|
||||
"tickets_count": len(tickets),
|
||||
"timeline": timeline,
|
||||
"last_audit_at": max((c["checked_at"] for c in checks), default=None),
|
||||
}
|
||||
|
||||
|
||||
def _apply_funnel_timing_to_domains(domain_details: list[dict]) -> None:
|
||||
from app.funnel_timing import apply_module_timing
|
||||
|
||||
for domain in domain_details:
|
||||
timeline = domain.get("timeline") or []
|
||||
if not timeline:
|
||||
continue
|
||||
enriched, timing_meta = apply_module_timing(timeline)
|
||||
domain["timeline"] = enriched
|
||||
if timing_meta:
|
||||
domain["timing"] = timing_meta
|
||||
|
||||
|
||||
def tenant_details(conn: sqlite3.Connection, tenant_id: int) -> dict | None:
|
||||
row = conn.execute("SELECT id, name, ip FROM tenants WHERE id = ?", (tenant_id,)).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
if tenant_id == 2:
|
||||
from app.modules import store as module_store
|
||||
|
||||
if module_store.is_module_enabled("wazuh-soc"):
|
||||
from app.wazuh_soc_store import wazuh_tenant_details
|
||||
|
||||
return wazuh_tenant_details(conn, tenant_id, row["name"], row["ip"])
|
||||
domains = list_audit_domains(conn, tenant_id)
|
||||
domain_details = [_domain_detail(conn, tenant_id, d) for d in domains]
|
||||
_apply_funnel_timing_to_domains(domain_details)
|
||||
summary = {
|
||||
"domains_total": len(domain_details),
|
||||
"in_progress": sum(1 for d in domain_details if d["execution_status"] == "in_progress"),
|
||||
"completed": sum(1 for d in domain_details if d["execution_status"] == "completed"),
|
||||
"failed": sum(1 for d in domain_details if d["execution_status"] == "failed"),
|
||||
"registered": sum(1 for d in domain_details if d["execution_status"] == "registered"),
|
||||
"with_issues": sum(1 for d in domain_details if d["issue_count"] > 0),
|
||||
}
|
||||
result = {
|
||||
"tenant_id": tenant_id,
|
||||
"name": row["name"],
|
||||
"ip": row["ip"],
|
||||
"generated_at": _now(),
|
||||
"summary": summary,
|
||||
"domains": domain_details,
|
||||
}
|
||||
if tenant_id == 1:
|
||||
from app.modules import store as module_store
|
||||
|
||||
if module_store.is_module_enabled("wizard-security"):
|
||||
from app import security_store
|
||||
|
||||
result["security"] = security_store.build_summary(conn, window_hours=24)
|
||||
return result
|
||||
1290
ligbox-ops-platform/deploy/vm122-desk/api/app/main.py
Normal file
1290
ligbox-ops-platform/deploy/vm122-desk/api/app/main.py
Normal file
File diff suppressed because it is too large
Load diff
4062
ligbox-ops-platform/deploy/vm122-desk/frontend/assets/app.js
Normal file
4062
ligbox-ops-platform/deploy/vm122-desk/frontend/assets/app.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,56 @@
|
|||
# KB — Portal de gerenciamento abria wizard Concluído (2026-06-19)
|
||||
|
||||
**Sistema:** Wizard VM112 · `onboard.ligbox.com.br`
|
||||
**Severidade:** P1 UX — botão errado no passo Concluído
|
||||
**Reportado por:** Roger
|
||||
**Estado:** ✅ Corrigido em produção + espelho CT130
|
||||
|
||||
---
|
||||
|
||||
## Sintoma
|
||||
|
||||
No passo **Concluído**, o botão **«Portal de gerenciamento»** (`href=/admin`) reabria o **wizard no passo final** em vez do painel **DomainAdmin** (Gerente de Domínio).
|
||||
|
||||
## Causa raiz
|
||||
|
||||
O host `onboard.ligbox.com.br` usa build **`VITE_WIZARD_ONLY=1`** (`main-wizard.jsx`), que **não tinha rota `/admin`**.
|
||||
|
||||
O `RouterApp.jsx` (site completo `ligbox.com.br`) mapeia `/admin` → `DomainAdmin`, mas o entry wizard-only caía sempre em `<App />` (wizard).
|
||||
|
||||
## Comportamento correcto (spec)
|
||||
|
||||
| Botão | Destino | Notas |
|
||||
|-------|---------|-------|
|
||||
| Abrir webmail | `https://mail.{domínio}/` | Nova aba |
|
||||
| Portal de gerenciamento | `/admin` → **DomainAdmin** | Login `admin@domínio` + senha onboarding |
|
||||
| Finalizar | `ligbox.com.br/#self-service` | Limpa sessão wizard |
|
||||
|
||||
Ver também: Spec 025 § Passo Concluído; chat bruto OPS jun/2025 («Abrir painel do domínio →»).
|
||||
|
||||
## Correção
|
||||
|
||||
`main-wizard.jsx` — antes do render do wizard:
|
||||
|
||||
```javascript
|
||||
if (path === "/admin/forgot-password") → ForgotDomainPassword
|
||||
else if (path === "/admin" || path.startsWith("/admin/")) → DomainAdmin
|
||||
```
|
||||
|
||||
Deploy: `npm run build:wizard` + `ligbox-wizard.service`.
|
||||
|
||||
## Outras correcções na mesma sessão
|
||||
|
||||
- `clientSettings.js` — texto SMTP legado em `sessionStorage` (IP partilhado / Ibytera)
|
||||
- `onboarding.py` — `smtp_note` canónico sem IP partilhado
|
||||
- Título clientes: Thunderbird / Outlook / **iPhone / Android**
|
||||
- Desk VM122: card **Ligbox Datacenter — Node VM001** + sync domínios audit
|
||||
|
||||
## Verificação
|
||||
|
||||
1. Concluir onboarding (ou passo 4 com conta criada)
|
||||
2. Clicar **Portal de gerenciamento**
|
||||
3. Deve abrir **login DomainAdmin**, não o wizard Concluído
|
||||
|
||||
## Ficheiros espelho (CT130)
|
||||
|
||||
`deploy/vm112-wizard/frontend/src/main-wizard.jsx`
|
||||
|
|
@ -129,3 +129,4 @@ Ver `INDICE_MODELAGEM_BRUTA.txt` em `/root/ligbox-ops-platform-chat-bruto/`:
|
|||
python3 /opt/ligbox-ops-platform/scripts/export-chat-bruto-standalone.py \
|
||||
<caminho.jsonl> CHAT_BRUTO_<NOME>_<YYYYMMDD> <transcript-uuid>
|
||||
```
|
||||
- [[20260619_WIZARD_PORTAL_ADMIN_ROUTE_FIX]] — Portal /admin reabria wizard Concluído (VM112)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,400 @@
|
|||
# Spec funcional — VM112 Ligbox Onboard
|
||||
|
||||
**Data:** 2026-06-04
|
||||
**Host:** `mail.dratcoin.com` · `10.10.10.112`
|
||||
**Produto:** **Ligbox Onboard** (linha **Ligbox Mail**)
|
||||
**Repo:** `ibytera-mail-portal` · produção `/opt/ibytera-mail-portal`
|
||||
**Família:** [FAMILIA_PRODUTOS_LIGBOX_MAIL.md](../product/FAMILIA_PRODUTOS_LIGBOX_MAIL.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. Identidade da VM
|
||||
|
||||
| Campo | Valor |
|
||||
|-------|--------|
|
||||
| **VMID Proxmox** | 112 |
|
||||
| **Hostname** | `mail.dratcoin.com` |
|
||||
| **IP LAN** | `10.10.10.112/24` |
|
||||
| **IP público mail** | `95.216.14.146` (via CT114 / pfSense) |
|
||||
| **SSH WAN (pfSense NAT)** | `95.216.14.146:2512` → VM112:22 |
|
||||
| **SO** | Ubuntu 22.04/24.04, kernel 6.8.x |
|
||||
| **RAM** | 11 GiB |
|
||||
| **Disco** | `/dev/sda1` ~96 GB (~20 GB usados) |
|
||||
| **Papel** | EmailServer Carbonio + **Ligbox Onboard** (único nó mail com wizard hoje) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Família de produtos nesta VM
|
||||
|
||||
```
|
||||
Ligbox Mail
|
||||
└── Ligbox Onboard (VM112) ← este documento
|
||||
├── Landing Ligbox (/)
|
||||
├── Wizard (/onboard)
|
||||
├── Painel domínio (/admin) — parcial
|
||||
└── Motor Carbonio (8 domínios)
|
||||
```
|
||||
|
||||
| Produto / módulo | Estado | Rota / interface |
|
||||
|------------------|--------|------------------|
|
||||
| **Ligbox Onboard** — wizard | ✅ Produção | `/onboard` |
|
||||
| **Ligbox** — landing marketing | ✅ | `/` |
|
||||
| **Domain Admin** — painel corporativo | 🔄 Parcial | `/admin` |
|
||||
| **Carbonio EmailServer** | ✅ | `mail.*` :443, SMTP, IMAP |
|
||||
| **Agente suporte IA** | ✅ Fases 1–3.1 | Modal no wizard + tickets |
|
||||
| **Ligbox Ops Platform** | 📋 VM113 | Integração futura API/webhooks |
|
||||
|
||||
**UI marca (PRD-6 ✅):** cabeçalho wizard = **Ligbox Onboard** + `powered by Ibytera`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Topologia — VMs e CTs ligados
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Internet
|
||||
CF[Cloudflare API]
|
||||
Users[Clientes / Browser]
|
||||
end
|
||||
|
||||
subgraph WAN["95.216.14.146"]
|
||||
CT114[CT114 Traefik + HAProxy SNI<br/>10.10.10.114]
|
||||
end
|
||||
|
||||
subgraph LAN["10.10.10.0/24"]
|
||||
VM112[VM112 Ligbox Onboard + Carbonio<br/>10.10.10.112:8090 / :443]
|
||||
VM115[VM115 ntfy<br/>10.10.10.115:8091]
|
||||
VM113[VM113 Ligbox Ops — planeado<br/>10.10.10.113]
|
||||
PVE[Proxmox 95.216.14.162<br/>sync / workspace]
|
||||
end
|
||||
|
||||
Users -->|HTTPS onboard.ibytera.com| CT114
|
||||
Users -->|HTTPS mail.dominio.com| CT114
|
||||
CT114 -->|HTTP :8090| VM112
|
||||
CT114 -->|HTTPS :443| VM112
|
||||
VM112 -->|SSH| CT114
|
||||
VM112 -->|zmprov local| VM112
|
||||
VM112 -->|HTTP notify| VM115
|
||||
VM112 -.->|API CF| CF
|
||||
VM112 -.->|webhooks futuro| VM113
|
||||
VM113 -.->|read API| VM112
|
||||
PVE -->|rsync deploy| VM112
|
||||
```
|
||||
|
||||
### 3.1 Tabela de nós externos
|
||||
|
||||
| Nó | ID | IP | Portas | Ligação desde VM112 | Função |
|
||||
|----|-----|-----|--------|---------------------|--------|
|
||||
| **Proxmox** | host | `95.216.14.162` | SSH `4422` | rsync **para** VM112 (origem workspace) | Deploy, backup projeto |
|
||||
| **CT114** | 114 | `10.10.10.114` | 443, 80, HAProxy | **SSH** `root@10.10.10.114` — edita Traefik/SNI | Terminação TLS WAN, roteamento |
|
||||
| **VM115** | 115 | `10.10.10.115` | `8091` | **HTTP** `ntfy_internal_url` | Push notificações (`ntfy.ligbox.com.br`) |
|
||||
| **VM113** | 113 | `10.10.10.113` (planeado) | TBD | **Futuro:** webhooks + API read | Ligbox Ops Platform |
|
||||
| **pfSense** | VM? | `10.10.10.1` LAN | API `10443` | Indirecto (NAT) | Firewall / WAN |
|
||||
| **Cloudflare** | SaaS | API | HTTPS | Token em `secrets/cloudflare.token` | Zonas DNS conta Ibytera |
|
||||
| **LLM** | SaaS | — | HTTPS | `secrets/llm.env`, `kimi.env` | Gemini (primário), Kimi, OpenAI, Anthropic |
|
||||
|
||||
**Nota:** Acesso SSH à VM112 pela rede: `ssh -p 2512 root@95.216.14.146` (via pfSense WAN (95.216.14.146)).
|
||||
|
||||
---
|
||||
|
||||
## 4. Serviços em execução (systemd)
|
||||
|
||||
### 4.1 Ligbox Onboard
|
||||
|
||||
| Unidade | Estado | Descrição |
|
||||
|---------|--------|-----------|
|
||||
| `ibytera-mail-portal.service` | **active** | FastAPI + SPA estática, porta **8090** |
|
||||
|
||||
```ini
|
||||
ExecStart=.../uvicorn app.main:app --host 0.0.0.0 --port 8090
|
||||
WorkingDirectory=/opt/ibytera-mail-portal
|
||||
After=carbonio-appserver.service
|
||||
```
|
||||
|
||||
Health: `GET http://127.0.0.1:8090/api/onboarding/health` → `{"status":"ok"}`
|
||||
|
||||
### 4.2 Carbonio (EmailServer)
|
||||
|
||||
**~40 unidades** `carbonio-*` + sidecars. Principais:
|
||||
|
||||
| Serviço | Função |
|
||||
|---------|--------|
|
||||
| `carbonio-appserver` | Core mail (Java) |
|
||||
| `carbonio-nginx` | Proxy webmail HTTPS :443 |
|
||||
| `carbonio-postfix` | SMTP :25, :587 |
|
||||
| `carbonio-openldap` | Diretório |
|
||||
| `carbonio-milter` / `carbonio-antivirus` | Segurança mail |
|
||||
| `carbonio-memcached` | Cache |
|
||||
|
||||
**zmprov:** `/opt/zextras/bin/zmprov` (utilizador `zextras`)
|
||||
|
||||
### 4.3 Portas em escuta (resumo)
|
||||
|
||||
| Porta | Processo | Uso |
|
||||
|-------|----------|-----|
|
||||
| **8090** | uvicorn | Ligbox Onboard API + SPA |
|
||||
| **443** | carbonio-nginx | Webmail / admin Carbonio |
|
||||
| **25, 587** | postfix | SMTP |
|
||||
| **993** | nginx | IMAPS |
|
||||
| **7071** | java | Admin SOAP (local/LAN) |
|
||||
| **8080** | java | Appserver interno |
|
||||
|
||||
---
|
||||
|
||||
## 5. Domínios Carbonio (produção mail)
|
||||
|
||||
| Domínio | Webmail (público) | Portal registry |
|
||||
|---------|-------------------|-----------------|
|
||||
| `betinplace.com` | `mail.betinplace.com` | — |
|
||||
| `betinsport.com` | `mail.betinsport.com` | — |
|
||||
| `diarissima.com` | `mail.diarissima.com` | ✅ `diarissima.com.json` |
|
||||
| `dratcoin.com` | `mail.dratcoin.com` | ✅ `dratcoin.com.json` |
|
||||
| `eplacebets.com` | `mail.eplacebets.com` | — |
|
||||
| `ibytera.com` | `mail.ibytera.com` | — |
|
||||
| `iofficebooks.com` | `mail.iofficebooks.com` | — |
|
||||
| `myvexx.com` | `mail.myvexx.com` | — |
|
||||
|
||||
**SNI CT114** (`sni_vm112.lst`): todos os `mail.*` acima.
|
||||
**Cert LE VM112:** `mail-vm112-multi` + legado `mail.ligbox.com.br`
|
||||
**zimbraPublicServiceHostname (global):** `mail.ligbox.com.br` — hostname por domínio via `zmprov` no onboard.
|
||||
|
||||
---
|
||||
|
||||
## 6. URLs públicas
|
||||
|
||||
| URL | Destino | TLS |
|
||||
|-----|---------|-----|
|
||||
| https://onboard.ibytera.com/onboard | CT114 → VM112:8090 | LE (CT114) |
|
||||
| https://onboard.ibytera.com/ | Landing + rotas SPA | idem |
|
||||
| https://onboard.ibytera.com/api/docs | OpenAPI | idem |
|
||||
| https://mail.{dominio}/ | CT114 → VM112:443 | LE multi-SAN |
|
||||
| http://10.10.10.112:8090 | LAN directo (debug) | — |
|
||||
| https://ntfy.ligbox.com.br | VM115 (push público) | — |
|
||||
|
||||
**DNS:** registos `A` mail/onboard → `95.216.14.146` (sem proxy CF no mail).
|
||||
|
||||
---
|
||||
|
||||
## 7. Estrutura de diretórios
|
||||
|
||||
### 7.1 Código e deploy (`/opt/ibytera-mail-portal`)
|
||||
|
||||
```
|
||||
/opt/ibytera-mail-portal/
|
||||
├── backend/app/
|
||||
│ ├── main.py # FastAPI, monta SPA
|
||||
│ ├── config.py # Settings + secrets
|
||||
│ ├── routers/
|
||||
│ │ ├── onboarding.py # Wizard API
|
||||
│ │ ├── domain_admin.py # /admin API
|
||||
│ │ ├── corporate.py
|
||||
│ │ └── portal_auth.py
|
||||
│ └── services/
|
||||
│ ├── carbonio.py # zmprov, vhost nginx
|
||||
│ ├── infrastructure.py # SSH CT114, certbot
|
||||
│ ├── cloudflare.py
|
||||
│ ├── support_agent.py / support_tickets.py
|
||||
│ ├── domain_registry.py
|
||||
│ └── ...
|
||||
├── frontend/
|
||||
│ ├── src/App.jsx # Ligbox Onboard wizard
|
||||
│ ├── src/ligbox/ # Landing /
|
||||
│ └── dist/ # Build servido pelo uvicorn
|
||||
├── secrets/ # NÃO rsync (tokens)
|
||||
│ ├── cloudflare.token
|
||||
│ ├── llm.env
|
||||
│ └── kimi.env
|
||||
├── scripts/sync-all.sh # Proxmox → 112 → Obsidian → LAPTOP
|
||||
├── docs/
|
||||
│ ├── architecture/ # Este spec
|
||||
│ └── product/ # Família Ligbox Mail
|
||||
├── .env # Config não-secreta
|
||||
└── .venv/ # Python 3.12
|
||||
```
|
||||
|
||||
### 7.2 Dados runtime (`/var/lib/ibytera-mail-portal`)
|
||||
|
||||
| Pasta | Conteúdo |
|
||||
|-------|----------|
|
||||
| `domains/` | JSON por domínio onboarded (`*.json`) |
|
||||
| `portal_users/` | Utilizadores portal / sessões |
|
||||
| `tickets/` | Tickets suporte IA |
|
||||
| `company_profiles/` | Perfil empresa wizard |
|
||||
|
||||
### 7.3 Carbonio
|
||||
|
||||
| Pasta | Uso |
|
||||
|-------|-----|
|
||||
| `/opt/zextras/` | Binários Carbonio |
|
||||
| `/opt/zextras/conf/nginx/` | **carbonio-nginx** vhosts |
|
||||
| `/opt/zextras/log/` | Logs mail (futuro SOC) |
|
||||
| `/etc/letsencrypt/live/mail-vm112-multi/` | Cert multi-domínio |
|
||||
|
||||
### 7.4 CT114 (remoto via SSH)
|
||||
|
||||
| Ficheiro | Uso |
|
||||
|----------|-----|
|
||||
| `/root/traefik/dynamic.yml` | Routers Traefik (onboard + mail.*) |
|
||||
| `/root/traefik/haproxy-mail-sni/maps/sni_vm112.lst` | Mapa SNI → backend 112 |
|
||||
| Docker: `traefik`, `mail-sni-proxy` | Edge HTTPS |
|
||||
|
||||
---
|
||||
|
||||
## 8. API Ligbox Onboard (resumo)
|
||||
|
||||
**Prefixo:** `/api/onboarding` · sessão: header `X-Onboarding-Session`
|
||||
|
||||
| Grupo | Endpoints principais |
|
||||
|-------|---------------------|
|
||||
| Sessão | `POST /session/reset` |
|
||||
| Domínio | `POST /validate-domain` |
|
||||
| DNS CF | `POST /dns/cloudflare/provision-zone`, `POST /dns/cloudflare/apply`, `GET /dns/verify/{domain}` |
|
||||
| Conta | `POST /account/create` |
|
||||
| Infra | `GET /infrastructure/status/{domain}`, `POST /infrastructure/provision` |
|
||||
| Suporte | `POST /support/ticket`, `POST .../message`, `POST .../escalate` |
|
||||
| Perfil | `GET/PUT /company-profile`, `POST .../finalize` |
|
||||
| Log | `GET /activity-log` |
|
||||
|
||||
**Domain admin:** `/api/domain-admin/*` (login, contas, quota, 2FA).
|
||||
|
||||
---
|
||||
|
||||
## 9. Pipeline infra (wizard → CT114 + VM112)
|
||||
|
||||
Ordem em `infrastructure.provision`:
|
||||
|
||||
1. **haproxy_sni** — adiciona `mail.{dom}` em `sni_vm112.lst` (CT114)
|
||||
2. **traefik_router** — router `mail-*-Router` em `dynamic.yml` (CT114)
|
||||
3. **certbot** — SAN em `mail-vm112-multi` (VM112)
|
||||
4. **carbonio_hostname** — `zimbraPublicServiceHostname` por domínio
|
||||
5. **carbonio_nginx_vhost** — `zmproxyconfgen` + reload nginx
|
||||
6. **webmail_https** — verificação HTTPS `mail.{dom}`
|
||||
|
||||
**SSH:** `traefik_ssh_host = root@10.10.10.114` (chave root VM112→CT114).
|
||||
|
||||
---
|
||||
|
||||
## 10. Sincronização de artefactos (canais)
|
||||
|
||||
| Canal | Caminho | Direção |
|
||||
|-------|---------|---------|
|
||||
| **Produção VM112** | `/opt/ibytera-mail-portal` | Target deploy |
|
||||
| **Workspace Proxmox** | `/root/workspace/projects/ibytera-mail-portal` | Dev + git |
|
||||
| **Obsidian** | `/root/obsidian-infra/carbonio/ibytera-mail-portal` | Documentação |
|
||||
| **LAPTOP** | `.../LAPTOP/` → `C:\LAPTOP\projetos\` | scp Windows |
|
||||
| **GitHub** | https://github.com/itecnologys/ibytera-mail-portal | Versionamento |
|
||||
|
||||
```bash
|
||||
# Executar no host que tem o código fonte (Proxmox ou VM112):
|
||||
./scripts/sync-all.sh
|
||||
```
|
||||
|
||||
**Excluídos do rsync:** `.venv`, `node_modules`, `secrets/`, `.env`
|
||||
|
||||
---
|
||||
|
||||
## 11. Segredos e integrações SaaS
|
||||
|
||||
| Ficheiro | Integração |
|
||||
|----------|------------|
|
||||
| `secrets/cloudflare.token` | Cloudflare API (conta Ibytera) |
|
||||
| `secrets/llm.env` | Gemini, fallback chain |
|
||||
| `secrets/kimi.env` | Kimi (opcional) |
|
||||
| `.env` | IP mail, SMTP local, URLs ntfy |
|
||||
|
||||
**Notificações:** SMTP `127.0.0.1:25` + ntfy `10.10.10.115:8091` / https://ntfy.ligbox.com.br
|
||||
|
||||
---
|
||||
|
||||
## 12. Frontend — rotas SPA
|
||||
|
||||
| Path | Componente |
|
||||
|------|------------|
|
||||
| `/` | `LigboxHome` (landing) |
|
||||
| `/onboard` | Redirect → `onboard.ligbox.com.br` (ver **Spec 016**) |
|
||||
| `/admin` | `DomainAdmin` |
|
||||
| `/termos`, `/privacidade` | Legal |
|
||||
| `/sobre`, `/planos`, `/suporte`, … | `LegalStub` |
|
||||
|
||||
### 12.1 Handoff portal → wizard (Spec 016)
|
||||
|
||||
**Problema:** `ligbox.com.br` e `onboard.ligbox.com.br` não partilham `localStorage`.
|
||||
|
||||
**Solução:** tripla redundância — `?planned_email=`, cookie `.ligbox.com.br`, localStorage.
|
||||
|
||||
Documentação completa: [`SPEC_016_PORTAL_WIZARD_HANDOFF.md`](SPEC_016_PORTAL_WIZARD_HANDOFF.md)
|
||||
|
||||
**Regra:** usar sempre `redirectToOnboard(email)` no portal; build falha se regressão (`npm run check:handoff`).
|
||||
|
||||
|
||||
### 12.3 Build wizard-only (`main-wizard.jsx`) — rotas obrigatórias
|
||||
|
||||
Host: **`onboard.ligbox.com.br`** · build `npm run build:wizard`
|
||||
|
||||
| Path | Componente | Obrigatório |
|
||||
|------|------------|-------------|
|
||||
| `/admin` | `DomainAdmin` | ✅ Sim |
|
||||
| `/admin/forgot-password` | `ForgotDomainPassword` | ✅ Sim |
|
||||
| `/assist/{session}` | `App` (ASM) | ✅ Sim |
|
||||
| `?ticket=` | `ClientTicketPortal` | ✅ Sim |
|
||||
| default | `App` (wizard) | ✅ Sim |
|
||||
|
||||
**Bug P1 (2026-06-19):** omitir `/admin` fazia «Portal de gerenciamento» reabrir o wizard no passo Concluído. Ver KB `20260619_WIZARD_PORTAL_ADMIN_ROUTE_FIX.md`.
|
||||
|
||||
### 12.2 Passo DNS — Zona DNS Ligbox (Spec 017)
|
||||
|
||||
**Passo 2 do wizard:** escolha entre trazer DNS para a **Zona DNS Ligbox** (Cloudflare) ou manter no registrador actual.
|
||||
|
||||
No modo externo, após clicar «Manter no provedor actual», a UI mostra **dois blocos**: zona DNS actual (público + Cloudflare) e registos necessários Ligbox.
|
||||
|
||||
Documentação completa: [`SPEC_017_PORTAL_WIZARD_DNS_STEP.md`](SPEC_017_PORTAL_WIZARD_DNS_STEP.md)
|
||||
|
||||
**Endpoint chave:** `GET /api/onboarding/dns/current-zone/{domain}`
|
||||
|
||||
---
|
||||
|
||||
## 13. Ligação futura VM113 (Ligbox Ops)
|
||||
|
||||
| Evento (planeado) | Origem VM112 | Destino VM113 |
|
||||
|-------------------|--------------|---------------|
|
||||
| `account.created` | webhook | Desk / auditoria |
|
||||
| `infra.pending` | webhook | Alertas ops |
|
||||
| `infrastructure/status` | API read | Dashboard tenant `112` |
|
||||
|
||||
**Regra:** VM112 **fora** do Docker Compose VM113.
|
||||
|
||||
---
|
||||
|
||||
## 14. Documentos relacionados
|
||||
|
||||
| Documento | Local |
|
||||
|-----------|--------|
|
||||
| Família produtos | `docs/product/FAMILIA_PRODUTOS_LIGBOX_MAIL.md` |
|
||||
| **Handoff portal→wizard (Spec 016)** | `docs/architecture/SPEC_016_PORTAL_WIZARD_HANDOFF.md` |
|
||||
| **Passo DNS wizard (Spec 017)** | `docs/architecture/SPEC_017_PORTAL_WIZARD_DNS_STEP.md` |
|
||||
| Traefik onboard | `docs/TRAEFIK_ONBOARD.md` |
|
||||
| Release marca UI | `docs/releases/RELEASE_20260604_PRD6_MARCA_UI.md` |
|
||||
| Backlog | `BACKLOG.md` |
|
||||
| Ligbox Ops spec (VM113) | `ligbox-ops-platform/docs/architecture/` (a criar) |
|
||||
|
||||
---
|
||||
|
||||
## 15. Inventário rápido (comando)
|
||||
|
||||
```bash
|
||||
# Serviços portal + carbonio
|
||||
systemctl is-active ibytera-mail-portal carbonio-nginx carbonio-appserver
|
||||
|
||||
# Domínios
|
||||
/opt/zextras/bin/zmprov gad
|
||||
|
||||
# Registry portal
|
||||
ls -la /var/lib/ibytera-mail-portal/domains/
|
||||
|
||||
# CT114 SNI
|
||||
ssh root@10.10.10.114 cat /root/traefik/haproxy-mail-sni/maps/sni_vm112.lst
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Gerado em 2026-06-04 a partir do estado live da VM112 (`mail.dratcoin.com`).*
|
||||
|
|
@ -152,3 +152,27 @@ Webhook: `account.reconciled` (novo) ou `account.created` com `reconciled: true`
|
|||
2. Clica «Continuar activação» → passo 4 sem erro 400
|
||||
3. Portal admin registado; gate Traefik sincronizado
|
||||
4. Ticket Desk recebe evento; Bloqueios Carbonio **não** aparece para este caso
|
||||
|
||||
---
|
||||
|
||||
## Passo Concluído — CTAs (UI)
|
||||
|
||||
**Adicionado:** 2026-06-19 · correção bug rota `/admin`
|
||||
|
||||
No passo **Concluído** (índice 4), três acções **independentes**:
|
||||
|
||||
| CTA | Comportamento | Não confundir com |
|
||||
|-----|---------------|-------------------|
|
||||
| **Abrir webmail** | Nova aba → `https://mail.{domínio}/` | — |
|
||||
| **Portal de gerenciamento** | Nova aba → `/admin` (**DomainAdmin**) | ≠ wizard Concluído |
|
||||
| **Finalizar** | Redirect `ligbox.com.br/#self-service` + `clearOnboardingSession()` | ≠ `/admin` |
|
||||
|
||||
### Build wizard-only (`onboard.ligbox.com.br`)
|
||||
|
||||
O entry **`main-wizard.jsx`** DEVE registar `/admin` → `DomainAdmin` (não só `RouterApp.jsx` da landing).
|
||||
|
||||
**Regressão conhecida (corrigida 2026-06-19):** sem esta rota, `/admin` re-renderizava `<App />` e o utilizador via o passo Concluído outra vez.
|
||||
|
||||
**KB:** `docs/anais-referencia/20260619_WIZARD_PORTAL_ADMIN_ROUTE_FIX.md`
|
||||
**Mirror código:** `deploy/vm112-wizard/frontend/src/main-wizard.jsx`
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue