#!/bin/bash
# sshepherd-secrets — o cofre de segredos do servidor. Roda como root, via sudo.
#
# Este arquivo NÃO roda na máquina local. O create-ops-user.sh (Dia Zero), o
# provision.sh e o secrets-install.sh instalam uma cópia dele em
# /usr/local/sbin/sshepherd-secrets, root:root 755, e o usuário de operação
# recebe no sudoers permissão para chamar SÓ este executável. É o único caminho
# para dentro do cofre, e ele nunca mostra um valor: nem na tela, nem no log.
#
# O cofre: /var/lib/sshepherd/secrets/app.env, root:root 600, dentro de pasta
# root:root 700. O systemd lê o arquivo como root antes de trocar para o usuário
# de serviço (EnvironmentFile=). Nem o usuário de operação, que é o que o agente
# usa, nem o usuário de serviço conseguem abrir o arquivo. Fica fora de /etc de
# propósito: o retrato do servidor (snapshot-etc.sh) copia /etc inteiro.
#
# Subcomandos. Conjunto fechado: não aceita caminho, comando nem arquivo.
#   status                     o que existe no cofre, sem nenhum valor
#   set NOME                   grava: pede no teclado, duas vezes, sem eco
#   set NOME --stdin           grava lendo a entrada padrão (uso interno do db.sh)
#   unset NOME                 remove
#   generate NOME [--bytes N]  gera valor aleatório AQUI; ele nunca viaja
#   apply [--health URL]       reinicia o serviço com o cofre atual. Com saúde
#                              confirmada, o cofre vira "último que funcionou".
#                              Sem saúde, volta ao último que funcionou e
#                              reinicia de novo. Uma reversão, nunca um laço
#   promote                    marca o cofre atual como "último que funcionou"
#                              (o deploy chama depois de uma publicação saudável)
#   rollback                   descarta o que ainda não foi aplicado com sucesso
#                              e volta ao último cofre que funcionou
#
# Saída: linhas chave=valor, nunca o valor de um segredo.
# Códigos: 0 ok | 2 argumento inválido | 3 cofre ausente | 8 serviço não subiu
#          10 precisa de teclado | 14 sem permissão | 15 cofre ocupado

set -uo pipefail
umask 077
# Ambiente fixo: este executável é chamado por sudo, e sudo não garante PATH.
PATH=/usr/sbin:/usr/bin:/sbin:/bin
export PATH
cd / || exit 14

SERVICE="sshepherd-app"
DIR="/var/lib/sshepherd/secrets"

# Modo de teste: só vale sem root e sem sudo. Como root o caminho é fixo, e o
# sudo apaga o ambiente de quem chama (env_reset), então ninguém chega aqui com
# a variável definida por fora.
if [ "$(id -u)" -ne 0 ] && [ -z "${SUDO_USER:-}" ] && [ -n "${SSHEPHERD_SECRETS_DIR:-}" ]; then
  DIR="$SSHEPHERD_SECRETS_DIR"
  TESTE=1
else
  TESTE=0
fi

FILE="$DIR/app.env"
OK="$DIR/app.env.ok"
PREV="$DIR/app.env.prev"
REJ="$DIR/app.env.rejected"
LOCK="$DIR/.lock"

die() { echo "ERRO: $1" >&2; exit "${2:-2}"; }

if [ "$TESTE" -eq 0 ] && [ "$(id -u)" -ne 0 ]; then
  die "o cofre pertence a root. Chame por sudo: sudo -n /usr/local/sbin/sshepherd-secrets ..." 14
fi

usage() { sed -n '2,36p' "$0" | sed 's/^# \{0,1\}//'; exit 0; }

# ---------------------------------------------------------------------------
# Validação. NOME de variável de ambiente, e valor de uma linha só.
# ---------------------------------------------------------------------------
valid_name() {
  case "$1" in
    *[!A-Z0-9_]*|[!A-Z]*|"") return 1 ;;
  esac
  [ "${#1}" -le 128 ]
}

# Variável que muda COMO um programa executa, não o que ele faz. Com uma destas
# no cofre, quem controla o nome controla o código que o serviço carrega: um
# NODE_OPTIONS=--require apontando para um arquivo em shared/ roda com os
# segredos no ambiente. O cofre guarda chave, não configuração do carregador.
nome_de_execucao() {
  case "$1" in
    LD_*|DYLD_*|NODE_OPTIONS|NODE_PATH|NODE_EXTRA_CA_CERTS|PYTHON*|BASH_ENV|ENV|PATH|SHELL|\
    PERL5*|PERLLIB|RUBYOPT|RUBYLIB|GEM_*|BUNDLE_*|IFS|PROMPT_COMMAND|SHELLOPTS|BASHOPTS|\
    CDPATH|GLIBC_TUNABLES|LC_*|LANG|JAVA_TOOL_OPTIONS|_JAVA_OPTIONS|JDK_JAVA_OPTIONS|\
    DOTNET_*|PHP_INI_SCAN_DIR|PHPRC|GOFLAGS|GODEBUG|TMPDIR|HOME|USER|LOGNAME|SUDO_*|SSH_*)
      return 0 ;;
  esac
  return 1
}

