#!/usr/bin/env bash
# =============================================================================
#  Suíte open source para MSP · instalador
#  Palestra "Coopetir" · CoopTI Summit 2026 · UNODATA
#
#  Sobe até 8 ferramentas num único servidor Linux, cada uma no seu subdomínio
#  com HTTPS automático (Let's Encrypt, via Caddy):
#
#    1 Keycloak      identidade e login único      sso.<domínio>
#    2 Zabbix 7      monitoramento                 zabbix.<domínio>
#    3 GLPI          helpdesk e inventário         glpi.<domínio>
#    4 Wazuh         segurança, SIEM e CIS         wazuh.<domínio>
#    5 Tactical RMM  RMM e acesso remoto           rmm, api-rmm e mesh-rmm.<domínio>
#    6 Nextcloud     arquivos                      arquivos.<domínio>
#    7 Vaultwarden   cofre de senhas               senhas.<domínio>
#    8 Uptime Kuma   página de status              status.<domínio>
#
#  Com o Keycloak, liga o LOGIN ÚNICO: um portal (portal.<domínio>) com 2FA por
#  aplicativo, e Zabbix, GLPI, Wazuh, Nextcloud, Vaultwarden e Uptime Kuma
#  entrando pela mesma conta. O Tactical RMM fica de fora: o login único dele é
#  pago (patrocínio do projeto).
#
#  Uso:
#    curl -fsSLO https://www.unodata.com.br/suite-msp/instalar-suite-msp.sh
#    sudo bash instalar-suite-msp.sh              (pergunta tudo)
#    sudo DOMINIO=msp.empresa.com.br EMAIL=ti@empresa.com.br \
#         FERRAMENTAS=todas bash instalar-suite-msp.sh --sim   (sem perguntas)
#
#  Sistemas testados: Ubuntu 24.04. Deve funcionar em Ubuntu 22.04 e Debian 12/13.
#  Pode rodar de novo: reaproveita as senhas e não reinstala o que já está no ar.
#
#  Licença MIT. Use, mude, melhore e devolva a melhoria para o grupo.
# =============================================================================
set -Eeuo pipefail

VERSAO_INSTALADOR="1.1 (2026-11-08)"
BASE=${BASE:-/opt/suite-msp}
ENVF=$BASE/.env
ESTADO=$BASE/.estado
CRED=$BASE/CREDENCIAIS.txt
LOG=$BASE/instalacao.log

# Versões testadas. Podem ser trocadas por variável de ambiente.
WAZUH_REPO_TAG=${WAZUH_REPO_TAG:-v4.14.10}
WAZUH_VERSOES=${WAZUH_VERSOES:-"4.14.10 4.14.9"}   # usa a primeira que tiver imagem publicada
TACTICAL_REF=${TACTICAL_REF:-master}
GLPI_IMG=${GLPI_IMG:-glpi/glpi:12.0.1}
OAUTH2_IMG=${OAUTH2_IMG:-quay.io/oauth2-proxy/oauth2-proxy:v7.15.5}

TODAS=(keycloak zabbix glpi wazuh tactical nextcloud vaultwarden kuma)
declare -A NOME=([keycloak]="Keycloak" [zabbix]="Zabbix 7" [glpi]="GLPI" [wazuh]="Wazuh"
  [tactical]="Tactical RMM" [nextcloud]="Nextcloud" [vaultwarden]="Vaultwarden" [kuma]="Uptime Kuma")
declare -A DESC=([keycloak]="identidade e login único" [zabbix]="monitoramento" [glpi]="helpdesk e inventário"
  [wazuh]="segurança, SIEM e CIS" [tactical]="RMM e acesso remoto" [nextcloud]="arquivos"
  [vaultwarden]="cofre de senhas" [kuma]="página de status")
# Memória aproximada medida em produção (MB), para o aviso de capacidade.
declare -A MEM=([keycloak]=700 [zabbix]=400 [glpi]=450 [wazuh]=3500 [tactical]=1100
  [nextcloud]=400 [vaultwarden]=50 [kuma]=150)

# ---------------------------------------------------------------- utilidades
if [ -t 1 ]; then C_AZ=$'\e[1;36m'; C_OK=$'\e[1;32m'; C_AV=$'\e[1;33m'; C_ER=$'\e[1;31m'; C_N=$'\e[1m'; C_0=$'\e[0m'
else C_AZ=; C_OK=; C_AV=; C_ER=; C_N=; C_0=; fi
etapa() { echo; echo "${C_AZ}==> $*${C_0}"; }
ok()    { echo "    ${C_OK}✔${C_0} $*"; }
aviso() { echo "    ${C_AV}!${C_0} $*"; }
erro()  { echo; echo "${C_ER}ERRO:${C_0} $*" >&2; exit 1; }
trap 'echo; echo "${C_ER}A instalação parou na linha $LINENO.${C_0} Veja o log: $LOG" >&2' ERR

SIM=0
for a in "$@"; do
  case "$a" in
    --sim|-y) SIM=1 ;;
    -h|--help) sed -n '2,27p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
    *) erro "opção desconhecida: $a (use --help)" ;;
  esac
done

# Lê do terminal mesmo quando o script vem por "curl ... | bash".
perguntar() { # $1 texto  $2 padrão  -> RESP
  local p="$1" d="${2:-}" r=""
  if [ "$SIM" = 1 ]; then RESP="$d"; return; fi
  sleep 0.3   # deixa o log (tee) terminar de escrever antes da pergunta
  if [ -n "$d" ]; then printf "%s [%s]: " "$p" "$d" >/dev/tty; else printf "%s: " "$p" >/dev/tty; fi
  IFS= read -r r </dev/tty || erro "não consegui ler a resposta no terminal. Baixe o script e rode: sudo bash instalar-suite-msp.sh"
  RESP="${r:-$d}"
}
sim_nao() { # $1 texto $2 padrão s|n
  perguntar "$1 (s/n)" "${2:-s}"
  case "${RESP,,}" in s|sim|y|yes) return 0 ;; *) return 1 ;; esac
}
gerar() { openssl rand -base64 48 | tr -dc 'A-Za-z0-9' | cut -c1-"${1:-20}"; }
tem() { [[ " ${SEL[*]} " == *" $1 "* ]]; }
esperar() { # $1 descrição  $2 segundos  $3.. comando
  local d="$1" t="$2"; shift 2
  local i=0
  printf "    aguardando %s " "$d"
  until "$@" >/dev/null 2>&1; do
    i=$((i+5)); [ $i -ge "$t" ] && { echo; return 1; }
    printf "."; sleep 5
  done
  echo " pronto"
}
# Com "curl ... | sudo bash" a entrada padrão É o próprio script: um
# "docker compose exec" ou um apt que leia a entrada engoliria o resto do código
# sem erro nenhum. Por isso nenhum deles lê a entrada padrão.
docker()  { command docker "$@" </dev/null; }
apt-get() { command apt-get "$@" </dev/null; }

