#!/usr/bin/env bash
# Managed by ansible 095-tropa (tag client). Клиент Тропы Жоры (m:клиент-tropa): входит
# в каждый выпуск архивом и лежит в /opt/tropa/current/tropa.
#   tropa up       — туннель на переднем плане; IPv6, разводку RU, запрет и резолвер
#                    имён держит служба tropa-guard (m:страж-tropa);
#   tropa check    — адрес ноги, выпуск и состояние службы: IPv6, DNS, разводка RU;
#   tropa session  — служебная: то, что up запускает под sudo.
# Регресс — talos/ansible/playbooks/095-tropa/files/tropa.test.sh.
set -euo pipefail

# Каталоги — переменными только ради регресса (m:клиент-tropa).
ROOT=${TROPA_ROOT:-/opt/tropa}
STATE_DIR=${TROPA_STATE_DIR:-/var/lib/tropa}
KEY_FILE=$STATE_DIR/key
CUR=$ROOT/current
BIN=$CUR/tropa-client
UPDATE=$CUR/tropa-update
GUARD_BIN=$CUR/tropa-guard
GUARD_CONF=$CUR/tropa-guard.json
DIAG_BIN=$CUR/tropa-diag
DIAG_CONF=$CUR/tropa-diag.json
STATUS_FILE=${TROPA_STATUS_FILE:-/var/run/tropa/status.json}
# Журнал клиента (клиент форка на Linux пишет его только в stderr) и суммы служб.
LOG_DIR=${TROPA_LOG_DIR:-/var/log/tropa}
LOG_FILE=$LOG_DIR/client.log
LOG_MAX=$(( 10 * 1024 * 1024 ))
# Каталог файла состояния клиента (/var/run/tropa/status.json) и сумм служб.
RUN_DIR=${TROPA_RUN_DIR:-/run/tropa}
# Предел ожидания connected нового выпуска и пауза между проверками выпуска, секунды.
NEW_RELEASE_WAIT=${TROPA_NEW_RELEASE_WAIT:-60}
NEW_RELEASE_POLL=${TROPA_NEW_RELEASE_POLL:-1}
CHECK_EVERY=${TROPA_CHECK_EVERY:-600}

# Ключ лежит у root (0600): обычный пользователь читает его через sudo.
read_key() {
  if [ -r "$KEY_FILE" ]; then
    [ -s "$KEY_FILE" ] || { no_key; }
    tr -d '[:space:]' < "$KEY_FILE"
  else
    local k
    k=$(sudo cat "$KEY_FILE" 2>/dev/null | tr -d '[:space:]') || true
    [ -n "$k" ] || no_key
    printf '%s' "$k"
  fi
}
no_key() {
  echo "tropa: нет ключа в $KEY_FILE — поставь клиент установщиком:" >&2
  echo "  sudo sh install.sh <файл ключа>" >&2
  exit 1
}

