#!/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    — работает ли Тропа Жоры и что не так, обычными словами (tropa-guard status);
#   tropa session  — служебная: то, что up запускает под sudo; на старте и раз в 10 минут
#                    сверяет выпуск и встаёт на новый сама (m:доезд-выпуска-tropa).
# Регресс — 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}
CUR=$ROOT/current
# Договор с переключателем VPN на NixOS (~/dos/nix, vpn-switch.sh и vpn-switch.nix): он читает
# обёртку построчно, sed-ом `^BIN='…'$` и `^KEY_FILE='…'$`, и зовёт `session` без терминала.
# Эти две строки — литералы с действующими путями; регресс tropa.test.sh читает их так же.
BIN='/opt/tropa/current/tropa-client'
KEY_FILE='/var/lib/tropa/key'
# Основа ссылки подписки (m:ключи-tropa, m:подписка-tropa): ссылка — SUB_BASE и знак. Хост и
# путь рендерит тег client из vpn_panel_host и tropa_sub_path.
SUB_BASE='https://rolder.net/tropa/sub/'
[ "$ROOT" = /opt/tropa ] || BIN=$CUR/tropa-client
[ "$STATE_DIR" = /var/lib/tropa ] || KEY_FILE=$STATE_DIR/key
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}
# Файл подписки: последняя принятая клиентом строка подключения (m:крейт-клиента-tropa).
SUB_FILE=$STATE_DIR/subscription
# Журнал клиента (клиент форка на 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}

no_key() {
  echo "tropa: нет ссылки подписки в $KEY_FILE — поставь клиент установщиком:" >&2
  echo "  sudo sh install.sh '<ссылка подписки>'" >&2
  exit 1
}

# Переход с ключа на подписку (m:клиент-tropa). Установщик прежнего выпуска клал в
# $KEY_FILE строку подключения tropa://, а теперь там лежит ссылка подписки. Строка несёт
# PSK в поле p, и знак подписки выводится из него так же, как его считает зона (m:ключи-tropa):
# SHA-256 от «tropa-sub-v1» и 32 байтов PSK. Ссылка ставится на место ключа, только когда
# она уже отдаёт подписку (subscription-check клиента): 404 отдаёт и подписка, которую зона
# ещё не положила, и клиент со ссылкой на 404 остался бы без подключения. Строка подключения
# уходит в файл подписки, откуда клиент берёт её, пока подписка не ответила. Файл ключа
# принадлежит root, а root есть только у session.
key_upgrade() {
  local cur b j p tok link
  cur=$(tr -d '[:space:]' < "$KEY_FILE")
  case "$cur" in tropa://?*) ;; *) return 0 ;; esac
  b=$(printf '%s' "${cur#tropa://}" | tr -- '-_' '+/')
  # Длина — через wc, а не длиной параметра bash: её запись «$, {, #» jinja
  # читает как начало комментария (регресс рендерит шаблон jinja2).
  while [ $(( $(printf '%s' "$b" | wc -c) % 4 )) -ne 0 ]; do b="$b="; done
  j=$(printf '%s' "$b" | base64 -d 2>/dev/null) || j=
  p=$(printf '%s' "$j" | sed -n 's/.*"p":"\([^"]*\)".*/\1/p' | head -n 1)
  if [ -z "$p" ] || ! tok=$({ printf 'tropa-sub-v1'; printf '%s' "$p" | base64 -d; } 2>/dev/null | sha256sum | cut -c1-64) \
      || [ "$(printf '%s' "$tok" | wc -c)" -ne 64 ]; then
    echo "ключ не разобран: ссылку подписки даст установщик" >&2
    return 0
  fi
  link=$SUB_BASE$tok
  if ! TROPA_CONNECTION_KEY=$link "$BIN" subscription-check >/dev/null 2>&1; then
    echo "ссылка подписки ещё не отвечает: ключ остаётся" >&2
    return 0
  fi
  if [ ! -e "$SUB_FILE" ]; then
    ( umask 077; printf '%s\n' "$cur" > "$SUB_FILE.new" )
    chmod 0600 "$SUB_FILE.new"
    mv -f "$SUB_FILE.new" "$SUB_FILE"
  fi
  ( umask 077; printf '%s\n' "$link" > "$KEY_FILE.new" )
  chmod 0600 "$KEY_FILE.new"
  mv -f "$KEY_FILE.new" "$KEY_FILE"
  echo "ключ заменён ссылкой подписки"
}