# Baixa as imagens uma a uma. O "docker compose pull" em paralelo trava o
# containerd quando vários serviços usam a mesma imagem (caso do Tactical).
puxar() {
  local img t
  for img in $(docker compose config --images | sort -u); do
    for t in 1 2 3; do
      if timeout 900 docker pull -q "$img" >/dev/null 2>&1; then break; fi
      [ $t = 3 ] && erro "não consegui baixar a imagem $img (rede ou limite do Docker Hub)"
      aviso "nova tentativa de baixar $img"; sleep 15
    done
  done
}
# >>> wazuh_endurecer
# Troca TODAS as senhas padrão do Wazuh, não só a do admin. Medido em 08/11/2026:
# kibanaserver, kibanaro, logstash, readall e snapshotrestore entravam no painel
# PÚBLICO com a senha da documentação. Também troca o wazuh-wui da API e liga a
# senha de cadastro de agentes (porta 1515). Roda dentro da pasta single-node.
wazuh_endurecer() {
  local IDX=/usr/share/wazuh-indexer D=/usr/share/wazuh-indexer/config
  local U=config/wazuh_indexer/internal_users.yml API_PADRAO='MyS3cr37P450r.*-'
  local novo par u p h c t TK id
  porta_9200() { [ "$(curl -sk -o /dev/null -m 5 -w '%{http_code}' https://127.0.0.1:9200/ || true)" != 000 ]; }
  api_tk() { curl -sk -m 10 -u "wazuh-wui:$1" -X POST "https://127.0.0.1:55000/security/user/authenticate?raw=true" || true; }
  # A API no ar responde 401 sem credencial; isso não conta como tentativa de login.
  api_no_ar() { [ "$(curl -sk -o /dev/null -m 5 -w '%{http_code}' https://127.0.0.1:55000/ || true)" = 401 ]; }
  esperar "o indexador do Wazuh" 600 porta_9200 || erro "o indexador do Wazuh não abriu a porta 9200"

  # 1. usuários internos do indexador: admin e kibanaserver com senha guardada,
  #    os outros quatro com senha aleatória descartada (ninguém precisa deles).
  novo=$(cat "$U")
  for par in "admin:$WAZUH_ADMIN_PASS" "kibanaserver:$WAZUH_DASH_PASS" "kibanaro:$(gerar 32)" \
             "logstash:$(gerar 32)" "readall:$(gerar 32)" "snapshotrestore:$(gerar 32)"; do
    u=${par%%:*}; p=${par#*:}
    h=$(docker compose exec -T wazuh.indexer bash -c "export JAVA_HOME=$IDX/jdk; bash $IDX/plugins/opensearch-security/tools/hash.sh -p '$p'" 2>/dev/null | tail -1 | tr -d '\r')
    [[ "$h" == \$2* ]] || erro "não consegui gerar o hash da senha de $u no Wazuh"
    novo=$(printf '%s\n' "$novo" | sed "/^$u:/{n;s|hash: \".*\"|hash: \"$h\"|}")
  done
  printf '%s\n' "$novo" > "$U"   # mesmo arquivo (mesmo inode): o contêiner enxerga a mudança
  ok "senhas novas para os 6 usuários internos do indexador"

  # 2. usuário wazuh-wui da API: o init da imagem do manager grava API_PASSWORD
  #    nesse usuário a cada início (create_user.py), então basta trocar a variável
  #    abaixo e recriar o contêiner. Nada de login em loop: a API bloqueia o
  #    endereço depois de várias tentativas falhas.

  # 3. compose e painel passam a usar as senhas novas
  sed -i -e "s/INDEXER_PASSWORD=SecretPassword/INDEXER_PASSWORD=$WAZUH_ADMIN_PASS/g" \
         -e "s/DASHBOARD_PASSWORD=kibanaserver/DASHBOARD_PASSWORD=$WAZUH_DASH_PASS/g" \
         -e "s/API_PASSWORD=MyS3cr37P450r\.\*-/API_PASSWORD=$WAZUH_API_PASS/g" docker-compose.yml
  novo=$(sed "s/password: \"MyS3cr37P450r\.\*-\"/password: \"$WAZUH_API_PASS\"/" config/wazuh_dashboard/wazuh.yml)
  printf '%s\n' "$novo" > config/wazuh_dashboard/wazuh.yml

  # 4. cadastro de agentes só com senha
  novo=$(sed 's#<use_password>no</use_password>#<use_password>yes</use_password>#' config/wazuh_cluster/wazuh_manager.conf)
  printf '%s\n' "$novo" > config/wazuh_cluster/wazuh_manager.conf
  docker compose exec -T wazuh.manager bash -c "printf '%s\n' '$WAZUH_AGENT_PASS' > /var/ossec/etc/authd.pass && chown root:wazuh /var/ossec/etc/authd.pass && chmod 640 /var/ossec/etc/authd.pass"
  docker compose up -d --force-recreate wazuh.manager wazuh.dashboard

  # 5. aplica os usuários internos no indexador
  D=$IDX/config
  printf "    aplicando as senhas no indexador "
  for t in 1 2 3 4 5 6 7 8; do
    printf "."
    docker compose exec -T wazuh.indexer bash -c "export JAVA_HOME=$IDX/jdk; bash $IDX/plugins/opensearch-security/tools/securityadmin.sh -cd $D/opensearch-security/ -nhnv -cacert $D/certs/root-ca.pem -cert $D/certs/admin.pem -key $D/certs/admin-key.pem -p 9200 -icl" >/dev/null 2>&1 || true
    c=$(curl -sk -o /dev/null -w '%{http_code}' -u "admin:$WAZUH_ADMIN_PASS" https://127.0.0.1:9200/ || true)
    [ "$c" = 200 ] && break
    sleep 20
  done
  echo
  [ "$c" = 200 ] || erro "as senhas novas do Wazuh não entraram (código $c)"

  # 6. prova: nenhuma senha da documentação entra mais
  for par in admin:admin admin:SecretPassword kibanaserver:kibanaserver kibanaro:kibanaro \
             logstash:logstash readall:readall snapshotrestore:snapshotrestore; do
    c=$(curl -sk -o /dev/null -w '%{http_code}' -u "$par" https://127.0.0.1:9200/ || true)
    [ "$c" = 401 ] || erro "o usuário ${par%%:*} do Wazuh ainda aceita a senha padrão (código $c)"
  done
  esperar "a API do Wazuh voltar" 600 api_no_ar || true
  for t in 1 2 3 4 5 6; do [[ "$(api_tk "$WAZUH_API_PASS")" == ey* ]] && break; sleep 20; done
  [[ "$(api_tk "$WAZUH_API_PASS")" == ey* ]] && ok "senha nova do usuário wazuh-wui da API" \
    || aviso "a API do Wazuh ainda não aceitou a senha nova: confira em alguns minutos"
  [[ "$(api_tk "$API_PADRAO")" == ey* ]] && erro "o wazuh-wui ainda aceita a senha padrão"
  docker compose exec -T wazuh.manager grep -q '<use_password>yes</use_password>' /var/ossec/etc/ossec.conf \
    && ok "cadastro de agentes exige senha" || aviso "não consegui confirmar a senha de cadastro de agentes"
  ok "nenhuma senha padrão do Wazuh entra mais"
}
# <<< wazuh_endurecer
# >>> wazuh_sso
# Login único no Wazuh por SAML (caminho documentado pela Wazuh com o Keycloak).
# O grupo msp-admin vira all_access no indexador e administrator na API do Wazuh.
# Roda dentro da pasta single-node, com kc() já definido.
wazuh_sso() {
  local IDX=/usr/share/wazuh-indexer D=/usr/share/wazuh-indexer/config SEC=/usr/share/wazuh-indexer/config/opensearch-security
  local id tmp c TK rid novo
  # 1. cliente SAML no Keycloak
  id=$(kc_cid wazuh-saml)
  if [ -z "$id" ]; then
    id=$(kc create clients -r msp -i -s clientId=wazuh-saml -s protocol=saml -s "baseUrl=https://$H_WAZUH/auth/saml/login" \
      -s "redirectUris=[\"https://$H_WAZUH/*\"]" \
      -s 'attributes."saml_idp_initiated_sso_url_name"=wazuh-dashboard' \
      -s "attributes.\"saml_assertion_consumer_url_post\"=https://$H_WAZUH/_opendistro/_security/saml/acs/idpinitiated" \
      -s "attributes.\"saml_single_logout_service_url_redirect\"=https://$H_WAZUH" \
      -s 'attributes."saml.client.signature"=false' -s 'attributes."saml.server.signature"=true' \
      -s 'attributes."saml.assertion.signature"=true' -s 'attributes."saml_name_id_format"=username' \
      -s 'attributes."saml_force_name_id_format"=true' -s 'attributes."saml.force.post.binding"=true')
  fi
  kc_sem_role_list $id
  kc_mapper $id Roles -s protocol=saml -s protocolMapper=saml-group-membership-mapper \
    -s 'config."attribute.name"=Roles' -s 'config."full.path"=false' -s 'config.single=true' -s 'config."attribute.nameformat"=Basic'

  # 2. indexador: domínio SAML depois do login interno (que deixa de pedir senha por janela)
  tmp=$(mktemp -d)
  docker compose cp wazuh.indexer:$SEC/config.yml "$tmp/config.yml" >/dev/null 2>&1
  docker compose cp wazuh.indexer:$SEC/roles_mapping.yml "$tmp/roles_mapping.yml" >/dev/null 2>&1
  if ! grep -q saml_auth_domain "$tmp/config.yml"; then
    cat > "$tmp/saml.yml" <<EOF
      saml_auth_domain:
        description: "Login único pelo Keycloak (instalador da suíte)"
        http_enabled: true
        transport_enabled: false
        order: 7
        http_authenticator:
          type: saml
          challenge: true
          config:
            idp:
              metadata_url: "https://$H_SSO/realms/msp/protocol/saml/descriptor"
              entity_id: "https://$H_SSO/realms/msp"
            sp:
              entity_id: wazuh-saml
            kibana_url: "https://$H_WAZUH"
            roles_key: Roles
            exchange_key: "$WAZUH_SAML_KEY"
        authentication_backend:
          type: noop
EOF
    sed -i -e '/basic_internal_auth_domain:/,/authentication_backend:/ s/challenge: true/challenge: false/' \
           -e "/^    authc:\$/r $tmp/saml.yml" "$tmp/config.yml"
  fi
  grep -q '"msp-admin"' "$tmp/roles_mapping.yml" || \
    sed -i '/^all_access:/,/^  hosts:/ s/^  - "admin"$/  - "admin"\n  - "msp-admin"/' "$tmp/roles_mapping.yml"
  docker compose cp "$tmp/config.yml" wazuh.indexer:$SEC/config.yml >/dev/null 2>&1
  docker compose cp "$tmp/roles_mapping.yml" wazuh.indexer:$SEC/roles_mapping.yml >/dev/null 2>&1
  rm -rf "$tmp"
  for t in config:config.yml rolesmapping:roles_mapping.yml; do
    docker compose exec -T -u root wazuh.indexer bash -c "chown wazuh-indexer: $SEC/${t#*:} 2>/dev/null; export JAVA_HOME=$IDX/jdk; bash $IDX/plugins/opensearch-security/tools/securityadmin.sh -f $SEC/${t#*:} -t ${t%%:*} -nhnv -cacert $D/certs/root-ca.pem -cert $D/certs/admin.pem -key $D/certs/admin-key.pem -p 9200 -icl" >/dev/null 2>&1 \
      || erro "Wazuh: o indexador recusou a configuração do login único (${t#*:})"
  done
  c=$(curl -sk -o /dev/null -w '%{http_code}' -u "admin:$WAZUH_ADMIN_PASS" https://127.0.0.1:9200/ || true)
  [ "$c" = 200 ] || erro "Wazuh: o login do admin parou de funcionar depois do SAML (código $c)"

  # 3. painel: botão "Log in with single sign-on" ao lado do login com senha
  if ! grep -q 'auth.type' config/wazuh_dashboard/opensearch_dashboards.yml; then
    novo=$(cat config/wazuh_dashboard/opensearch_dashboards.yml; cat <<'EOF'
# Login único (instalador da suíte)
opensearch_security.auth.multiple_auth_enabled: true
opensearch_security.auth.type: ["basicauth","saml"]
server.xsrf.allowlist: ["/_opendistro/_security/saml/acs", "/_opendistro/_security/saml/logout", "/_opendistro/_security/saml/acs/idpinitiated"]
EOF
)
    printf '%s\n' "$novo" > config/wazuh_dashboard/opensearch_dashboards.yml
    docker compose up -d --force-recreate wazuh.dashboard >/dev/null 2>&1
  fi

  # 4. API do Wazuh: quem chega com msp-admin é administrator (um login só, sem loop)
  TK=$(curl -sk -m 10 -u "wazuh-wui:$WAZUH_API_PASS" -X POST "https://127.0.0.1:55000/security/user/authenticate?raw=true" || true)
  if [[ "$TK" == ey* ]]; then
    rid=$(curl -sk -m 10 -H "Authorization: Bearer $TK" "https://127.0.0.1:55000/security/rules?search=msp-admin" | jq -r '.data.affected_items[]|select(.name=="msp-admin")|.id' | head -1)
    [ -n "$rid" ] || rid=$(curl -sk -m 10 -H "Authorization: Bearer $TK" -H 'Content-Type: application/json' -X POST \
      "https://127.0.0.1:55000/security/rules" -d '{"name":"msp-admin","rule":{"FIND":{"backend_roles":"msp-admin"}}}' | jq -r '.data.affected_items[0].id // empty')
    [ -n "$rid" ] && curl -sk -m 10 -H "Authorization: Bearer $TK" -X POST "https://127.0.0.1:55000/security/roles/1/rules?rule_ids=$rid" >/dev/null
    [ -n "$rid" ] && ok "Wazuh: login único (msp-admin = administrador)" || aviso "Wazuh: não consegui dar o papel de administrador ao msp-admin na API"
  else aviso "Wazuh: a API não respondeu; o login único entra, mas sem permissão no app Wazuh"; fi
}
# <<< wazuh_sso

# Cria o administrador do Uptime Kuma na instalação, pelo mesmo canal que a tela
# de primeiro acesso usa. Sem isso, quem abrir o endereço primeiro vira o dono.
kuma_admin() {
  docker compose exec -T uptime-kuma node -e '
const io=require("socket.io-client");const [u,p]=process.argv.slice(1);
const s=io("http://localhost:3001",{transports:["websocket"],reconnection:false});
s.on("connect_error",e=>{console.log("ERRO "+e.message);process.exit(3)});
s.on("connect",()=>s.emit("needSetup",need=>{
  if(!need){console.log("JA_EXISTE");process.exit(0)}
  s.emit("setup",u,p,r=>{console.log(r&&r.ok?"CRIADO":"ERRO "+(r&&r.msg));process.exit(r&&r.ok?0:1)})}));
setTimeout(()=>{console.log("ERRO tempo esgotado");process.exit(2)},30000)' -- "$KUMA_USER" "$KUMA_PASS" 2>/dev/null | tr -d '\r' | tail -1
}

# Com o login único na frente (só msp-admin passa), desliga a segunda tela de login do Kuma.
kuma_sem_login() {
  docker compose exec -T uptime-kuma node -e '
const io=require("socket.io-client");const [u,p]=process.argv.slice(1);
const s=io("http://localhost:3001",{transports:["websocket"],reconnection:false});
s.on("connect_error",e=>{console.log("ERRO "+e.message);process.exit(3)});
s.on("connect",()=>s.emit("login",{username:u,password:p,token:""},r=>{
  if(!r||!r.ok){console.log("ERRO login "+(r&&r.msg));process.exit(1)}
  s.emit("getSettings",g=>{const d=(g&&g.data)||{};d.disableAuth=true;
    s.emit("setSettings",d,p,x=>{console.log(x&&x.ok?"OK":"ERRO "+(x&&x.msg));process.exit(x&&x.ok?0:1)})})}));
setTimeout(()=>{console.log("ERRO tempo esgotado");process.exit(2)},30000)' -- "$KUMA_USER" "$KUMA_PASS" 2>/dev/null | tr -d '\r' | tail -1
}

# Portal do login único: uma página com as ferramentas, servida pelo Caddy atrás do porteiro.
portal_bloco() { # $1 chave $2 url $3 nome $4 descrição $5 observação
  printf '      <a class="app" href="%s" data-k="%s"><span class="ic">%s</span><span class="tx"><b>%s</b><small>%s</small>%s</span></a>\n' \
    "$2" "$1" "$(printf '%s' "$3" | cut -c1-2)" "$3" "$4" "${5:+<em>$5</em>}"
}

gerar_portal() {
  mkdir -p "$BASE/core/portal"
  {
    cat <<EOF
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>$SSO_NOME · portal</title>
<style>
:root{--bg:#f4f5f7;--card:#fff;--tx:#14181f;--mut:#5b6472;--bd:#e3e6eb;--ac:#0f62fe;--ok:#1a7f4b}
@media (prefers-color-scheme:dark){:root{--bg:#0f1216;--card:#171b21;--tx:#e8ebf0;--mut:#9aa3b1;--bd:#262c35;--ac:#78a9ff;--ok:#42be65}}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--tx);font:16px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
header{max-width:1040px;margin:0 auto;padding:40px 20px 8px;display:flex;justify-content:space-between;align-items:flex-end;gap:16px;flex-wrap:wrap}
h1{margin:0;font-size:28px;letter-spacing:-.02em}header p{margin:4px 0 0;color:var(--mut)}
.eu{font-size:14px;color:var(--mut);text-align:right}.eu b{color:var(--tx)}.eu a{color:var(--ac);text-decoration:none}
.sel{display:inline-flex;align-items:center;gap:6px;margin-top:6px;font-size:13px;color:var(--ok)}
main{max-width:1040px;margin:0 auto;padding:16px 20px 48px}
.grade{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:14px}
.app{display:flex;gap:14px;align-items:flex-start;background:var(--card);border:1px solid var(--bd);border-radius:14px;padding:18px;color:inherit;text-decoration:none;transition:transform .12s,border-color .12s}
.app:hover{transform:translateY(-2px);border-color:var(--ac)}
.ic{flex:none;width:42px;height:42px;border-radius:10px;display:grid;place-items:center;font-weight:700;background:var(--ac);color:#fff;text-transform:uppercase;font-size:15px}
.tx{display:flex;flex-direction:column;min-width:0}.tx small{color:var(--mut)}.tx em{font-style:normal;font-size:12px;color:var(--mut);margin-top:6px}
footer{max-width:1040px;margin:0 auto;padding:0 20px 32px;color:var(--mut);font-size:13px}
</style>
</head>
<body>
<header>
  <div><h1>$SSO_NOME</h1><p>Todas as ferramentas, um login só.</p></div>
  <div class="eu">Olá, <b>{{.Req.Header.Get "X-Usuario"}}</b> · <a href="/oauth2/sign_out?rd=https%3A%2F%2F$H_SSO%2Frealms%2Fmsp%2Fprotocol%2Fopenid-connect%2Flogout">sair</a><br><span class="sel">● sessão protegida com 2FA</span></div>
</header>
<main><div class="grade">
EOF
    tem zabbix      && portal_bloco zabbix "https://$H_ZABBIX/index_sso.php" "Zabbix" "monitoramento"
    tem glpi        && portal_bloco glpi "https://$H_GLPI/" "GLPI" "helpdesk e inventário"
    tem wazuh       && portal_bloco wazuh "https://$H_WAZUH/auth/saml/login?nextUrl=%2Fapp%2Fwz-home&redirectHash=false" "Wazuh" "segurança e SIEM"
    tem nextcloud   && portal_bloco nextcloud "https://$H_ARQUIVOS/apps/user_oidc/login/1" "Nextcloud" "arquivos"
    tem vaultwarden && portal_bloco vaultwarden "https://$H_SENHAS/#/sso" "Vaultwarden" "cofre de senhas" "o cofre ainda pede a senha mestra"
    tem kuma        && portal_bloco kuma "https://$H_STATUS/" "Uptime Kuma" "página de status"
    tem tactical    && portal_bloco tactical "https://$H_RMM/" "Tactical RMM" "RMM e acesso remoto" "login próprio com 2FA"
    tem keycloak    && portal_bloco keycloak "https://$H_SSO/realms/msp/account/" "Minha conta" "senha, 2FA e sessões"
    cat <<EOF
</div></main>
<footer>Instalado pelo instalador da suíte open source para MSP.</footer>
</body>
</html>
EOF
  } > "$BASE/core/portal/index.html"
}

http_ok() { # $1 url  [$2 user:senha]
  local c
  c=$(curl -sk -o /dev/null -m 10 -w '%{http_code}' ${2:+-u "$2"} "$1" || true)
  [[ "$c" =~ ^(200|301|302|303|307|308|401)$ ]]
}

# ------------------------------------------------------------ pré-requisitos
# Com "curl ... | sudo bash" o sudo do Ubuntu 24.04 (use_pty) não deixa ler o
# terminal: as perguntas falhariam. Nesse caso, só sem perguntas (--sim).
if [ "$SIM" = 0 ] && [ ! -t 0 ]; then
  erro "este modo faz perguntas e precisa do terminal. Baixe e rode assim:
    curl -fsSLO https://www.unodata.com.br/suite-msp/instalar-suite-msp.sh && sudo bash instalar-suite-msp.sh
  ou, sem perguntas:
    curl -fsSL https://www.unodata.com.br/suite-msp/instalar-suite-msp.sh | sudo DOMINIO=msp.suaempresa.com.br EMAIL=ti@suaempresa.com.br FERRAMENTAS=todas bash -s -- --sim"
fi
[ "$(id -u)" = 0 ] || erro "rode como root: sudo bash $0"
[ -r /etc/os-release ] && . /etc/os-release
case "${ID:-}" in ubuntu|debian) ;; *) erro "sistema não suportado (${PRETTY_NAME:-desconhecido}). Use Ubuntu 22.04+ ou Debian 12+." ;; esac
[ "$(uname -m)" = x86_64 ] || erro "arquitetura $(uname -m) não suportada: o Tactical RMM e parte das imagens são só x86_64."

mkdir -p "$BASE" "$ESTADO"; chmod 700 "$BASE"
exec > >(tee -a "$LOG") 2>&1

echo
echo "${C_N}  Suíte open source para MSP · instalador $VERSAO_INSTALADOR${C_0}"
echo "  Coopetir · CoopTI Summit 2026"

# Reaproveita uma instalação anterior.
if [ -f "$ENVF" ]; then
  set -a; . "$ENVF"; set +a
  aviso "instalação anterior encontrada em $BASE: as senhas e o domínio serão reaproveitados"
fi

IP_PUBLICO=$(curl -4 -s -m 8 https://api.ipify.org || curl -4 -s -m 8 https://ifconfig.me || true)
[ -n "$IP_PUBLICO" ] || aviso "não consegui descobrir o IP público deste servidor"

# ------------------------------------------------------------- 1. ferramentas
etapa "1/4 · Quais ferramentas instalar?"
if [ -n "${FERRAMENTAS_INSTALADAS:-}" ] && [ -z "${FERRAMENTAS:-}" ]; then FERRAMENTAS="$FERRAMENTAS_INSTALADAS"; fi
if [ "$SIM" = 0 ] && [ -z "${FERRAMENTAS:-}" ]; then
  i=1; for f in "${TODAS[@]}"; do printf "    %d  %-13s %s\n" $i "${NOME[$f]}" "${DESC[$f]}"; i=$((i+1)); done
  echo
  perguntar "Digite os números separados por espaço, ou Enter para TODAS" "todas"
  FERRAMENTAS="$RESP"
fi
FERRAMENTAS="${FERRAMENTAS:-todas}"
SEL=()
if [[ "${FERRAMENTAS,,}" =~ ^(todas|all|t)$ ]]; then SEL=("${TODAS[@]}")
else
  for x in ${FERRAMENTAS//,/ }; do
    if [[ "$x" =~ ^[1-8]$ ]]; then SEL+=("${TODAS[$((x-1))]}")
    elif [[ " ${TODAS[*]} " == *" $x "* ]]; then SEL+=("$x")
    else erro "ferramenta inválida: $x"; fi
  done
fi
[ ${#SEL[@]} -gt 0 ] || erro "nenhuma ferramenta escolhida"
for f in "${SEL[@]}"; do ok "${NOME[$f]}"; done

# ---------------------------------------------------------------- 2. domínio
etapa "2/4 · Domínio"
if [ -z "${DOMINIO:-}" ]; then
  echo "    Cada ferramenta vira um subdomínio do domínio base."
  echo "    Ex.: msp.suaempresa.com.br  →  zabbix.msp.suaempresa.com.br"
  echo "    Sem domínio ainda? Deixe em branco para usar ${IP_PUBLICO:-<ip>}.sslip.io (só para teste)."
  perguntar "Domínio base" ""
  DOMINIO="${RESP,,}"
fi
if [ -z "$DOMINIO" ]; then
  [ -n "$IP_PUBLICO" ] || erro "sem domínio e sem IP público não há como emitir certificado"
  DOMINIO="${IP_PUBLICO//./-}.sslip.io"
  aviso "usando $DOMINIO: resolve sozinho para este IP, bom para testar, não para produção"
fi
DOMINIO="${DOMINIO#.}"; DOMINIO="${DOMINIO%.}"
[[ "$DOMINIO" =~ ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?\.[a-z0-9-]+$ ]] || erro "domínio inválido: $DOMINIO"

if [ -z "${EMAIL:-}" ]; then
  perguntar "E-mail para o Let's Encrypt avisar sobre certificados" "admin@$DOMINIO"
  EMAIL="$RESP"
fi
[[ "$EMAIL" =~ ^[^@[:space:]]+@[^@[:space:]]+\.[^@[:space:]]+$ ]] || erro "e-mail inválido: $EMAIL"

if tem keycloak && [ -z "${SSO_NOME:-}" ]; then
  perguntar "Nome da sua empresa (aparece no portal e na tela de login)" "Suíte MSP"
  SSO_NOME="$RESP"
fi
SSO_NOME=$(printf '%s' "${SSO_NOME:-Suíte MSP}" | tr -d '"$\\`<>&'"'")

H_SSO=${H_SSO:-sso.$DOMINIO}; H_ZABBIX=${H_ZABBIX:-zabbix.$DOMINIO}; H_GLPI=${H_GLPI:-glpi.$DOMINIO}
H_WAZUH=${H_WAZUH:-wazuh.$DOMINIO}; H_RMM=${H_RMM:-rmm.$DOMINIO}; H_API_RMM=${H_API_RMM:-api-rmm.$DOMINIO}
H_MESH_RMM=${H_MESH_RMM:-mesh-rmm.$DOMINIO}; H_ARQUIVOS=${H_ARQUIVOS:-arquivos.$DOMINIO}
H_SENHAS=${H_SENHAS:-senhas.$DOMINIO}; H_STATUS=${H_STATUS:-status.$DOMINIO}
H_PORTAL=${H_PORTAL:-portal.$DOMINIO}

HOSTS=()
tem keycloak && HOSTS+=("$H_PORTAL" "$H_SSO"); tem zabbix && HOSTS+=("$H_ZABBIX"); tem glpi && HOSTS+=("$H_GLPI")
tem wazuh && HOSTS+=("$H_WAZUH"); tem tactical && HOSTS+=("$H_RMM" "$H_API_RMM" "$H_MESH_RMM")
tem nextcloud && HOSTS+=("$H_ARQUIVOS"); tem vaultwarden && HOSTS+=("$H_SENHAS"); tem kuma && HOSTS+=("$H_STATUS")

# ------------------------------------------------------------------- 3. DNS
etapa "3/4 · Conferindo o DNS"
echo "    Cada nome abaixo precisa apontar (registro A) para ${IP_PUBLICO:-o IP deste servidor}."
echo "    Atalho: um registro curinga  *.$DOMINIO  →  ${IP_PUBLICO:-IP}  resolve todos de uma vez."
FALTA=0
for h in "${HOSTS[@]}"; do
  r=$(getent ahostsv4 "$h" 2>/dev/null | awk 'NR==1{print $1}' || true)
  if [ -n "$IP_PUBLICO" ] && [ "$r" = "$IP_PUBLICO" ]; then ok "$h → $r"
  elif [ -z "$r" ]; then aviso "$h não resolve"; FALTA=1
  else aviso "$h → $r (esperado ${IP_PUBLICO:-?})"; FALTA=1; fi
done
if [ "$FALTA" = 1 ]; then
  echo "    Sem o DNS certo o HTTPS não sai. O Caddy tenta de novo sozinho depois, então dá para"
  echo "    instalar agora e acertar o DNS em seguida."
  sim_nao "    Continuar mesmo assim?" "s" || erro "instalação cancelada para acertar o DNS"
fi

# ------------------------------------------------------------- capacidade
MEM_TOTAL=$(awk '/MemTotal/{print int($2/1024)}' /proc/meminfo)
NEC=600; for f in "${SEL[@]}"; do NEC=$((NEC + MEM[$f])); done
DISCO=$(df -Pm / | awk 'NR==2{print int($4/1024)}')
echo
echo "    Memória: ${MEM_TOTAL} MB no servidor, ~${NEC} MB estimados para o escolhido."
echo "    Disco livre: ${DISCO} GB."
if [ "$MEM_TOTAL" -lt $((NEC * 11 / 10)) ]; then
  aviso "pouca memória para essa combinação. Recomendado: 16 GB para as 8 ferramentas, 8 GB sem o Wazuh."
  sim_nao "    Continuar mesmo assim?" "n" || erro "instalação cancelada por falta de memória"
fi
[ "$DISCO" -ge 30 ] || aviso "menos de 30 GB livres: o Wazuh e o Nextcloud crescem rápido"

# ------------------------------------------------------------ 4. confirmação
etapa "4/4 · Resumo"
echo "    Domínio:     $DOMINIO"
echo "    E-mail:      $EMAIL"
echo "    Ferramentas: $(for f in "${SEL[@]}"; do printf '%s, ' "${NOME[$f]}"; done | sed 's/, $//')"
echo "    Pasta:       $BASE"
sim_nao "Instalar agora?" "s" || erro "instalação cancelada"

# ===================================================================== base
etapa "Preparando o servidor"
export DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a NEEDRESTART_SUSPEND=1
apt-get update -qq
apt-get install -y -qq ca-certificates curl git jq openssl argon2 >/dev/null
ok "pacotes básicos"

if ! type -P docker >/dev/null 2>&1; then  # type -P ignora a função docker() acima
  curl -fsSL https://get.docker.com | sh >/dev/null
  ok "Docker instalado"
else ok "Docker já presente ($(docker --version | cut -d, -f1))"; fi
systemctl enable --now docker >/dev/null 2>&1 || true
docker compose version >/dev/null 2>&1 || erro "o plugin docker compose não está disponível"

if tem wazuh; then
  sysctl -qw vm.max_map_count=262144
  echo 'vm.max_map_count=262144' > /etc/sysctl.d/99-wazuh.conf
  ok "vm.max_map_count=262144 (exigido pelo Wazuh)"
fi

# Imagens Ubuntu da OCI vêm com iptables fechando tudo menos a 22.
if iptables -S INPUT 2>/dev/null | grep -q -- '-j REJECT'; then
  for p in 80 443; do
    iptables -C INPUT -p tcp --dport $p -j ACCEPT 2>/dev/null || iptables -I INPUT 1 -p tcp --dport $p -j ACCEPT
  done
  command -v netfilter-persistent >/dev/null && netfilter-persistent save >/dev/null 2>&1 || true
  ok "portas 80 e 443 liberadas no firewall local"
fi

# ------------------------------------------------------------------- senhas
[ -n "${PG_PASS:-}" ]        || PG_PASS=$(gerar)
[ -n "${MARIA_ROOT:-}" ]     || MARIA_ROOT=$(gerar)
[ -n "${GLPI_DB_PASS:-}" ]   || GLPI_DB_PASS=$(gerar)
[ -n "${KC_ADMIN_PASS:-}" ]  || KC_ADMIN_PASS=$(gerar)
[ -n "${NC_ADMIN_PASS:-}" ]  || NC_ADMIN_PASS=$(gerar)
[ -n "${VW_ADMIN_TOKEN:-}" ] || VW_ADMIN_TOKEN=$(gerar 40)
# O Vaultwarden recomenda guardar só o hash do token (parâmetros do wiki dele).
[ -n "${VW_ADMIN_HASH:-}" ] || VW_ADMIN_HASH=$(printf '%s' "$VW_ADMIN_TOKEN" | argon2 "$(gerar 24)" -e -id -k 65540 -t 3 -p 4)
[[ "$VW_ADMIN_HASH" == \$argon2id\$* ]] || erro "não consegui gerar o hash do token do Vaultwarden"
[ -n "${GLPI_ADMIN_PASS:-}" ]   || GLPI_ADMIN_PASS=$(gerar)
[ -n "${ZABBIX_ADMIN_PASS:-}" ] || ZABBIX_ADMIN_PASS=$(gerar)
[ -n "${WAZUH_ADMIN_PASS:-}" ]  || WAZUH_ADMIN_PASS="Wz$(gerar 16)!9"   # o Wazuh exige símbolo
[ -n "${WAZUH_DASH_PASS:-}" ]  || WAZUH_DASH_PASS=$(gerar 24)
[ -n "${WAZUH_API_PASS:-}" ]   || WAZUH_API_PASS="Wa$(gerar 16)!7"     # a API exige símbolo
[ -n "${WAZUH_AGENT_PASS:-}" ] || WAZUH_AGENT_PASS=$(gerar 24)
[ -n "${KUMA_PASS:-}" ]  || KUMA_PASS=$(gerar)
KUMA_USER=${KUMA_USER:-admin}
[ -n "${TRMM_PASS:-}" ]   || TRMM_PASS=$(gerar)
[ -n "${MESH_PASS:-}" ]   || MESH_PASS=$(gerar)
[ -n "${MONGO_PASS:-}" ]  || MONGO_PASS=$(gerar)
[ -n "${TRMM_PG_PASS:-}" ] || TRMM_PG_PASS=$(gerar)
TRMM_USER=${TRMM_USER:-gestor}
# Ambiente público (demo): True desliga o terminal web e os scripts no servidor do Tactical.
TRMM_DISABLE_WEB_TERMINAL=${TRMM_DISABLE_WEB_TERMINAL:-False}
TRMM_DISABLE_SERVER_SCRIPTS=${TRMM_DISABLE_SERVER_SCRIPTS:-False}
# login único
SSO_USER=${SSO_USER:-gestor}
[ -n "${SSO_PASS:-}" ]           || SSO_PASS=$(gerar 16)
[ -n "${OIDC_PORTAL_SECRET:-}" ] || OIDC_PORTAL_SECRET=$(gerar 40)
[ -n "${OIDC_NC_SECRET:-}" ]     || OIDC_NC_SECRET=$(gerar 40)
[ -n "${OIDC_VW_SECRET:-}" ]     || OIDC_VW_SECRET=$(gerar 40)
[ -n "${OAUTH2_COOKIE_SECRET:-}" ] || OAUTH2_COOKIE_SECRET=$(openssl rand -hex 16)
[ -n "${WAZUH_SAML_KEY:-}" ]     || WAZUH_SAML_KEY=$(openssl rand -hex 32)

# Junta o que já estava instalado com o escolhido agora.
ANTES=(${FERRAMENTAS_INSTALADAS:-})
for f in "${ANTES[@]}"; do tem "$f" || SEL+=("$f"); done
FERRAMENTAS_INSTALADAS="${SEL[*]}"

umask 077
cat > "$ENVF" <<EOF
# Gerado pelo instalador da suíte em $(date '+%Y-%m-%d %H:%M'). Guarde com cuidado.
DOMINIO=$DOMINIO
EMAIL=$EMAIL
FERRAMENTAS_INSTALADAS="$FERRAMENTAS_INSTALADAS"
H_SSO=$H_SSO
H_ZABBIX=$H_ZABBIX
H_GLPI=$H_GLPI
H_WAZUH=$H_WAZUH
H_RMM=$H_RMM
H_API_RMM=$H_API_RMM
H_MESH_RMM=$H_MESH_RMM
H_ARQUIVOS=$H_ARQUIVOS
H_SENHAS=$H_SENHAS
H_STATUS=$H_STATUS
H_PORTAL=$H_PORTAL
GLPI_IMG=$GLPI_IMG
OAUTH2_IMG=$OAUTH2_IMG
SSO_NOME="$SSO_NOME"
SSO_USER=$SSO_USER
SSO_PASS=$SSO_PASS
OIDC_PORTAL_SECRET=$OIDC_PORTAL_SECRET
OIDC_NC_SECRET=$OIDC_NC_SECRET
OIDC_VW_SECRET=$OIDC_VW_SECRET
OAUTH2_COOKIE_SECRET=$OAUTH2_COOKIE_SECRET
WAZUH_SAML_KEY=$WAZUH_SAML_KEY
PG_PASS=$PG_PASS
MARIA_ROOT=$MARIA_ROOT
GLPI_DB_PASS=$GLPI_DB_PASS
KC_ADMIN_PASS=$KC_ADMIN_PASS
NC_ADMIN_PASS=$NC_ADMIN_PASS
VW_ADMIN_TOKEN=$VW_ADMIN_TOKEN
VW_ADMIN_HASH='$VW_ADMIN_HASH'
GLPI_ADMIN_PASS=$GLPI_ADMIN_PASS
ZABBIX_ADMIN_PASS=$ZABBIX_ADMIN_PASS
WAZUH_ADMIN_PASS=$WAZUH_ADMIN_PASS
WAZUH_DASH_PASS=$WAZUH_DASH_PASS
WAZUH_API_PASS=$WAZUH_API_PASS
WAZUH_AGENT_PASS=$WAZUH_AGENT_PASS
KUMA_USER=$KUMA_USER
KUMA_PASS=$KUMA_PASS
TRMM_USER=$TRMM_USER
TRMM_PASS=$TRMM_PASS
MESH_PASS=$MESH_PASS
MONGO_PASS=$MONGO_PASS
TRMM_PG_PASS=$TRMM_PG_PASS
TRMM_DISABLE_WEB_TERMINAL=$TRMM_DISABLE_WEB_TERMINAL
TRMM_DISABLE_SERVER_SCRIPTS=$TRMM_DISABLE_SERVER_SCRIPTS
EOF
umask 022
ok "senhas geradas em $ENVF"

# ============================================================ compose principal
etapa "Montando a suíte principal"
mkdir -p "$BASE/core"
cd "$BASE/core"
ln -sf ../.env .env

PRECISA_PG=0; { tem keycloak || tem zabbix || tem nextcloud; } && PRECISA_PG=1
: > initdb.sql
tem keycloak  && echo "CREATE DATABASE keycloak;"  >> initdb.sql
tem zabbix    && echo "CREATE DATABASE zabbix;"    >> initdb.sql
tem nextcloud && echo "CREATE DATABASE nextcloud;" >> initdb.sql

C=docker-compose.yml
cat > $C <<'EOF'
# Suíte open source para MSP · gerado pelo instalador
# Cada ferramenta publica só em 127.0.0.1; o Caddy (rede do host) faz o HTTPS.
name: suite
services:
EOF
VOLS="caddy_data: {}, caddy_config: {}"

if [ "$PRECISA_PG" = 1 ]; then cat >> $C <<'EOF'
  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: suite
      POSTGRES_PASSWORD: ${PG_PASS}
    volumes:
      - pg:/var/lib/postgresql/data
      - ./initdb.sql:/docker-entrypoint-initdb.d/initdb.sql:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U suite"]
      interval: 10s
      retries: 30

EOF
VOLS="$VOLS, pg: {}"; fi

if tem glpi; then cat >> $C <<'EOF'
  mariadb:
    image: mariadb:11
    restart: unless-stopped
    environment:
      MARIADB_ROOT_PASSWORD: ${MARIA_ROOT}
      MARIADB_DATABASE: glpi
      MARIADB_USER: glpi
      MARIADB_PASSWORD: ${GLPI_DB_PASS}
    volumes: [maria:/var/lib/mysql]
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 10s
      retries: 30

  glpi:
    image: ${GLPI_IMG}
    restart: unless-stopped
    environment:
      GLPI_DB_HOST: mariadb
      GLPI_DB_PORT: 3306
      GLPI_DB_NAME: glpi
      GLPI_DB_USER: glpi
      GLPI_DB_PASSWORD: ${GLPI_DB_PASS}
    # glpi-sso.conf: o Apache transforma o cabeçalho do porteiro em REMOTE_USER,
    # a variável que o GLPI 12 aceita para login externo (as HTTP_* ele marca como inseguras).
    volumes: [glpi:/var/glpi, ./glpi-sso.conf:/etc/apache2/conf-enabled/zzz-sso.conf:ro]
    ports: ["127.0.0.1:8083:80"]
    depends_on: {mariadb: {condition: service_healthy}}

EOF
VOLS="$VOLS, maria: {}, glpi: {}"; fi

if tem keycloak; then cat >> $C <<'EOF'
  keycloak:
    image: quay.io/keycloak/keycloak:26.0
    restart: unless-stopped
    command: start
    environment:
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
      KC_DB_USERNAME: suite
      KC_DB_PASSWORD: ${PG_PASS}
      KC_HOSTNAME: https://${H_SSO}
      KC_HTTP_ENABLED: "true"
      KC_PROXY_HEADERS: xforwarded
      KC_BOOTSTRAP_ADMIN_USERNAME: admin
      KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_ADMIN_PASS}
    ports: ["127.0.0.1:8081:8080"]
    depends_on: {postgres: {condition: service_healthy}}

EOF
fi

if tem zabbix; then cat >> $C <<'EOF'
  zabbix-server:
    image: zabbix/zabbix-server-pgsql:alpine-7.0-latest
    restart: unless-stopped
    environment:
      DB_SERVER_HOST: postgres
      POSTGRES_USER: suite
      POSTGRES_PASSWORD: ${PG_PASS}
      POSTGRES_DB: zabbix
    ports: ["10051:10051"]
    depends_on: {postgres: {condition: service_healthy}}

  zabbix-web:
    image: zabbix/zabbix-web-nginx-pgsql:alpine-7.0-latest
    restart: unless-stopped
    environment:
      ZBX_SERVER_HOST: zabbix-server
      DB_SERVER_HOST: postgres
      POSTGRES_USER: suite
      POSTGRES_PASSWORD: ${PG_PASS}
      POSTGRES_DB: zabbix
      PHP_TZ: America/Sao_Paulo
      # login único (SAML): o Zabbix está atrás do Caddy
      ZBX_SSO_SETTINGS: '{"baseurl":"https://${H_ZABBIX}","use_proxy_headers":true}'
    volumes: ["./zabbix-saml:/etc/zabbix/web/certs:ro"]
    ports: ["127.0.0.1:8082:8080"]
    depends_on: [zabbix-server]

  # Agente para o host "Zabbix server" (sem ele, o primeiro login já abre com alerta vermelho).
  zabbix-agent:
    image: zabbix/zabbix-agent2:alpine-7.0-latest
    restart: unless-stopped
    environment:
      ZBX_HOSTNAME: Zabbix server
      ZBX_SERVER_HOST: zabbix-server
    depends_on: [zabbix-server]

EOF
fi

if tem nextcloud; then cat >> $C <<'EOF'
  nextcloud:
    image: nextcloud:31-apache
    restart: unless-stopped
    environment:
      POSTGRES_HOST: postgres
      POSTGRES_DB: nextcloud
      POSTGRES_USER: suite
      POSTGRES_PASSWORD: ${PG_PASS}
      NEXTCLOUD_ADMIN_USER: admin
      NEXTCLOUD_ADMIN_PASSWORD: ${NC_ADMIN_PASS}
      NEXTCLOUD_TRUSTED_DOMAINS: ${H_ARQUIVOS}
      OVERWRITEPROTOCOL: https
      OVERWRITEHOST: ${H_ARQUIVOS}
      TRUSTED_PROXIES: 172.16.0.0/12 127.0.0.1
    volumes: [nextcloud:/var/www/html]
    ports: ["127.0.0.1:8084:80"]
    depends_on: {postgres: {condition: service_healthy}}

  # Tarefas de fundo do Nextcloud (limpeza, versões, avisos). Sem isto elas nunca rodam.
  nextcloud-cron:
    image: nextcloud:31-apache
    restart: unless-stopped
    entrypoint: /cron.sh
    volumes: [nextcloud:/var/www/html]
    depends_on: [nextcloud]

EOF
VOLS="$VOLS, nextcloud: {}"; fi

if tem vaultwarden; then cat >> $C <<'EOF'
  vaultwarden:
    image: vaultwarden/server:latest
    restart: unless-stopped
    environment:
      DOMAIN: https://${H_SENHAS}
      SIGNUPS_ALLOWED: "false"
      ADMIN_TOKEN: ${VW_ADMIN_HASH}   # só o hash Argon2; você entra com o token de CREDENCIAIS.txt
EOF
if tem keycloak; then cat >> $C <<'EOF'
      # login único: a conta entra pelo Keycloak; o cofre continua pedindo a senha mestra
      SSO_ENABLED: "true"
      SSO_AUTHORITY: https://${H_SSO}/realms/msp
      SSO_CLIENT_ID: vaultwarden
      SSO_CLIENT_SECRET: ${OIDC_VW_SECRET}
      SSO_SCOPES: "email profile offline_access"
      SSO_PKCE: "true"
EOF
fi
cat >> $C <<'EOF'
    volumes: [vaultwarden:/data]
    ports: ["127.0.0.1:8085:80"]

EOF
VOLS="$VOLS, vaultwarden: {}"; fi

if tem kuma; then cat >> $C <<'EOF'
  uptime-kuma:
    image: louislam/uptime-kuma:1
    restart: unless-stopped
    volumes: [kuma:/app/data]
    ports: ["127.0.0.1:8086:3001"]

EOF
VOLS="$VOLS, kuma: {}"; fi

if tem keycloak; then cat >> $C <<'EOF'
  # Porteiro do login único: protege o portal, o GLPI e o Uptime Kuma, que não
  # falam com o Keycloak sozinhos. O Caddy pergunta a ele antes de deixar passar.
  oauth2-proxy:
    image: ${OAUTH2_IMG}
    restart: unless-stopped
    environment:
      OAUTH2_PROXY_PROVIDER: keycloak-oidc
      OAUTH2_PROXY_OIDC_ISSUER_URL: https://${H_SSO}/realms/msp
      OAUTH2_PROXY_CLIENT_ID: portal
      OAUTH2_PROXY_CLIENT_SECRET: ${OIDC_PORTAL_SECRET}
      OAUTH2_PROXY_COOKIE_SECRET: ${OAUTH2_COOKIE_SECRET}
      OAUTH2_PROXY_COOKIE_DOMAINS: .${DOMINIO}
      OAUTH2_PROXY_WHITELIST_DOMAINS: .${DOMINIO}
      OAUTH2_PROXY_REDIRECT_URL: https://${H_PORTAL}/oauth2/callback
      OAUTH2_PROXY_EMAIL_DOMAINS: "*"
      OAUTH2_PROXY_CODE_CHALLENGE_METHOD: S256
      OAUTH2_PROXY_REVERSE_PROXY: "true"
      OAUTH2_PROXY_SET_XAUTHREQUEST: "true"
      OAUTH2_PROXY_SKIP_PROVIDER_BUTTON: "true"
      # sessão do porteiro: confere com o Keycloak a cada hora (usuário desativado cai)
      OAUTH2_PROXY_COOKIE_EXPIRE: 12h
      OAUTH2_PROXY_COOKIE_REFRESH: 1h
      OAUTH2_PROXY_HTTP_ADDRESS: 0.0.0.0:4180
    ports: ["127.0.0.1:4180:4180"]

EOF
fi

cat >> $C <<EOF
  caddy:
    image: caddy:2
    restart: unless-stopped
    network_mode: host
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - ./portal:/srv/portal:ro
      - caddy_data:/data
      - caddy_config:/config

volumes: {$VOLS}
EOF

# Caddyfile
{
  printf '{\n\temail %s\n}\n' "$EMAIL"
  # HSTS em todos os endereços e compressão (o painel do Tactical é um JS de 3,6 MB sem gzip)
  HSTS='\theader ?Strict-Transport-Security "max-age=31536000"\n\tencode zstd gzip\n'
  px() { printf "%s {\n$HSTS\treverse_proxy 127.0.0.1:%s\n}\n" "$1" "$2"; }
  # Login único pelo porteiro (oauth2-proxy). $1 = filtro de grupo, $2 = cabeçalhos copiados
  O2='\thandle /oauth2/* {\n\t\treverse_proxy 127.0.0.1:4180 {\n\t\t\theader_up X-Real-IP {remote_host}\n\t\t\theader_up X-Forwarded-Uri {uri}\n\t\t}\n\t}\n'
  porteiro() {
    printf '\t\tforward_auth 127.0.0.1:4180 {\n\t\t\turi /oauth2/auth%s\n\t\t\tcopy_headers %s\n\t\t\t@fora status 401\n\t\t\thandle_response @fora {\n\t\t\t\tredir * https://%s/oauth2/start?rd={scheme}://{host}{uri} 302\n\t\t\t}\n\t\t}\n' "$1" "$2" "$H_PORTAL"
  }
  tem keycloak    && px "$H_SSO" 8081
  tem zabbix      && px "$H_ZABBIX" 8082
  tem nextcloud   && px "$H_ARQUIVOS" 8084
  tem vaultwarden && px "$H_SENHAS" 8085
  if tem keycloak; then
    printf "%s {\n$HSTS$O2\thandle {\n" "$H_PORTAL"
    porteiro "" "X-Auth-Request-Preferred-Username>X-Usuario X-Auth-Request-Email>X-Email"
    printf '\t\troot * /srv/portal\n\t\ttemplates\n\t\tfile_server\n\t}\n}\n'
  fi
  if tem glpi && tem keycloak; then
    # O GLPI confia no REMOTE_USER que o Apache monta a partir do X-Glpi-Usuario, que só o
    # porteiro preenche (o Caddy apaga o que vier de fora). Inventário
    # e API seguem livres porque agentes e integrações não fazem login no navegador.
    printf "%s {\n$HSTS\trequest_header -Remote-User\n\trequest_header -X-Glpi-Usuario\n$O2" "$H_GLPI"
    printf '\t@livre path /front/inventory.php* /api.php* /apirest.php* /api/* /marketplace/glpiinventory/* /plugins/glpiinventory/*\n\thandle @livre {\n\t\treverse_proxy 127.0.0.1:8083\n\t}\n\thandle {\n'
    porteiro "" "X-Auth-Request-Preferred-Username>X-Glpi-Usuario"
    printf '\t\treverse_proxy 127.0.0.1:8083\n\t}\n}\n'
  elif tem glpi; then px "$H_GLPI" 8083; fi
  if tem kuma && tem keycloak; then
    # Páginas de status e os pings dos monitores ficam públicos; o painel só para msp-admin.
    printf "%s {\n$HSTS$O2" "$H_STATUS"
    printf '\t@publico path /status/* /api/push/* /api/badge/* /api/status-page/* /assets/* /upload/* /icon.svg /favicon.ico /manifest.json /apple-touch-icon.png\n\thandle @publico {\n\t\treverse_proxy 127.0.0.1:8086\n\t}\n\thandle {\n'
    porteiro "?allowed_groups=msp-admin" "X-Auth-Request-Preferred-Username>X-Usuario"
    printf '\t\treverse_proxy 127.0.0.1:8086\n\t}\n}\n'
  elif tem kuma; then px "$H_STATUS" 8086; fi
  if tem wazuh; then
    printf "%s {\n$HSTS"'\treverse_proxy https://127.0.0.1:5601 {\n\t\ttransport http {\n\t\t\ttls_insecure_skip_verify\n\t\t}\n\t}\n}\n' "$H_WAZUH"
  fi
  if tem tactical; then
    printf "%s, %s, %s {\n$HSTS"'\treverse_proxy https://127.0.0.1:4443 {\n\t\theader_up Host {host}\n\t\ttransport http {\n\t\t\ttls_insecure_skip_verify\n\t\t\ttls_server_name {host}\n\t\t}\n\t}\n}\n' "$H_RMM" "$H_API_RMM" "$H_MESH_RMM"
  fi
  # Blocos próprios do administrador, preservados entre execuções.
  [ -f Caddyfile.extra ] && cat Caddyfile.extra
} > Caddyfile
mkdir -p portal zabbix-saml
if tem keycloak; then
  printf '%s\n' '# Login único: o Caddy apaga qualquer X-Glpi-Usuario vindo de fora; só o porteiro preenche.' \
    'SetEnvIf X-Glpi-Usuario "^([A-Za-z0-9._@-]+)$" REMOTE_USER=$1' > glpi-sso.conf
else echo '# sem login único' > glpi-sso.conf; fi
[ -f portal/index.html ] || echo '<!doctype html><meta charset="utf-8"><title>Portal</title><p>Configurando o login único…</p>' > portal/index.html

docker compose config -q || erro "o docker-compose.yml gerado é inválido"
echo "    baixando imagens (pode levar alguns minutos)..."
puxar
docker compose up -d --remove-orphans
# O Caddyfile é um arquivo montado: o "up" não percebe que ele mudou. Sem o reload,
# uma ferramenta acrescentada numa segunda execução ficaria sem endereço.
for t in 1 2 3 4 5 6; do docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile >/dev/null 2>&1 && break; sleep 5; done
ok "suíte principal no ar"

# =================================================================== Wazuh
if tem wazuh; then
  etapa "Wazuh (SIEM)"
  W=$BASE/wazuh-docker/single-node
  if [ ! -d "$BASE/wazuh-docker" ]; then
    git -c advice.detachedHead=false clone -q -b "$WAZUH_REPO_TAG" --depth 1 https://github.com/wazuh/wazuh-docker.git "$BASE/wazuh-docker"
  fi
  if [ -z "${WAZUH_IMG:-}" ]; then
    for v in $WAZUH_VERSOES; do
      if docker manifest inspect "wazuh/wazuh-manager:$v" >/dev/null 2>&1; then WAZUH_IMG=$v; break; fi
    done
  fi
  [ -n "${WAZUH_IMG:-}" ] || erro "nenhuma imagem do Wazuh encontrada para: $WAZUH_VERSOES"
  ok "versão $WAZUH_IMG"
  cd "$W"
  if [ ! -f "$ESTADO/wazuh-compose" ]; then
    sed -i -E "s#(wazuh/wazuh-(manager|indexer|dashboard)):[0-9.]+#\1:$WAZUH_IMG#" docker-compose.yml
    sed -i 's/- 443:5601/- "127.0.0.1:5601:5601"/; s/- "9200:9200"/- "127.0.0.1:9200:9200"/; s/- "55000:55000"/- "127.0.0.1:55000:55000"/' docker-compose.yml
    touch "$ESTADO/wazuh-compose"
  fi
  if [ ! -f config/wazuh_indexer_ssl_certs/root-ca.pem ]; then
    docker compose -f generate-indexer-certs.yml run --rm generator >/dev/null 2>&1
    ok "certificados internos gerados"
  fi
  puxar
  docker compose up -d
  if [ ! -f "$ESTADO/wazuh-usuarios" ]; then
    wazuh_endurecer
    touch "$ESTADO/wazuh-usuarios"
  fi
  # Cadastro de agentes com senha (vale também para instalações antigas).
  if ! docker compose exec -T wazuh.manager grep -q '<use_password>yes</use_password>' /var/ossec/etc/ossec.conf 2>/dev/null; then
    aviso "o cadastro de agentes do Wazuh não exige senha: rode o instalador de novo"
  fi
  ok "Wazuh no ar"
fi

# ============================================================ Tactical RMM
if tem tactical; then
  etapa "Tactical RMM"
  T=$BASE/tactical; mkdir -p "$T"; cd "$T"
  [ -f docker-compose.yml ] || curl -fsSL -o docker-compose.yml "https://raw.githubusercontent.com/amidaware/tacticalrmm/$TACTICAL_REF/docker/docker-compose.yml"
  # A rede "proxy" do Tactical vem fixa em 172.20.0.0/24, faixa que o Docker também
  # distribui sozinho: as outras redes do próprio Tactical podem pegá-la antes e o
  # "up" falha com "Pool overlaps". Movida para uma faixa fora do pool automático.
  sed -i 's#172\.20\.0\.0/24#10.213.20.0/24#; s#172\.20\.0\.20#10.213.20.20#' docker-compose.yml
  grep -q '10.213.20.0/24' docker-compose.yml || aviso "não achei a rede fixa do Tactical para ajustar (o compose de origem mudou?)"
  # O MeshCentral procura o nginx em NGINX_HOST_IP, que por padrão é o 172.20.0.20 antigo.
  grep -q 'NGINX_HOST_IP' docker-compose.yml || \
    sed -i '/MESH_PERSISTENT_CONFIG: \${MESH_PERSISTENT_CONFIG}/a\      NGINX_HOST_IP: 10.213.20.20' docker-compose.yml
  grep -q 'NGINX_HOST_IP: 10.213.20.20' docker-compose.yml || erro "não consegui ajustar o NGINX_HOST_IP do MeshCentral"
  umask 077
  cat > .env <<EOF
IMAGE_REPO=tacticalrmm/
VERSION=latest
TRMM_USER=$TRMM_USER
TRMM_PASS=$TRMM_PASS
TRMM_HTTP_PORT=127.0.0.1:8088
TRMM_HTTPS_PORT=127.0.0.1:4443
APP_HOST=$H_RMM
API_HOST=$H_API_RMM
MESH_HOST=$H_MESH_RMM
CSRF_COOKIE_DOMAIN=$DOMINIO
SESSION_COOKIE_DOMAIN=$DOMINIO
MESH_USER=meshadmin
MESH_PASS=$MESH_PASS
MONGODB_USER=mongouser
MONGODB_PASSWORD=$MONGO_PASS
MESH_PERSISTENT_CONFIG=0
POSTGRES_USER=trmm
POSTGRES_PASS=$TRMM_PG_PASS
TRMM_DISABLE_WEB_TERMINAL=$TRMM_DISABLE_WEB_TERMINAL
TRMM_DISABLE_SERVER_SCRIPTS=$TRMM_DISABLE_SERVER_SCRIPTS
TRMM_DISABLE_SSO=False
EOF
  umask 022
  puxar
  docker compose up -d
  ok "Tactical RMM no ar (o primeiro início leva alguns minutos)"
fi

# ======================================================= trocar senhas padrão
cd "$BASE/core"
if tem zabbix && [ ! -f "$ESTADO/zabbix-senha" ]; then
  etapa "Zabbix: trocando a senha padrão"
  Z=http://127.0.0.1:8082/api_jsonrpc.php
  zlogin() { curl -s -m 10 -H 'Content-Type: application/json-rpc' $Z \
    -d "{\"jsonrpc\":\"2.0\",\"method\":\"user.login\",\"params\":{\"username\":\"Admin\",\"password\":\"$1\"},\"id\":1}" | jq -r '.result // empty'; }
  zpronto() { [ -n "$(zlogin zabbix)" ]; }
  if esperar "o Zabbix criar o banco" 600 zpronto; then
    TK=$(zlogin zabbix)
    curl -s -H 'Content-Type: application/json-rpc' -H "Authorization: Bearer $TK" $Z \
      -d "{\"jsonrpc\":\"2.0\",\"method\":\"user.update\",\"params\":{\"userid\":\"1\",\"current_passwd\":\"zabbix\",\"passwd\":\"$ZABBIX_ADMIN_PASS\"},\"id\":2}" >/dev/null
    if [ -n "$(zlogin "$ZABBIX_ADMIN_PASS")" ]; then touch "$ESTADO/zabbix-senha"; ok "senha do Admin trocada"
      TK=$(zlogin "$ZABBIX_ADMIN_PASS")
      IFID=$(curl -s -H 'Content-Type: application/json-rpc' -H "Authorization: Bearer $TK" $Z \
        -d '{"jsonrpc":"2.0","method":"hostinterface.get","params":{"output":["interfaceid"],"filter":{"type":1},"hostids":"10084"},"id":3}' | jq -r '.result[0].interfaceid // empty')
      if [ -n "$IFID" ]; then
        curl -s -H 'Content-Type: application/json-rpc' -H "Authorization: Bearer $TK" $Z \
          -d "{\"jsonrpc\":\"2.0\",\"method\":\"hostinterface.update\",\"params\":{\"interfaceid\":\"$IFID\",\"useip\":0,\"dns\":\"zabbix-agent\"},\"id\":4}" >/dev/null
        ok "host Zabbix server monitorado pelo agente"
      fi
    else aviso "não consegui confirmar a troca: troque a senha do Admin (padrão: zabbix) no primeiro acesso"; fi
  elif [ -n "$(zlogin "$ZABBIX_ADMIN_PASS")" ]; then touch "$ESTADO/zabbix-senha"; ok "senha já estava trocada"
  else aviso "o Zabbix ainda não respondeu: troque a senha do Admin (padrão: zabbix) no primeiro acesso"; fi
fi

if tem glpi && [ ! -f "$ESTADO/glpi-senha" ]; then
  etapa "GLPI: trocando a senha padrão"
  gpronto() { docker compose exec -T mariadb mariadb -uroot -p"$MARIA_ROOT" glpi -Nse "SELECT COUNT(*) FROM glpi_users WHERE name='glpi'" 2>/dev/null | grep -q '^1$'; }
  if esperar "o GLPI instalar o banco" 600 gpronto; then
    HG=$(docker compose exec -T glpi php -r 'echo password_hash($argv[1], PASSWORD_DEFAULT);' -- "$GLPI_ADMIN_PASS" | tr -d '\r')
    [[ "$HG" == \$2* ]] || erro "não consegui gerar o hash da senha do GLPI"
    docker compose exec -T mariadb mariadb -uroot -p"$MARIA_ROOT" glpi -e \
      "UPDATE glpi_users SET password='$HG' WHERE name='glpi'; UPDATE glpi_users SET is_active=0 WHERE name IN ('tech','normal','post-only');"
    touch "$ESTADO/glpi-senha"
    ok "senha do usuário glpi trocada; tech, normal e post-only desativados"
  else aviso "o GLPI ainda não terminou: troque a senha do glpi (padrão: glpi) e desative tech, normal e post-only"; fi
fi

if tem nextcloud && [ ! -f "$ESTADO/nextcloud-ajustes" ]; then
  etapa "Nextcloud: ajustes de pós-instalação"
  occ() { docker compose exec -T -u www-data nextcloud php occ "$@"; }
  nc_ok() { occ status --output=json 2>/dev/null | grep -q '"installed":true'; }
  if esperar "o Nextcloud terminar a instalação" 600 nc_ok; then
    occ background:cron >/dev/null                                                  # tarefas pelo contêiner nextcloud-cron
    occ config:system:set maintenance_window_start --type=integer --value=5 >/dev/null   # 5h UTC = 2h em Brasília
    occ config:system:set default_phone_region --value=BR >/dev/null
    occ db:add-missing-indices >/dev/null 2>&1 || true
    occ maintenance:repair --include-expensive >/dev/null 2>&1 || true
    touch "$ESTADO/nextcloud-ajustes"
    ok "tarefas de fundo pelo cron, janela de manutenção às 2h, índices e região BR"
  else aviso "o Nextcloud ainda não terminou de instalar: rode o instalador de novo daqui a pouco"; fi
fi

if tem glpi; then
  # O GLPI grava http://localhost como endereço e gera links quebrados em e-mails e notificações.
  docker compose exec -T mariadb mariadb -uroot -p"$MARIA_ROOT" glpi -e \
    "UPDATE glpi_configs SET value='https://$H_GLPI' WHERE name='url_base' AND context='core';
     UPDATE glpi_configs SET value='pt_BR' WHERE name='language' AND context='core' AND value='en_GB';" 2>/dev/null || true
fi

if tem kuma && [ ! -f "$ESTADO/kuma-admin" ]; then
  etapa "Uptime Kuma: criando o administrador"
  kuma_ok() { http_ok http://127.0.0.1:8086/; }
  esperar "o Uptime Kuma" 300 kuma_ok || true
  r=$(kuma_admin || true)
  case "$r" in
    CRIADO)    touch "$ESTADO/kuma-admin"; ok "administrador $KUMA_USER criado" ;;
    JA_EXISTE) touch "$ESTADO/kuma-admin" "$ESTADO/kuma-admin-externo"; ok "o administrador já tinha sido criado antes" ;;
    *) aviso "não consegui criar o administrador ($r): abra o endereço e crie AGORA" ;;
  esac
fi

# ============================================================ login único
if tem keycloak; then
  etapa "Login único: Keycloak, portal e ferramentas"
  cd "$BASE/core"
  kc() { # kcadm do Keycloak, sem o falatório "Created new ..." e sem perder os erros
    local r=0 e; e=$(mktemp)
    docker compose --project-directory "$BASE/core" exec -T keycloak /opt/keycloak/bin/kcadm.sh "$@" 2>"$e" || r=$?
    grep -Ev '^(Created new .*|)$' "$e" >&2 || true; rm -f "$e"; return $r
  }
  kc_login() { kc config credentials --server http://localhost:8080 --realm master --user admin --password "$KC_ADMIN_PASS" >/dev/null 2>&1; }
  kc_cid() { kc get clients -r msp -q "clientId=$1" 2>/dev/null | jq -r '.[0].id // empty'; }
  kc_gid() { kc get groups -r msp -q "search=$1" -q exact=true 2>/dev/null | jq -r --arg n "$1" '.[]|select(.name==$n)|.id' | head -1; }
  # Cliente OIDC confidencial: $1 clientId  $2 segredo  $3 JSON de redirectUris  $4 origem
  kc_oidc() {
    local id; id=$(kc_cid "$1")
    if [ -z "$id" ]; then
      id=$(kc create clients -r msp -i -s "clientId=$1" -s protocol=openid-connect -s publicClient=false \
        -s clientAuthenticatorType=client-secret -s "secret=$2" -s standardFlowEnabled=true \
        -s directAccessGrantsEnabled=false -s "redirectUris=$3" -s "webOrigins=[\"$4\"]" \
        -s 'attributes."post.logout.redirect.uris"=+')
    else
      kc update clients/$id -r msp -s "secret=$2" -s "redirectUris=$3" -s "webOrigins=[\"$4\"]" >/dev/null
    fi
    echo "$id"
  }
  # Mapeador, só se ainda não existir: $1 id do cliente  $2 nome  $3.. -s campos
  kc_mapper() {
    local id=$1 n=$2; shift 2
    kc get clients/$id/protocol-mappers/models -r msp | jq -e --arg n "$n" 'any(.[]; .name==$n)' >/dev/null 2>&1 && return 0
    kc create clients/$id/protocol-mappers/models -r msp -s "name=$n" "$@" >/dev/null
  }

  # O escopo padrão role_list manda um atributo "Role" por papel, com o mesmo nome
  # repetido; o Zabbix recusa a resposta inteira ("duplicated Name"). Sai dos clientes SAML.
  kc_sem_role_list() {
    local sid; sid=$(kc get clients/$1/default-client-scopes -r msp | jq -r '.[]|select(.name=="role_list")|.id')
    [ -z "$sid" ] || kc delete clients/$1/default-client-scopes/$sid -r msp >/dev/null
  }
  esperar "o Keycloak" 300 kc_login || erro "não consegui entrar no Keycloak como admin"

  # ---- reino "msp": política de senha, bloqueio por força bruta e 2FA obrigatório
  if ! kc get realms/msp >/dev/null 2>&1; then
    kc create realms -s realm=msp -s enabled=true -s bruteForceProtected=true -s failureFactor=10 \
      -s internationalizationEnabled=true -s 'supportedLocales=["pt-BR","en"]' -s defaultLocale=pt-BR \
      -s loginWithEmailAllowed=true -s duplicateEmailsAllowed=false \
      -s 'passwordPolicy=length(12) and notUsername and notEmail' \
      -s accessTokenLifespan=600 -s ssoSessionIdleTimeout=28800 -s ssoSessionMaxLifespan=86400 >/dev/null
  fi
  kc update realms/msp -s "displayName=$SSO_NOME" -s "displayNameHtml=<b>$SSO_NOME</b>" >/dev/null
  kc update authentication/required-actions/CONFIGURE_TOTP -r msp -s enabled=true -s defaultAction=true >/dev/null
  # Não mexer no fluxo "browser": atualizar uma etapa com JSON parcial zera a ordem e o
  # Keycloak passa a pedir o código ANTES do usuário (medido em 08/11/2026). A ação
  # obrigatória acima já faz todo usuário novo configurar o 2FA no primeiro login.
  for g in msp-admin msp-tecnico; do [ -n "$(kc_gid $g)" ] || kc create groups -r msp -s name=$g >/dev/null; done
  GA=$(kc_gid msp-admin)
  # Todo cliente recebe a lista de grupos (msp-admin, msp-tecnico) no token.
  if ! kc get client-scopes -r msp | jq -e 'any(.[]; .name=="grupos")' >/dev/null; then
    sc=$(kc create client-scopes -r msp -i -s name=grupos -s protocol=openid-connect -s 'attributes."include.in.token.scope"=false')
    kc create client-scopes/$sc/protocol-mappers/models -r msp -s name=groups -s protocol=openid-connect \
      -s protocolMapper=oidc-group-membership-mapper -s 'config."claim.name"=groups' -s 'config."full.path"=false' \
      -s 'config."id.token.claim"=true' -s 'config."access.token.claim"=true' -s 'config."userinfo.token.claim"=true' >/dev/null
    kc update default-default-client-scopes/$sc -r msp >/dev/null
  fi
  ok "reino msp: 2FA por aplicativo obrigatório, senha de 12+ caracteres, bloqueio após 10 erros"

  # ---- primeiro administrador
  U=$(kc get users -r msp -q "username=$SSO_USER" -q exact=true | jq -r '.[0].id // empty')
  if [ -z "$U" ]; then
    U=$(kc create users -r msp -i -s "username=$SSO_USER" -s "email=$EMAIL" -s emailVerified=true -s enabled=true \
      -s firstName=Gestor -s "lastName=$SSO_NOME" -s 'requiredActions=["CONFIGURE_TOTP"]')
    kc set-password -r msp --username "$SSO_USER" --new-password "$SSO_PASS" >/dev/null
  fi
  kc update users/$U/groups/$GA -r msp -s realm=msp -s userId=$U -s groupId=$GA -n >/dev/null
  ok "usuário $SSO_USER no grupo msp-admin"

  # ---- portal (porteiro oauth2-proxy)
  id=$(kc_oidc portal "$OIDC_PORTAL_SECRET" "[\"https://$H_PORTAL/oauth2/callback\"]" "https://$H_PORTAL")
  kc_mapper $id audiencia -s protocol=openid-connect -s protocolMapper=oidc-audience-mapper \
    -s 'config."included.client.audience"=portal' -s 'config."access.token.claim"=true' -s 'config."id.token.claim"=true'
  kc update clients/$id -r msp -s "baseUrl=https://$H_PORTAL/" -s "rootUrl=https://$H_PORTAL" >/dev/null

  # ---- Nextcloud (OIDC, app user_oidc). Admin do Nextcloud = grupo msp-admin.
  if tem nextcloud; then
    id=$(kc_oidc nextcloud "$OIDC_NC_SECRET" "[\"https://$H_ARQUIVOS/apps/user_oidc/code\",\"https://$H_ARQUIVOS/index.php/apps/user_oidc/code\"]" "https://$H_ARQUIVOS")
    kc get clients/$id/roles/admin -r msp >/dev/null 2>&1 || kc create clients/$id/roles -r msp -s name=admin >/dev/null
    kc add-roles -r msp --gid "$GA" --cclientid nextcloud --rolename admin >/dev/null 2>&1 || true
    kc_mapper $id nc_groups -s protocol=openid-connect -s protocolMapper=oidc-usermodel-client-role-mapper \
      -s 'config."claim.name"=nc_groups' -s 'config."usermodel.clientRoleMapping.clientId"=nextcloud' \
      -s 'config.multivalued=true' -s 'config."id.token.claim"=true' -s 'config."access.token.claim"=true' -s 'config."userinfo.token.claim"=true'
    occ() { docker compose exec -T -u www-data nextcloud php occ "$@"; }
    occ app:install user_oidc >/dev/null 2>&1 || occ app:enable user_oidc >/dev/null 2>&1 || true
    occ user_oidc:provider keycloak --clientid=nextcloud --clientsecret="$OIDC_NC_SECRET" \
      --discoveryuri="https://$H_SSO/realms/msp/.well-known/openid-configuration" \
      --unique-uid=0 --mapping-uid=preferred_username --mapping-display-name=name --mapping-email=email \
      --group-provisioning=1 --mapping-groups=nc_groups --scope="openid email profile" >/dev/null
    NC_PROV=$(occ user_oidc:provider --output=json 2>/dev/null | jq -r '.[]|select(.identifier=="keycloak")|.id' | head -1)
    [ -n "$NC_PROV" ] && ok "Nextcloud: botão de login único" || aviso "Nextcloud: não consegui confirmar o login único"
  fi

  # ---- Vaultwarden (OIDC nativo; a variável já está no compose)
  if tem vaultwarden; then
    kc_oidc vaultwarden "$OIDC_VW_SECRET" "[\"https://$H_SENHAS/identity/connect/oidc-signin\"]" "https://$H_SENHAS" >/dev/null
    docker compose restart vaultwarden >/dev/null 2>&1 || true
    # Cadastro fechado: sem convite, nem o login único cria a conta. Convida o gestor.
    vj=$(mktemp)
    if esperar "o Vaultwarden" 120 http_ok http://127.0.0.1:8085/alive \
       && curl -s -m 20 -c "$vj" -o /dev/null --data-urlencode "token=$VW_ADMIN_TOKEN" http://127.0.0.1:8085/admin; then
      c=$(curl -s -m 20 -b "$vj" -o /dev/null -w '%{http_code}' -H 'Content-Type: application/json' \
        -d "{\"email\":\"$EMAIL\"}" http://127.0.0.1:8085/admin/invite || true)
      [[ "$c" =~ ^(200|409)$ ]] || aviso "Vaultwarden: não consegui convidar $EMAIL (código $c); convide pelo /admin"
    fi
    rm -f "$vj"
    ok "Vaultwarden: login único (o cofre ainda pede a senha mestra, que é quem cifra as senhas)"
  fi

  # ---- Zabbix (SAML). Grupo msp-admin vira Super admin; msp-tecnico vira Admin.
  if tem zabbix; then
    id=$(kc_cid zabbix)
    if [ -z "$id" ]; then
      id=$(kc create clients -r msp -i -s clientId=zabbix -s protocol=saml -s "baseUrl=https://$H_ZABBIX/index_sso.php" \
        -s "redirectUris=[\"https://$H_ZABBIX/*\"]" \
        -s "attributes.\"saml_assertion_consumer_url_post\"=https://$H_ZABBIX/index_sso.php?acs" \
        -s "attributes.\"saml_single_logout_service_url_redirect\"=https://$H_ZABBIX/index_sso.php?sls" \
        -s 'attributes."saml.client.signature"=false' -s 'attributes."saml.server.signature"=true' \
        -s 'attributes."saml.assertion.signature"=true' -s 'attributes."saml_name_id_format"=username' \
        -s 'attributes."saml_force_name_id_format"=true' -s 'attributes."saml.force.post.binding"=true')
    fi
    kc_mapper $id username -s protocol=saml -s protocolMapper=saml-user-property-mapper \
      -s 'config."user.attribute"=username' -s 'config."attribute.name"=username' -s 'config."attribute.nameformat"=Basic'
    kc_mapper $id groups -s protocol=saml -s protocolMapper=saml-group-membership-mapper \
      -s 'config."attribute.name"=groups' -s 'config."full.path"=false' -s 'config.single=true' -s 'config."attribute.nameformat"=Basic'
    kc_sem_role_list $id
    # Certificado de assinatura do reino, que o Zabbix usa para conferir a resposta.
    crt=$(kc get keys -r msp | jq -r '.keys[]|select(.algorithm=="RS256" and .use=="SIG" and .status=="ACTIVE")|.certificate' | head -1)
    [ -n "$crt" ] || erro "não achei o certificado do reino msp"
    printf -- '-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----\n' "$(printf '%s' "$crt" | fold -w 64)" > zabbix-saml/idp.crt
    chmod 644 zabbix-saml/idp.crt
    Z=http://127.0.0.1:8082/api_jsonrpc.php
    zapi() { curl -s -m 20 -H 'Content-Type: application/json-rpc' -H "Authorization: Bearer $ZTK" $Z \
      -d "{\"jsonrpc\":\"2.0\",\"method\":\"$1\",\"params\":$2,\"id\":1}"; }
    ZTK=$(curl -s -m 10 -H 'Content-Type: application/json-rpc' $Z \
      -d "{\"jsonrpc\":\"2.0\",\"method\":\"user.login\",\"params\":{\"username\":\"Admin\",\"password\":\"$ZABBIX_ADMIN_PASS\"},\"id\":1}" | jq -r '.result // empty')
    if [ -n "$ZTK" ]; then
      ug() { zapi usergroup.get "{\"output\":[\"usrgrpid\"],\"filter\":{\"name\":\"$1\"}}" | jq -r '.result[0].usrgrpid // empty'; }
      UG_ADM=$(ug "Zabbix administrators"); UG_OFF=$(ug "Disabled")
      UG_TEC=$(ug "MSP técnicos")
      if [ -z "$UG_TEC" ]; then
        HGS=$(zapi hostgroup.get '{"output":["groupid"]}' | jq -c '[.result[]|{id:.groupid,permission:3}]')
        UG_TEC=$(zapi usergroup.create "{\"name\":\"MSP técnicos\",\"hostgroup_rights\":$HGS}" | jq -r '.result.usrgrpids[0] // empty')
      fi
      R_SUPER=$(zapi role.get '{"output":["roleid"],"filter":{"type":3}}' | jq -r '.result[0].roleid')
      R_ADMIN=$(zapi role.get '{"output":["roleid"],"filter":{"name":"Admin role"}}' | jq -r '.result[0].roleid')
      UD=$(zapi userdirectory.get '{"output":["userdirectoryid"],"filter":{"idp_type":2}}' | jq -r '.result[0].userdirectoryid // empty')
      PAR="\"idp_entityid\":\"https://$H_SSO/realms/msp\",\"sso_url\":\"https://$H_SSO/realms/msp/protocol/saml\",\"slo_url\":\"https://$H_SSO/realms/msp/protocol/saml\",\"sp_entityid\":\"zabbix\",\"username_attribute\":\"username\",\"group_name\":\"groups\",\"provision_status\":1,\"provision_groups\":[{\"name\":\"msp-admin\",\"roleid\":\"$R_SUPER\",\"user_groups\":[{\"usrgrpid\":\"$UG_ADM\"}]},{\"name\":\"msp-tecnico\",\"roleid\":\"$R_ADMIN\",\"user_groups\":[{\"usrgrpid\":\"$UG_TEC\"}]}]"
      if [ -z "$UD" ]; then r=$(zapi userdirectory.create "{\"idp_type\":2,\"name\":\"Keycloak\",$PAR}")
      else r=$(zapi userdirectory.update "{\"userdirectoryid\":\"$UD\",$PAR}"); fi
      r2=$(zapi authentication.update "{\"saml_auth_enabled\":1,\"saml_jit_status\":1,\"disabled_usrgrpid\":\"$UG_OFF\"}")
      if echo "$r$r2" | grep -q '"error"'; then aviso "Zabbix: a API recusou a configuração SAML: $(echo "$r$r2" | jq -rs 'map(.error.data // empty)|join(" ")')"
      else ok "Zabbix: login único (msp-admin = Super admin, msp-tecnico = Admin)"; fi
    else aviso "Zabbix: não consegui entrar na API para ligar o login único"; fi
  fi

  # ---- GLPI: confia no cabeçalho Remote-User que o porteiro preenche (o Caddy apaga o
  #      que vier de fora). Usuário novo nasce sozinho; o administrador já nasce Super-Admin.
  if tem glpi; then
    gsql() { docker compose exec -T mariadb mariadb -uroot -p"$MARIA_ROOT" glpi -Nse "$1"; }
    SV=$(gsql "SELECT id FROM glpi_ssovariables WHERE name='REMOTE_USER'" | head -1)
    [ -n "$SV" ] || { gsql "INSERT INTO glpi_ssovariables (name) VALUES ('REMOTE_USER')"; SV=$(gsql "SELECT id FROM glpi_ssovariables WHERE name='REMOTE_USER'"); }
    LOGOUT="https://$H_PORTAL/oauth2/sign_out?rd=https%3A%2F%2F$H_SSO%2Frealms%2Fmsp%2Fprotocol%2Fopenid-connect%2Flogout"
    gsql "UPDATE glpi_configs SET value='$SV' WHERE context='core' AND name='ssovariables_id';
          UPDATE glpi_configs SET value='1' WHERE context='core' AND name='is_users_auto_add';
          UPDATE glpi_configs SET value='$LOGOUT' WHERE context='core' AND name='ssologout_url';"
    if [ -z "$(gsql "SELECT id FROM glpi_users WHERE name='$SSO_USER'")" ]; then
      gsql "INSERT INTO glpi_users (name, realname, firstname, authtype, is_active, profiles_id, entities_id, date_creation, date_mod)
            VALUES ('$SSO_USER', '$SSO_NOME', 'Gestor', 4, 1, 4, 0, NOW(), NOW());"
      GU=$(gsql "SELECT id FROM glpi_users WHERE name='$SSO_USER'")
      gsql "INSERT INTO glpi_profiles_users (users_id, profiles_id, entities_id, is_recursive, is_dynamic, is_default_profile)
            VALUES ($GU, 4, 0, 1, 0, 1);
            INSERT INTO glpi_useremails (users_id, is_default, is_dynamic, email) VALUES ($GU, 1, 0, '$EMAIL');"
    fi
    ok "GLPI: login único pelo portal ($SSO_USER como Super-Admin; técnicos novos entram como Self-Service)"
  fi

  # ---- Wazuh (SAML)
  if tem wazuh; then
    cd "$BASE/wazuh-docker/single-node"
    if [ ! -f "$ESTADO/wazuh-sso" ]; then wazuh_sso && touch "$ESTADO/wazuh-sso"; fi
    cd "$BASE/core"
  fi

  # ---- Uptime Kuma: atrás do porteiro (só msp-admin), sem a segunda tela de login.
  if tem kuma; then
    r=$(kuma_sem_login || true)
    [ "$r" = OK ] && ok "Uptime Kuma: painel só para msp-admin, pelo login único" \
      || aviso "Uptime Kuma: continua com login próprio ($r)"
  fi

  gerar_portal
  docker compose up -d oauth2-proxy >/dev/null 2>&1
  docker compose restart oauth2-proxy >/dev/null 2>&1 || true
  for t in 1 2 3 4 5 6; do docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile >/dev/null 2>&1 && break; sleep 5; done
  ok "portal em https://$H_PORTAL"
fi

# =============================================================== conferência
etapa "Conferindo cada endereço"
sleep 10
if tem tactical; then
  # O Tactical leva de 3 a 6 minutos no primeiro início (medido em 08/11/2026).
  trmm_ok() { [ "$(curl -sk -o /dev/null -m 10 -w '%{http_code}' --resolve "$H_RMM:443:127.0.0.1" "https://$H_RMM/" || true)" = 200 ]; }
  esperar "o Tactical RMM terminar o primeiro início" 600 trmm_ok || true
fi
for h in "${HOSTS[@]}"; do
  c=$(curl -sk -o /dev/null -m 20 -w '%{http_code}' --resolve "$h:443:127.0.0.1" "https://$h/" || true)
  if [[ "$c" =~ ^(200|301|302|303|307|308|401)$ ]]; then ok "https://$h ($c)"
  else aviso "https://$h respondeu $c (algumas ferramentas levam alguns minutos no primeiro início)"; fi
done
cert=$(echo | timeout 10 openssl s_client -connect 127.0.0.1:443 -servername "${HOSTS[0]}" 2>/dev/null | openssl x509 -noout -issuer 2>/dev/null || true)
if [[ "$cert" == *"Let's Encrypt"* ]]; then ok "certificado Let's Encrypt emitido"
else aviso "certificado ainda não emitido: confira o DNS e as portas 80 e 443 abertas na nuvem"; fi

# ============================================================== credenciais
umask 077
{
  echo "SUÍTE OPEN SOURCE PARA MSP · credenciais · $(date '+%Y-%m-%d %H:%M')"
  echo "Servidor: ${IP_PUBLICO:-?} · domínio $DOMINIO · pasta $BASE"
  echo
  if tem keycloak; then
    echo "PORTAL · LOGIN ÚNICO (comece por aqui)"
    echo "  https://$H_PORTAL"
    echo "  usuário $SSO_USER · senha $SSO_PASS"
    echo "  No primeiro acesso o portal mostra um QR code: escaneie com o Google Authenticator,"
    echo "  Microsoft Authenticator ou Authy. Dali em diante todas as ferramentas entram por ele."
    echo
    echo "CONTAS LOCAIS DE EMERGÊNCIA (para quando o login único estiver fora do ar)"
  fi
  tem keycloak    && echo "Keycloak      https://$H_SSO/admin      admin / $KC_ADMIN_PASS"
  tem zabbix      && echo "Zabbix        https://$H_ZABBIX         Admin / $ZABBIX_ADMIN_PASS"
  tem glpi        && echo "GLPI          https://$H_GLPI           glpi / $GLPI_ADMIN_PASS"
  tem wazuh       && echo "Wazuh         https://$H_WAZUH          admin / $WAZUH_ADMIN_PASS"
  tem wazuh       && echo "              senha de cadastro dos agentes (porta 1515): $WAZUH_AGENT_PASS"
  tem tactical    && echo "Tactical RMM  https://$H_RMM            $TRMM_USER / $TRMM_PASS   (pede 2FA no primeiro acesso)"
  tem nextcloud   && echo "Nextcloud     https://$H_ARQUIVOS       admin / $NC_ADMIN_PASS"
  tem vaultwarden && echo "Vaultwarden   https://$H_SENHAS/admin   token: $VW_ADMIN_TOKEN   (cadastro fechado: convide pelo /admin)"
  if tem kuma; then
    if [ -f "$ESTADO/kuma-admin" ] && [ ! -f "$ESTADO/kuma-admin-externo" ]; then echo "Uptime Kuma   https://$H_STATUS         $KUMA_USER / $KUMA_PASS"
    elif [ -f "$ESTADO/kuma-admin-externo" ]; then echo "Uptime Kuma   https://$H_STATUS         (administrador criado antes desta instalação)"
    else echo "Uptime Kuma   https://$H_STATUS         crie o administrador no PRIMEIRO acesso"; fi
  fi
  echo
  echo "Senhas internas (bancos de dados) em $ENVF"
} > "$CRED"
umask 022

echo
echo "${C_OK}══════════════════════════════════════════════════════════════${C_0}"
echo "${C_N}  Pronto. ${#SEL[@]} ferramenta(s) no ar.${C_0}"
echo "${C_OK}══════════════════════════════════════════════════════════════${C_0}"
cat "$CRED"
echo
echo "${C_N}Próximos passos${C_0}"
tem keycloak && echo "  • ${C_AV}Comece pelo portal:${C_0} https://$H_PORTAL (tenha o celular na mão para o 2FA)."
tem keycloak && echo "  • Técnicos novos: Keycloak (https://$H_SSO/admin) → reino msp → Users → grupo msp-tecnico."
tem kuma && [ ! -f "$ESTADO/kuma-admin" ] && echo "  • ${C_AV}Agora:${C_0} abra https://$H_STATUS e crie o administrador. Até lá, qualquer um pode criar."
echo "  • Guarde $CRED num lugar seguro e apague do servidor depois."
echo "  • Na nuvem (security list ou firewall), libere as portas 80 e 443."
tem wazuh  && echo "  • Agentes do Wazuh usam as portas 1514 e 1515 (TCP)."
tem zabbix && echo "  • Agentes ativos do Zabbix usam a porta 10051 (TCP)."
echo "  • Faça backup dos volumes do Docker (docker volume ls) e da pasta $BASE."
echo "  • Log desta instalação: $LOG"
exit 0