# Ключ — «tropa://» + base64 в URL-безопасном алфавите без паддинга от JSON, где
# поле s — «адрес:порт» первой ноги, а pool[].endpoint — прочих (URL_SAFE_NO_PAD,
# client_ops.rs форка). Печатает адреса ног без порта, первая — из s. Без jq: на
# компьютере человека его может не быть.
key_hosts() {
  local b=${1#tropa://}
  b=$(printf '%s' "$b" | tr -- '-_' '+/')
  # Длина — через wc, а не длиной параметра bash: её запись «$, {, #» jinja
  # читает как начало комментария (регресс рендерит шаблон jinja2).
  while [ $(( $(printf '%s' "$b" | wc -c) % 4 )) -ne 0 ]; do b="$b="; done
  local j first
  j=$(printf '%s' "$b" | base64 -d)
  first=$(printf '%s' "$j" | grep -oE '"s" *: *"[^"]*"' | head -n 1 || true)
  [ -n "$first" ] || { echo "tropa: в ключе нет поля s" >&2; exit 2; }
  { printf '%s\n' "$first"; printf '%s' "$j" | grep -oE '"endpoint" *: *"[^"]*"' || true; } \
    | sed -E 's/^"[a-z]+" *: *"//; s/"$//; s/:[0-9]+$//'
}

# Журнал клиента: больше 10 МБ при запуске — в client.log.1 (прежний удаляется).
log_rotate() {
  mkdir -p "$LOG_DIR" && chmod 0700 "$LOG_DIR"
  if [ -f "$LOG_FILE" ] && [ "$(stat -c %s "$LOG_FILE")" -gt "$LOG_MAX" ]; then
    mv -f "$LOG_FILE" "$LOG_FILE.1"
  fi
}

# Служба поднимается от root прежде клиента и переживает сессию. Активна, а сумма
# бинаря и настроек та же, что записана при прошлом запуске, — не трогается. Иначе
# stop и reset-failed (служба, упавшая чаще предела запусков, держит имя, и
# systemd-run без сброса откажет), затем systemd-run. Код 1 — служба не встала.
svc_on() {
  local unit=$1 bin=$2 conf=$3; shift 3
  local sum_file=$RUN_DIR/$unit.sha256 sum
  sum=$(sha256sum "$bin" "$conf" | awk '{ print $1 }' | tr '\n' ' ')
  if systemctl is-active --quiet "$unit" && [ "$(cat "$sum_file" 2>/dev/null || true)" = "$sum" ]; then
    return 0
  fi
  # Постоянный unit (установщик ставит его там, где /etc/systemd/system изменяем):
  # systemd-run не нужен, служба перезапускается.
  if [ "$unit" = tropa-diag ] && systemctl cat tropa-diag.service >/dev/null 2>&1; then
    systemctl restart tropa-diag || return 1
    printf '%s' "$sum" > "$sum_file"
    return 0
  fi
  systemctl stop "$unit" 2>/dev/null || true
  systemctl reset-failed "$unit" 2>/dev/null || true
  if systemd-run --unit="$unit" --property=Restart=always "$bin" "$@" >/dev/null; then
    printf '%s' "$sum" > "$sum_file"
  else
    return 1
  fi
}

# Путь в ядре снят: клиент форка сам открывает /dev/tropa, когда устройство есть, и
# повёл бы приём через ядро, а флага, который запретил бы это, у него нет. Модуль,
# который грузил прежний tropa-kernel load, снимается до запуска клиента, и с ним
# каталог сборок. Каталоги берутся из переменных — их ставит только регресс.
MODULE_DIR=${TROPA_MODULE_DIR:-/sys/module/tropa}
KERNEL_BUILD_DIR=${TROPA_KERNEL_BUILD_DIR:-/var/lib/tropa/kernel}
kmod_off() {
  if [ -e "$MODULE_DIR" ] && ! rmmod tropa; then
    echo "модуль ядра tropa не снят, его снимет перезагрузка компьютера" >&2
    exit 1
  fi
  rm -rf "$KERNEL_BUILD_DIR"
}

# Следы клиента прежнего имени (aivpn) — m:клиент-tropa. Снимаются, только когда
# никакой unit, кроме aivpn-guard и aivpn-diag, прежнее имя не носит: переключатель
# VPN ставит конфигурация системы, и он зовёт ~/.local/bin/aivpn через unit
# (aivpn-session.service). Домашний каталог — пользователя из $STATE_DIR/user: у
# session под sudo HOME — root. Переменные ставит только регресс.
user_home() {
  local u
  u=$(cat "$STATE_DIR/user" 2>/dev/null || true)
  [ -n "$u" ] || return 0
  getent passwd "$u" | cut -d: -f6
}
OLD_MODULE_DIR=${TROPA_OLD_MODULE_DIR:-/sys/module/aivpn}
old_off() {
  local files unit svc path f second home old_bin old_dirs
  home=$(user_home)
  old_bin=${TROPA_OLD_BIN_DIR:-${home:+$home/.local/bin}}
  old_dirs=${TROPA_OLD_DIRS:-/var/lib/aivpn /var/lib/aivpn-diag /var/lib/aivpn-guard /var/log/aivpn /var/log/aivpn-guard /run/aivpn /run/aivpn-guard${home:+ $home/.local/share/aivpn $home/.config/aivpn $home/.aivpn}}
  if ! files=$(systemctl list-unit-files --no-legend 2>/dev/null); then
    echo "не узнать, зовёт ли кто клиент прежнего имени: его следы остаются" >&2
    return 0
  fi
  unit=$(awk '{ print $1 }' <<<"$files" | grep -i aivpn | grep -ivx -e aivpn-guard.service -e aivpn-diag.service | head -n 1 || true)
  if [ -n "$unit" ]; then
    echo "клиент прежнего имени зовёт $unit: его следы остаются" >&2
    return 0
  fi
  # Службы первыми: страж прежнего имени на TERM снимает всё поставленное и только
  # потом выходит, а её каталоги уходят следом.
  for svc in $(systemctl list-units --all --no-legend --plain 2>/dev/null | awk '{ print $1 }' \
      | grep -iE '^aivpn-(guard|diag)(\.service)?$' || true); do
    systemctl stop "$svc" || { echo "служба $svc не остановлена" >&2; exit 1; }
    systemctl reset-failed "$svc" 2>/dev/null || true
  done
  if [ -e "$OLD_MODULE_DIR" ] && ! rmmod aivpn; then
    echo "модуль ядра aivpn не снят, его снимет перезагрузка компьютера" >&2
    exit 1
  fi
  for path in $old_dirs; do
    rm -rf "$path" || echo "не удалён след $path" >&2
  done
  # Обёртки — по второй строке: чужой файл с тем же именем не трогается.
  if [ -n "$old_bin" ]; then
    for f in "$old_bin"/*; do
      [ -f "$f" ] || continue
      second=$(sed -n 2p "$f" 2>/dev/null || true)
      case "$second" in
        '# Managed by ansible 095-aivpn '*) rm -f "$f" || echo "не удалён след $f" >&2 ;;
      esac
    done
  fi
}

# Файл состояния клиента несёт state connected и pid клиента $1.
connected_pid() {
  local s
  s=$(cat "$STATUS_FILE" 2>/dev/null) || return 1
  printf '%s' "$s" | grep -Eq '"state" *: *"connected"' \
    && printf '%s' "$s" | grep -Eq "\"pid\" *: *$1([^0-9]|\$)"
}

# Выпуск: apply ходит в кузню мимо туннеля. Код 10 — встал новый выпуск, и сессию
# ведёт обёртка нового выпуска; код 1 — печатается вывод, идём на нынешнем; 0 — новости нет.
apply_release() {
  local out rc=0
  [ -x "$UPDATE" ] || return 0
  out=$("$UPDATE" apply --root "$ROOT" --state "$STATE_DIR" 2>&1) || rc=$?
  case "$rc" in
    10)
      echo "встал выпуск $(tr -d '[:space:]' < "$CUR/RELEASE")"
      TROPA_NEW_RELEASE=1 exec "$CUR/tropa" session
      ;;
    0) ;;
    *) printf '%s\n' "$out" >&2 ;;
  esac
}

case "${1:-}" in
  up)
    # Вся сессия под одним sudo: пароль спрашивается один раз, а службы, модуль ядра,
    # ключ и подмена выпуска требуют root.
    exec sudo "$CUR/tropa" session
    ;;
  session)
    if [ "$(id -u)" != 0 ]; then
      echo "tropa: session работает от root — запускай tropa up" >&2
      exit 1
    fi
    if [ ! -s "$KEY_FILE" ]; then
      no_key
    fi
    apply_release
    kmod_off
    old_off
    # Каталог файла состояния клиента: клиент пишет туда сам, а каталога не заводит.
    mkdir -p "$RUN_DIR" && chmod 0755 "$RUN_DIR"
    log_rotate
    # Без tropa-guard подключение шло бы с открытым IPv6 и с именами мимо туннеля.
    svc_on tropa-guard "$GUARD_BIN" "$GUARD_CONF" run --config "$GUARD_CONF" \
      || { echo "служба tropa-guard не встала" >&2; exit 1; }
    # Диагностика выход в интернет не держит: нет её файлов или она не встала — идём дальше.
    if [ -x "$DIAG_BIN" ] && [ -s "$DIAG_CONF" ]; then
      svc_on tropa-diag "$DIAG_BIN" "$DIAG_CONF" --config "$DIAG_CONF" \
        || echo "служба диагностики не встала" >&2
    fi
    client=
    sleeper=
    # TERM: на нём клиент снимает свои маршруты и выходит.
    stop_client() {
      [ -z "$sleeper" ] || kill "$sleeper" 2>/dev/null || true
      if [ -n "$client" ] && kill -0 "$client" 2>/dev/null; then
        kill -TERM "$client" 2>/dev/null || true
        while kill -0 "$client" 2>/dev/null; do wait "$client" 2>/dev/null || sleep 0.1; done
      fi
    }
    # Пока клиент гасится, повторный сигнал (двойной Ctrl-C, закрытое окно)
    # игнорируется. Снимать IPv6, запрет имён, резолвер и маршруты session не нужно:
    # их снимает tropa-guard, когда клиент перестал писать файл состояния.
    on_signal() {
      trap '' INT TERM HUP
      stop_client
      exit $(( 128 + $1 ))
    }
    trap 'on_signal 2' INT
    trap 'on_signal 15' TERM
    trap 'on_signal 1' HUP
    # --full-tunnel: без него клиент маршрута по умолчанию не ставит. Ключ — переменной
    # TROPA_CONNECTION_KEY (клиент читает её и сам стирает, run.rs форка), а не
    # аргументом -k: командную строку видно в ps, а ключ несёт PSK. Вывод клиента — в журнал.
    TROPA_CONNECTION_KEY="$(tr -d '[:space:]' < "$KEY_FILE")" "$BIN" --full-tunnel >>"$LOG_FILE" 2>&1 &
    client=$!
    # Сессия нового выпуска: туннель обязан встать, иначе выпуск возвращается.
    if [ -n "${TROPA_NEW_RELEASE:-}" ]; then
      waited=0
      until connected_pid "$client"; do
        if ! kill -0 "$client" 2>/dev/null || [ "$waited" -ge "$NEW_RELEASE_WAIT" ]; then
          stop_client
          bad_release=$(tr -d '[:space:]' < "$CUR/RELEASE" 2>/dev/null || true)
          "$UPDATE" rollback --root "$ROOT" --state "$STATE_DIR" || true
          echo "выпуск $bad_release не поднял туннель, вернулся прежний" >&2
          unset TROPA_NEW_RELEASE
          exec "$CUR/tropa" session
        fi
        sleep "$NEW_RELEASE_POLL"
        waited=$(( waited + NEW_RELEASE_POLL ))
      done
    fi
    # Пока клиент идёт: раз в CHECK_EVERY секунд — не вышел ли новый выпуск.
    while :; do
      sleep "$CHECK_EVERY" &
      sleeper=$!
      rc=0
      wait -n || rc=$?
      if ! kill -0 "$client" 2>/dev/null; then
        kill "$sleeper" 2>/dev/null || true
        wait "$client" 2>/dev/null || true
        exit "$rc"
      fi
      wait "$sleeper" 2>/dev/null || true
      sleeper=
      crc=0
      "$UPDATE" check --root "$ROOT" --state "$STATE_DIR" >/dev/null 2>&1 || crc=$?
      if [ "$crc" -eq 10 ]; then
        stop_client
        arc=0
        "$UPDATE" apply --root "$ROOT" --state "$STATE_DIR" >/dev/null 2>&1 || arc=$?
        if [ "$arc" -eq 10 ]; then
          echo "встал выпуск $(tr -d '[:space:]' < "$CUR/RELEASE")"
          TROPA_NEW_RELEASE=1 exec "$CUR/tropa" session
        fi
        unset TROPA_NEW_RELEASE
        exec "$CUR/tropa" session
      fi
    done
    ;;
  check)
    key=$(read_key)
    hosts=$(key_hosts "$key")
    ip=$(curl -s --max-time 15 https://ifconfig.me || true)
    if [ -z "$ip" ]; then
      echo "tropa: ifconfig.me не ответил — внешний адрес неизвестен" >&2
      rc=2
    else
      n=0; leg=
      while read -r h; do
        n=$(( n + 1 ))
        if [ "$h" = "$ip" ]; then leg=$n; break; fi
      done <<<"$hosts"
      if [ -n "$leg" ]; then
        echo "внешний адрес $ip совпадает с адресом ноги $leg из ключа — клиент выходит в интернет через ногу"
        rc=0
      else
        echo "внешний адрес $ip НЕ совпадает ни с одной ногой ключа: $(tr '\n' ' ' <<<"$hosts")"
        rc=1
      fi
    fi
    echo "выпуск $(tr -d '[:space:]' < "$CUR/RELEASE" 2>/dev/null || echo неизвестен)"
    # Внешний адрес говорит о маршруте трафика, а открытый IPv6 и имя, разрешённое
    # роутером мимо туннеля, ведут браузер мимо туннеля и при верном адресе.
    "$GUARD_BIN" status || { [ "$rc" -eq 0 ] && rc=1; }
    exit "$rc"
    ;;
  *)
    echo "использование: tropa up | check" >&2
    exit 64
    ;;
esac
