#!/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 из tropa_sub_host и tropa_sub_path (group_vars/all/tropa.yml).
SUB_BASE='https://api.rovenp5.org/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): её читает служба диагностики.
STOP_FILE=$STATE_DIR/stop.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://?*) ;;
    "$SUB_BASE"*) return 0 ;;
    https://*) link_upgrade "$cur"; return 0 ;;
    *) 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 "ключ заменён ссылкой подписки"
}

# Смена хоста ссылки (m:клиент-tropa, m:подписка-tropa): в $KEY_FILE лежит ссылка https://
# не на SUB_BASE — ссылка той же подписки с прежней основой. Знак — часть после последнего
# «/»: он выводится из PSK, а не из хоста, и зона кладёт под ним ту же подписку при любой
# основе. Новая ссылка встаёт на место прежней, только когда уже отдаёт подписку; файл
# подписки остаётся — строка в нём принадлежит той же подписке.
link_upgrade() {
  local cur=$1 tok link
  tok=${cur##*/}
  case "$tok" in
    *[!0-9a-f]*|'') tok= ;;
  esac
  if [ -z "$tok" ] || [ "$(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
  ( 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
}

# Отметка остановки клиента $client: {"pid","cause"[,"release"],"ts_ms"} — во временный файл
# того же каталога и переименованием, потому что служба диагностики читает файл в любую
# секунду. Причины: off, shutdown, old_vpn, update (с выпуском $2), settings, revoked, limit. Не легла —
# строка в stderr, ход не меняется: подключение важнее отметки, а остановка без неё
# ляжет в диагностике падением клиента.
put_stop() {
  local cause=$1 release=${2:-} rel= ts
  [ -n "${client:-}" ] || return 0
  [ -z "$release" ] || rel=",\"release\":\"$release\""
  ts=$(date +%s%3N) || ts=0
  { printf '{"pid":%s,"cause":"%s"%s,"ts_ms":%s}\n' "$client" "$cause" "$rel" "$ts" > "$STOP_FILE.tmp" \
      && mv -f "$STOP_FILE.tmp" "$STOP_FILE"; } 2>/dev/null \
    || echo "отметка остановки не легла: $STOP_FILE" >&2
  return 0
}

# Переключатель VPN (~/dos/nix) сам погасил tropa-session, уйдя с молчащей ноги на прежний
# VPN (m:клиент-tropa): $$ есть главный процесс unit'а tropa-session (а не сеанс из
# терминала), выбор в файле переключателя — auto и файл переписан не меньше чем за 5 с
# до сигнала (команда vpn человека переписывает его прежде, чем переключатель берётся за
# переход, и vpn old либо vpn off гасят сеанс уже при слове old либо off).
SWITCH_CHOICE=${TROPA_SWITCH_CHOICE:-/var/lib/vpn-switch/choice}
switch_left() {
  local main first mt now
  main=$(systemctl show -p MainPID --value tropa-session.service 2>/dev/null || true)
  [ "$main" = "$$" ] || return 1
  first=$(head -n 1 "$SWITCH_CHOICE" 2>/dev/null | awk '{ print $1 }' || true)
  [ "$first" = auto ] || return 1
  mt=$(stat -c %Y "$SWITCH_CHOICE" 2>/dev/null) || return 1
  now=$(date +%s)
  [ $(( now - mt )) -ge 5 ]
}

# Ноги отвергли выключенный на лимите трафика ключ: клиент кончился кодом 69. Отметка
# limit, сообщение и код 69 без нового запуска — до первого тика нового месяца нога
# отвергает ключ на каждой попытке (m:ключи-tropa).
limit_stop() {
  echo "лимит трафика исчерпан: до начала следующего месяца ноги не принимают подключения по этой подписке"
  put_stop limit
  exit 69
}

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
      # Менеджер systemd гасит систему — выключение компьютера; переключатель VPN ушёл с
      # молчащей ноги на прежний VPN — old_vpn; иначе выключил человек.
      if [ "$(systemctl is-system-running 2>/dev/null || true)" = stopping ]; then
        put_stop shutdown
      elif switch_left; then
        put_stop old_vpn
      else
        put_stop off
      fi
      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; then
          # Клиент кончился кодом 69: ноги отвергли ключ, а не выпуск — выпуск не возвращается.
          nrc=0
          wait "$client" 2>/dev/null || nrc=$?
          [ "$nrc" -ne 69 ] || limit_stop
        fi
        if ! kill -0 "$client" 2>/dev/null || [ "$waited" -ge "$NEW_RELEASE_WAIT" ]; then
          # Отметка update с выпуском, на который клиент возвращается.
          put_stop update "$(tr -d '[:space:]' < "$ROOT/previous/RELEASE" 2>/dev/null || true)"
          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=
        # Код клиента — wait по pid: wait -n при sleep, кончившемся в ту же секунду, отдаёт код sleep.
        # 127 — клиент уже не ребёнок оболочки: остаётся код wait -n.
        wrc=0
        wait "$client" 2>/dev/null || wrc=$?
        [ "$wrc" -eq 127 ] || rc=$wrc
        case "$rc" in
          130|143)
            # Клиент получил INT либо TERM сам, мимо ловушки session (vpn off переключателя
            # шлёт TERM клиенту). Отметка с pid клиента уже лежит (tropa-guard: legs, ipv6, dns) —
            # её не трогаем; иначе выключение компьютера либо человека (m:клиент-tropa).
            if ! grep -Eq "\"pid\":$client[,}]" "$STOP_FILE" 2>/dev/null; then
              if [ "$(systemctl is-system-running 2>/dev/null || true)" = stopping ]; then
                put_stop shutdown
              else
                put_stop off
              fi
            fi
            ;;
          75)
            # Настройки подписки сменились: клиент вышел перед новой попыткой и встаёт
            # заново с новой строкой, службы не трогаются (m:крейт-клиента-tropa).
            echo "настройки подписки сменились: клиент встаёт заново"
            put_stop settings
            start_client
            continue
            ;;
          77)
            echo "подписка отозвана: Тропа Жоры для этой учётки снята"
            put_stop revoked
            exit 77
            ;;
          69)
            limit_stop
            ;;
        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
          echo "встал выпуск $(release_now) вместо $prev"
          put_stop update "$(release_now)"
          stop_client
          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