# Поле строки файла состояния клиента: "name": "значение".
status_field() {
  printf '%s' "$1" | sed -n "s/.*\"$2\" *: *\"\([^\"]*\)\".*/\1/p" | head -n 1
}

# Журнал клиента: больше 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 — служба не встала.
# Запись несёт и пути бинаря и настроек: служба, поднятая из других путей (прежний
# тег client клал их в ~/.local/share/tropa/), перезапускается из выпуска и при той же сборке.
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' ' ')$bin $conf"
  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]|\$)"
}

# Имя стоящего выпуска: журнал сессии называет и новый выпуск, и тот, на месте которого
# он встал, — по журналу видно, откуда пришёл доезд.
release_now() { tr -d '[:space:]' < "$CUR/RELEASE" 2>/dev/null || echo неизвестен; }

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

# Список маршрутизации (m:доезд-выпуска-tropa, команда routing): доезжает отдельно от выпуска
# и мимо туннеля. Любой код идёт дальше: код 1 оставляет прежнюю версию списка, а без неё
# служба стража ведёт весь трафик в туннель; новую версию служба берёт сама.
routing_update() {
  local out rc=0
  [ -x "$UPDATE" ] || return 0
  out=$("$UPDATE" routing --out "$STATE_DIR/routing.json" --state "$STATE_DIR" 2>&1) || rc=$?
  [ "$rc" -eq 0 ] || [ "$rc" -eq 10 ] || printf '%s\n' "$out" >&2
  return 0
}

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
    routing_update
    key_upgrade
    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. --subscription-cache — файл, куда клиент кладёт последнюю
    # принятую строку и откуда берёт её, пока подписка не ответила. Вывод клиента — в журнал.
    start_client() {
      TROPA_CONNECTION_KEY="$(tr -d '[:space:]' < "$KEY_FILE")" \
        "$BIN" --full-tunnel --subscription-cache "$SUB_FILE" >>"$LOG_FILE" 2>&1 &
      client=$!
    }
    start_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
        # Убитый sleep забирается сразу: иначе следующий wait -n вернёт его код вместо кода клиента.
        kill "$sleeper" 2>/dev/null || true
        wait "$sleeper" 2>/dev/null || true
        sleeper=
        case "$rc" in
          75)
            # Настройки подписки сменились: клиент вышел перед новой попыткой и встаёт
            # заново с новой строкой, службы не трогаются (m:крейт-клиента-tropa).
            echo "настройки подписки сменились: клиент встаёт заново"
            start_client
            continue
            ;;
          77)
            echo "подписка отозвана: Тропа Жоры для этой учётки снята"
            exit 77
            ;;
        esac
        exit "$rc"
      fi
      wait "$sleeper" 2>/dev/null || true
      sleeper=
      routing_update
      crc=0
      "$UPDATE" check --root "$ROOT" --state "$STATE_DIR" >/dev/null 2>&1 || crc=$?
      if [ "$crc" -eq 10 ]; then
        # apply кладёт выпуск рядом с прежним и переставляет ссылки переименованием,
        # поэтому клиент гасится только после того, как выпуск встал (m:доезд-выпуска-tropa).
        arc=0
        prev=$(release_now)
        aout=$("$UPDATE" apply --root "$ROOT" --state "$STATE_DIR" 2>&1) || arc=$?
        if [ "$arc" -eq 10 ]; then
          stop_client
          echo "встал выпуск $(release_now) вместо $prev"
          TROPA_NEW_RELEASE=1 exec "$CUR/tropa" session
        fi
        [ "$arc" -eq 0 ] || printf '%s\n' "$aout" >&2
      fi
    done
    ;;
  check)
    # Ответ, его строки и признаки держит tropa-guard status (m:страж-tropa): слова одни на
    # Linux и Windows. Файл состояния клиента пишет root, поэтому под sudo. Своих строк нет.
    exec sudo "$GUARD_BIN" status --config "$GUARD_CONF"
    ;;
  *)
    echo "использование: tropa up | check" >&2
    exit 64
    ;;
esac