# Uma linha, sem espaço nas pontas, sem barra invertida, sem começar por aspa.
# Não é capricho: o systemd tira espaço das pontas, trata barra invertida como
# escape e aspa como delimitador. Um valor que passa por aqui é lido igual por
# qualquer leitor de .env. Chave de API nunca precisa de nada disso.
valid_value() {
  case "$1" in
    *$'\n'*|*$'\r'*|*"\\"*|" "*|*" "|$'\t'*|*$'\t'|\"*|\'*|"") return 1 ;;
  esac
  [ "${#1}" -le 8192 ]
}

# ---------------------------------------------------------------------------
# Lock. Dois `set` ao mesmo tempo perdiam uma alteração; um `set` no meio de um
# `apply` promovia para "funcionou" um cofre diferente do que o serviço carregou.
# ---------------------------------------------------------------------------
ensure_dir() {
  if [ "$TESTE" -eq 1 ]; then
    [ -d "$DIR" ] || mkdir -p "$DIR"
    chmod 700 "$DIR" 2>/dev/null || true
    [ -f "$FILE" ] || install -m 600 /dev/null "$FILE"
  else
    install -d -m 700 -o root -g root "$DIR"
    [ -f "$FILE" ] || install -m 600 -o root -g root /dev/null "$FILE"
    chown root:root "$FILE"; chmod 600 "$FILE"
  fi
}

trancar() {
  ensure_dir
  exec 9>"$LOCK"
  if command -v flock >/dev/null 2>&1; then
    flock -w 30 9 || die "o cofre está ocupado por outra operação; tente de novo em instantes" 15
  fi
}

# ---------------------------------------------------------------------------
# O arquivo. Sempre inteiro, sempre atômico: nunca existe um cofre pela metade,
# e nunca existe um instante sem cofre.
# ---------------------------------------------------------------------------
# Copia origem para destino por temporário + mv. `cp` direto deixa janela com
# arquivo parcial, e o EnvironmentFile= é opcional: o serviço podia subir sem
# segredo nenhum nessa janela.
trocar() {
  local origem="$1" destino="$2" tmp
  tmp=$(mktemp "$DIR/.troca.XXXXXX") || die "não consegui criar temporário em $DIR" 14
  cat "$origem" > "$tmp" || { rm -f "$tmp"; die "não consegui copiar $(basename "$origem")" 14; }
  chmod 600 "$tmp"
  [ "$TESTE" -eq 1 ] || chown root:root "$tmp"
  mv -f "$tmp" "$destino" || { rm -f "$tmp"; die "não consegui gravar $(basename "$destino")" 14; }
}

# Grava o conteúdo recebido no stdin em um temporário na mesma pasta (mesmo
# sistema de arquivos: o mv é atômico), valida linha a linha e só então troca.
write_file() {
  local tmp linha nome valor
  tmp=$(mktemp "$DIR/.app.env.XXXXXX") || die "não consegui criar temporário em $DIR" 14
  cat > "$tmp"
  while IFS= read -r linha || [ -n "$linha" ]; do
    [ -z "$linha" ] && continue
    nome="${linha%%=*}"; valor="${linha#*=}"
    if [ "$nome" = "$linha" ] || ! valid_name "$nome" || ! valid_value "$valor"; then
      rm -f "$tmp"
      die "o cofre ficaria inválido (linha fora do formato NOME=valor); nada foi gravado"
    fi
  done < "$tmp"
  chmod 600 "$tmp"
  [ "$TESTE" -eq 1 ] || chown root:root "$tmp"
  mv -f "$tmp" "$FILE" || { rm -f "$tmp"; die "não consegui gravar o cofre" 14; }
}

# Linhas do cofre que NÃO são a variável pedida.
others() {
  local nome="$1" linha
  while IFS= read -r linha || [ -n "$linha" ]; do
    [ -z "$linha" ] && continue
    [ "${linha%%=*}" = "$nome" ] && continue
    printf '%s\n' "$linha"
  done < "$FILE"
}

set_var() {
  local nome="$1" valor="$2"
  valid_name "$nome" || die "nome inválido: '${nome}'. Use MAIUSCULAS, dígitos e sublinhado, começando por letra."
  nome_de_execucao "$nome" && die "'${nome}' controla como o programa executa, não é segredo. O cofre não aceita."
  valid_value "$valor" || die "valor inválido: uma linha só, até 8192 caracteres, sem espaço nas pontas, sem barra invertida, sem começar por aspa."
  { others "$nome"; printf '%s=%s\n' "$nome" "$valor"; } | write_file
}

unset_var() {
  local nome="$1"
  valid_name "$nome" || die "nome inválido: '${nome}'"
  if ! grep -q "^${nome}=" "$FILE"; then
    echo "var=${nome} resultado=nao_existia"
    return 0
  fi
  others "$nome" | write_file
  echo "var=${nome} resultado=removida"
}

# ---------------------------------------------------------------------------
# status: tudo que dá para saber sem ver valor.
# ---------------------------------------------------------------------------
cmd_status() {
  [ $# -eq 0 ] || die "status não aceita argumento"
  if [ ! -f "$FILE" ]; then
    echo "cofre=ausente"
    echo "dica=rode /sshp-provision; ele cria o cofre vazio"
    exit 3
  fi
  echo "cofre=${FILE}"
  echo "dono_modo=$(stat -c '%U:%G %a' "$FILE" 2>/dev/null)"
  echo "pasta_modo=$(stat -c '%U:%G %a' "$DIR" 2>/dev/null)"
  echo "atualizado_em=$(stat -c '%y' "$FILE" 2>/dev/null | cut -d. -f1)"
  if [ -f "$OK" ]; then
    if cmp -s "$FILE" "$OK"; then echo "aplicado=sim"; else echo "aplicado=não (há alteração que ainda não passou por apply)"; fi
  else
    echo "aplicado=nunca (nenhum apply ou publicação saudável ainda)"
  fi
  echo "rejeitado_guardado=$([ -f "$REJ" ] && echo sim || echo não)"
  local n=0 linha nome valor
  while IFS= read -r linha || [ -n "$linha" ]; do
    [ -z "$linha" ] && continue
    nome="${linha%%=*}"; valor="${linha#*=}"
    n=$((n + 1))
    if [ -n "$valor" ]; then echo "var=${nome} estado=definida"; else echo "var=${nome} estado=vazia"; fi
  done < "$FILE"
  echo "variaveis=${n}"
  if [ "$TESTE" -eq 0 ] && command -v systemctl >/dev/null 2>&1; then
    echo "servico=$(systemctl is-active "$SERVICE" 2>/dev/null || echo inexistente)"
  fi
}

# ---------------------------------------------------------------------------
# set: do teclado da pessoa para o cofre, sem passar por mais ninguém.
# ---------------------------------------------------------------------------
cmd_set() {
  [ $# -ge 1 ] && [ $# -le 2 ] || die "uso: set NOME [--stdin]"
  local nome="$1" modo="${2:-}" valor confirma
  valid_name "$nome" || die "nome inválido: '${nome}'. Use MAIUSCULAS, dígitos e sublinhado, começando por letra."
  nome_de_execucao "$nome" && die "'${nome}' controla como o programa executa, não é segredo. O cofre não aceita."
  if [ "$modo" = "--stdin" ]; then
    # Limite em bytes ANTES de virar variável: lê no máximo um byte além do
    # permitido, e se ele existir o valor é grande demais.
    valor=$(head -c 8193)
    [ "${#valor}" -le 8192 ] || die "valor grande demais (máximo 8192 bytes)"
  elif [ -n "$modo" ]; then
    die "uso: set NOME [--stdin]"
  else
    # Sem terminal não há teclado, e sem teclado não há `set`: é o que impede o
    # agente, em sessão BatchMode, de gravar um valor que ele inventou.
    if ! { exec 3</dev/tty; } 2>/dev/null; then
      echo "RECUSADO: 'set' lê o valor do teclado, e esta sessão não tem terminal." >&2
      echo "  Rode no SEU terminal:  ssh -t <usuario>@<servidor> 'sudo -n /usr/local/sbin/sshepherd-secrets set ${nome}'" >&2
      exit 10
    fi
    printf 'Valor de %s (não aparece enquanto digita): ' "$nome" >&2
    IFS= read -rs valor <&3; echo >&2
    printf 'Repita: ' >&2
    IFS= read -rs confirma <&3; echo >&2
    exec 3<&-
    [ "$valor" = "$confirma" ] || die "os dois valores não conferem; nada foi gravado"
  fi
  [ -n "$valor" ] || die "valor vazio; nada foi gravado. Para remover, use: unset ${nome}"
  trancar
  set_var "$nome" "$valor"
  echo "var=${nome} resultado=gravada"
  echo "proximo=apply (reinicia a aplicação para ela enxergar o valor)"
}

# ---------------------------------------------------------------------------
# generate: valor aleatório nascido aqui. Serve para segredo de sessão, chave de
# assinatura, senha de banco: nada que precise existir em outro lugar.
# ---------------------------------------------------------------------------
cmd_generate() {
  [ $# -ge 1 ] || die "uso: generate NOME [--bytes N]"
  local nome="$1" bytes=32 valor
  shift
  while [ $# -gt 0 ]; do
    case "$1" in
      --bytes) [ $# -ge 2 ] || die "--bytes precisa de um número"; bytes="$2"; shift 2 ;;
      *) die "argumento desconhecido: $1" ;;
    esac
  done
  case "$bytes" in
    *[!0-9]*|"") die "--bytes aceita um número entre 16 e 64" ;;
  esac
  [ "$bytes" -ge 16 ] && [ "$bytes" -le 64 ] || die "--bytes aceita um número entre 16 e 64"
  # base64url: `/` e `+` viram `_` e `-`; só o padding e as quebras saem. Assim
  # os N bytes de entropia ficam inteiros no valor, em vez de serem cortados.
  valor=$(head -c "$bytes" /dev/urandom | base64 | tr '/+' '_-' | tr -d '=\n')
  [ "${#valor}" -ge 21 ] || die "não consegui gerar valor aleatório"
  trancar
  set_var "$nome" "$valor"
  echo "var=${nome} resultado=gerada bytes=${bytes}"
  echo "proximo=apply (reinicia a aplicação para ela enxergar o valor)"
}

# ---------------------------------------------------------------------------
# apply, promote e rollback: o serviço só é reiniciado por aqui.
# ---------------------------------------------------------------------------
servico_existe() { systemctl cat "$SERVICE" >/dev/null 2>&1; }

# A unidade pode existir antes da primeira publicação: WorkingDirectory ainda
# não aponta para nada. Reiniciar aí falharia sem motivo.
publicado() {
  local wd
  wd=$(systemctl show -p WorkingDirectory --value "$SERVICE" 2>/dev/null)
  [ -n "$wd" ] && [ -d "$wd" ]
}

# `systemctl restart` sem --no-block fica preso num stop travado ou num start
# que nunca termina, e matar o cliente não cancela o job. Dispara e acompanha
# com prazo. Ativo e estável: ativo agora e ainda ativo três segundos depois,
# sem ter entrado em "failed" no meio.
reiniciar_e_esperar() {
  local i
  systemctl reset-failed "$SERVICE" >/dev/null 2>&1 || true
  systemctl restart --no-block "$SERVICE" >/dev/null 2>&1 || return 1
  for i in $(seq 1 60); do
    if systemctl is-failed --quiet "$SERVICE" 2>/dev/null; then return 1; fi
    if systemctl is-active --quiet "$SERVICE" 2>/dev/null; then
      sleep 3
      systemctl is-active --quiet "$SERVICE" 2>/dev/null && return 0
      return 1
    fi
    sleep 1
  done
  return 1
}

# Só a própria máquina. URL externa aqui seria o root do servidor batendo onde
# quem chamou mandar. Autoridade inteira validada: sem usuário, porta numérica.
valid_health() {
  [[ "$1" =~ ^https?://(127\.0\.0\.1|localhost)(:[0-9]{1,5})?(/[A-Za-z0-9._~%/-]*)?$ ]]
}

saude_ok() {
  local url="$1" i code
  for i in $(seq 1 6); do
    code=$(curl -q -s -o /dev/null -w '%{http_code}' --max-time 5 --noproxy '*' "$url" 2>/dev/null || echo 000)
    case "$code" in 2*|3*) return 0 ;; esac
    [ "$i" -lt 6 ] && sleep 5
  done
  return 1
}

promover() {
  # O anterior que funcionou vira .prev: é para onde `rollback` volta quando o
  # atual já foi aplicado. Uma versão atrás, não um histórico.
  if [ -f "$OK" ] && ! cmp -s "$FILE" "$OK"; then trocar "$OK" "$PREV"; fi
  trocar "$FILE" "$OK"
}

estado_servico() { systemctl is-active "$SERVICE" 2>/dev/null || echo inativo; }

cmd_apply() {
  local url=""
  while [ $# -gt 0 ]; do
    case "$1" in
      --health) [ $# -ge 2 ] || die "--health precisa de um endereço"; url="$2"; shift 2 ;;
      *) die "argumento desconhecido: $1" ;;
    esac
  done
  [ -z "$url" ] || valid_health "$url" || die "--health aceita só endereço local: http://127.0.0.1[:porta]/caminho"
  [ -f "$FILE" ] || die "cofre ausente; rode /sshp-provision" 3
  [ "$TESTE" -eq 0 ] || die "apply não roda em modo de teste"
  if ! servico_existe; then
    echo "apply=sem_servico"
    echo "dica=o serviço ainda não existe; o cofre será lido na primeira publicação"
    return 0
  fi
  if ! publicado; then
    echo "apply=sem_publicacao"
    echo "dica=ainda não houve publicação; o cofre será lido no primeiro deploy"
    return 0
  fi
  trancar

  if reiniciar_e_esperar && { [ -z "$url" ] || saude_ok "$url"; }; then
    if [ -n "$url" ]; then
      promover
      echo "apply=ok promovido=sim"
    else
      # Ativo não prova saúde. Sem --health, o cofre não vira "funcionou".
      echo "apply=ok promovido=não"
      echo "dica=passe --health http://127.0.0.1/ para o cofre virar o último que funcionou"
    fi
    echo "servico=$(estado_servico)"
    return 0
  fi

  # Não subiu com o cofre novo. Uma reversão, para o último que funcionou.
  if [ ! -f "$OK" ]; then
    echo "apply=falhou_sem_versao_anterior"
    echo "servico=$(estado_servico)"
    echo "dica=não existe cofre anterior que tenha funcionado; corrija o valor e rode apply de novo"
    echo "registro=ssh-exec.sh --op logs"
    return 8
  fi
  trocar "$FILE" "$REJ"
  trocar "$OK" "$FILE"
  if reiniciar_e_esperar && { [ -z "$url" ] || saude_ok "$url"; }; then
    echo "apply=revertido"
    echo "servico=$(estado_servico)"
    echo "dica=o cofre que falhou ficou guardado como app.env.rejected; corrija e rode apply de novo"
  else
    echo "apply=revertido_mas_servico_nao_subiu"
    echo "servico=$(estado_servico)"
    echo "dica=com o cofre anterior o serviço também não subiu; a causa pode não ser o cofre: /sshp-doctor"
  fi
  echo "registro=ssh-exec.sh --op logs"
  return 8
}

cmd_promote() {
  [ $# -eq 0 ] || die "promote não aceita argumento"
  [ -f "$FILE" ] || die "cofre ausente; rode /sshp-provision" 3
  trancar
  if [ "$TESTE" -eq 0 ] && servico_existe && ! systemctl is-active --quiet "$SERVICE" 2>/dev/null; then
    echo "promote=recusado motivo=servico_inativo"
    return 8
  fi
  promover
  echo "promote=ok"
}

cmd_rollback() {
  [ $# -eq 0 ] || die "rollback não aceita argumento"
  [ -f "$OK" ] || die "não existe cofre anterior que tenha funcionado" 3
  trancar
  local alvo="$OK"
  # Se o atual já é o último que funcionou, voltar para ele não muda nada:
  # o pedido é a versão anterior a ele.
  if cmp -s "$FILE" "$OK"; then
    [ -f "$PREV" ] || die "o cofre atual já é o último que funcionou, e não há versão anterior a ele" 3
    alvo="$PREV"
  fi
  trocar "$FILE" "$REJ"
  trocar "$alvo" "$FILE"
  if [ "$TESTE" -eq 1 ] || ! servico_existe || ! publicado; then
    echo "rollback=ok apply=sem_servico"
    return 0
  fi
  if reiniciar_e_esperar; then
    echo "rollback=ok"
    echo "servico=$(estado_servico)"
    return 0
  fi
  echo "rollback=aplicado_mas_servico_nao_subiu"
  echo "servico=$(estado_servico)"
  echo "registro=ssh-exec.sh --op logs"
  return 8
}

# ---------------------------------------------------------------------------
cmd="${1:-}"
shift || true
case "$cmd" in
  status)   cmd_status "$@" ;;
  set)      cmd_set "$@" ;;
  unset)    [ $# -eq 1 ] || die "uso: unset NOME"; trancar; unset_var "$1" ;;
  generate) cmd_generate "$@" ;;
  apply)    cmd_apply "$@" ;;
  promote)  cmd_promote "$@" ;;
  rollback) cmd_rollback "$@" ;;
  -h|--help|"") usage ;;
  *) die "subcomando desconhecido: '${cmd}'. Use status, set, unset, generate, apply, promote ou rollback." ;;
esac
