#!/bin/bash
# Помощник обновления. Единственное место, где скачанный архив становится
# кодом, работающим от root.
#
# ГРАНИЦА ДОВЕРИЯ ПРОХОДИТ ЗДЕСЬ, а не в службе. Служба (пользователь spektr)
# скачивает архив и сверяет его сама — но помощник ей НЕ ВЕРИТ: если процесс
# службы скомпрометирован, он подсунет любой файл. Поэтому подпись проверяется
# ЗАНОВО, системным openssl и открытым ключом из /opt/spektr/config — оба
# принадлежат root, служба их изменить не может. Ни venv службы, ни её код
# сюда не привлекаются намеренно.
#
# ПОЧЕМУ systemd-run. Служба живёт в своей cgroup с KillMode=control-group:
# всё, что она породила, умирает вместе с ней. А install.sh первым делом делает
# `systemctl stop spektr` — то есть убил бы и этот помощник, будь он потомком
# службы. Через systemd-run установка идёт в СВОЕЙ переходной единице и
# останавливать службу может спокойно.
#
# ОТКАТ. Перед установкой код (не данные — они в /var/lib и не трогаются)
# копируется в сторону. Если после установки служба не поднялась за
# UP_TIMEOUT секунд, код возвращается на место и служба перезапускается.
# Граница отката известна и записана: venv не откатывается. install.sh ставит
# зависимости в существующий venv, и если новая версия их поменяла, старый код
# на новом venv может не подняться. Для правок в пределах одной минорной
# версии это не случается; для мажорных обновлений откат нужен полный.
#
# Вызывается как:  sudo /opt/spektr/bin/spektr-update install <архив>
#                  архив — в /var/lib/spektr/updates/, рядом обязан лежать
#                  <архив>.sig (payload+sig, как выпускает tools/update_sign.py)
set -euo pipefail

# Пути переопределяются ТОЛЬКО для теста (tools/test_update_helper.py гоняет
# проверки без root в своём каталоге). sudo по умолчанию сбрасывает
# окружение (env_reset), так что из службы подсунуть их нельзя.
INSTALL_DIR="${SPEKTR_UPDATE_INSTALL_DIR:-/opt/spektr}"
DATA_DIR="${SPEKTR_UPDATE_DATA_DIR:-/var/lib/spektr}"
STAGE="$DATA_DIR/updates"
STATUS="$STAGE/status.json"
LOG="$STAGE/install.log"
PUB="$INSTALL_DIR/config/update_pub.pem"
UP_TIMEOUT=150
SVC_USER=spektr

# Состояние пишем в файл, который читает служба (и после перезапуска тоже):
# помощник работает в отрыве от неё и иначе о ходе дела рассказать некому.
status() {   # status <state> <message>
  local at; at="$(date -Is)"
  python3 - "$STATUS" "$1" "$2" "$at" <<'PY'
import json, sys, os
p, state, msg, at = sys.argv[1:5]
try: d = json.load(open(p))
except Exception: d = {}
d.update({"state": state, "message": msg, "at": at})
tmp = p + ".tmp"
with open(tmp, "w") as f: json.dump(d, f, ensure_ascii=False)
os.replace(tmp, p)
PY
  chown "$SVC_USER:$SVC_USER" "$STATUS" 2>/dev/null || true
}
die() { status failed "$1"; echo "$1" >&2; exit 1; }

cmd="${1:-}"
case "$cmd" in
  install) ;;
  status)  cat "$STATUS" 2>/dev/null || echo '{}'; exit 0 ;;
  *) echo "usage: spektr-update install <архив> | status" >&2; exit 2 ;;
esac

# ---------------------------------------------------------------- аргумент
# Разрешён РОВНО один каталог и РОВНО одна форма имени. Всё остальное —
# отказ до единого действия: путь через «..», символическая ссылка наружу,
# файл с чужим именем — это не «странный ввод», а попытка.
arch="${2:-}"
[ -n "$arch" ] || die "не указан архив"
real="$(realpath -e -- "$arch" 2>/dev/null || true)"
[ -n "$real" ] || die "архив не найден: $arch"
case "$real" in "$STAGE"/*) ;; *) die "архив обязан лежать в $STAGE" ;; esac
[ -f "$real" ] && [ ! -L "$arch" ] || die "архив — не обычный файл"
name="$(basename -- "$real")"
[[ "$name" =~ ^spektr-[0-9][0-9.]*-py[0-9]+\.[0-9]+\.tar\.gz$ ]] || die "имя архива не по форме: $name"
sig="$real.sig"
[ -f "$sig" ] || die "рядом нет файла подписи: $sig"
[ -f "$PUB" ] || die "нет ключа проверки $PUB — поставка неполная"
mkdir -p "$STAGE"

# ---------------------------------------------------------------- подпись
status verifying "Проверяю подпись"
tmpd="$(mktemp -d)"; trap 'rm -rf "$tmpd"' EXIT
# payload и sig — из JSON, стандартной библиотекой system python3 (root),
# без venv службы
python3 - "$sig" "$tmpd" <<'PY' || die "файл подписи не разбирается"
import json, base64, sys
d = json.load(open(sys.argv[1]))
open(sys.argv[2] + "/payload", "wb").write(base64.b64decode(d["payload"]))
open(sys.argv[2] + "/sig", "wb").write(base64.b64decode(d["sig"]))
PY
openssl pkeyutl -verify -pubin -inkey "$PUB" -rawin \
        -in "$tmpd/payload" -sigfile "$tmpd/sig" >/dev/null 2>&1 \
  || die "подпись не подтверждена — архив не от нас, установка отменена"

# описание подписано — теперь архив обязан ему соответствовать
inst_ver="$(cat "$INSTALL_DIR/VERSION" 2>/dev/null || echo 0)"
py_ver="$(cat "$INSTALL_DIR/PYTHON_VERSION" 2>/dev/null || echo "")"
check="$(python3 - "$tmpd/payload" "$real" "$name" "$inst_ver" "$py_ver" <<'PY'
import json, hashlib, sys, os
payload, path, name, inst_ver, py_ver = sys.argv[1:6]
d = json.load(open(payload))
def nums(v): return tuple(int(x) for x in str(v).split(".") if x.isdigit())
if d.get("name") != name: sys.exit("подпись выдана для другого файла: %s" % d.get("name"))
if os.path.getsize(path) != d.get("size"): sys.exit("размер архива не совпадает с подписанным")
h = hashlib.sha256()
with open(path, "rb") as f:
    for chunk in iter(lambda: f.read(1 << 20), b""): h.update(chunk)
if h.hexdigest() != d.get("sha256"): sys.exit("контрольная сумма архива не совпадает с подписанной")
if py_ver and d.get("python") != py_ver: sys.exit("сборка под Python %s, установлена под %s" % (d.get("python"), py_ver))
# ЗАЩИТА ОТ ПОВТОРА: подпись вечна, старую подлинную сборку можно подсунуть
# снова. Единственная защита — ставим только версию СТАРШЕ установленной.
if not nums(d.get("version", "")) > nums(inst_ver): sys.exit("версия %s не новее установленной %s" % (d.get("version"), inst_ver))
print(d["version"])
PY
)" || die "$check"
new_ver="$check"
status verifying "Подпись верна: версия $new_ver"

# ---------------------------------------------------------------- место
need_kb=$(( $(stat -c %s "$real") / 1024 * 4 ))
have_kb=$(df -Pk "$STAGE" | awk 'NR==2{print $4}')
[ "$have_kb" -gt "$need_kb" ] || die "мало места: нужно ~$((need_kb/1024)) МБ, свободно $((have_kb/1024)) МБ"

# ---------------------------------------------------------------- запуск
# Всё дальнейшее — в переходной единице systemd, чтобы `systemctl stop spektr`
# внутри install.sh не убил нас самих. Единица получает готовый сценарий.
run="$STAGE/run.sh"
cat > "$run" <<EOS
#!/bin/bash
set -uo pipefail
STAGE="$STAGE"; STATUS="$STATUS"; LOG="$LOG"; INSTALL_DIR="$INSTALL_DIR"
SVC_USER="$SVC_USER"; UP_TIMEOUT=$UP_TIMEOUT; new_ver="$new_ver"; real="$real"
$(declare -f status)
prev="\$STAGE/prev"
status backup "Сохраняю прежний код для отката"
rm -rf "\$prev"; mkdir -p "\$prev"
for d in backend frontend third_party deploy bin VERSION PYTHON_VERSION requirements.txt; do
  [ -e "\$INSTALL_DIR/\$d" ] && cp -a "\$INSTALL_DIR/\$d" "\$prev/" || true
done
status installing "Распаковываю и устанавливаю \$new_ver"
work="\$STAGE/work"; rm -rf "\$work"; mkdir -p "\$work"
if ! tar xzf "\$real" -C "\$work" >>"\$LOG" 2>&1; then status failed "архив не распаковался"; exit 1; fi
src="\$(find "\$work" -maxdepth 1 -mindepth 1 -type d | head -1)"
if ( cd "\$src" && bash install.sh ) >>"\$LOG" 2>&1; then
  status starting "Установлено, жду запуска службы"
else
  status rollback "Установка не удалась, возвращаю прежний код"
  rollback=1
fi
# Ждём не «active» у systemd (падающая циклом служба тоже active), а
# СЛУШАЮЩИЙ СОКЕТ У ПРОЦЕССА СЛУЖБЫ: uvicorn открывает его только после
# того, как приложение стартовало. Порт и адрес при этом не важны — первая
# версия стучалась в 127.0.0.1:8080 и на машине с другим портом или
# привязкой к конкретному адресу откатывала ИСПРАВНУЮ установку.
# Без ss и без зависимости от порта: сокеты ИМЕННО ЭТОГО процесса (по
# /proc/<pid>/fd) сверяются с LISTEN-записями (/proc/net/tcp*). Таблица
# /proc/<pid>/net/tcp для этого не годится — она общая на всё сетевое
# пространство и показала бы и go2rtc, и сервер времени.
alive() {
  local pid; pid="\$(systemctl show spektr -p MainPID --value 2>/dev/null)"
  [ -n "\$pid" ] && [ "\$pid" -gt 0 ] 2>/dev/null || return 1
  python3 - "\$pid" <<'PYALIVE'
import os, sys
pid = sys.argv[1]; inodes = set()
try:
    for fd in os.listdir(f"/proc/{pid}/fd"):
        try: l = os.readlink(f"/proc/{pid}/fd/{fd}")
        except OSError: continue
        if l.startswith("socket:["): inodes.add(l[8:-1])
except OSError: sys.exit(1)
for f in ("/proc/net/tcp", "/proc/net/tcp6"):
    try:
        for line in open(f).read().splitlines()[1:]:
            parts = line.split()
            if len(parts) > 9 and parts[3] == "0A" and parts[9] in inodes: sys.exit(0)
    except OSError: pass
sys.exit(1)
PYALIVE
}
if [ -z "\${rollback:-}" ]; then
  ok=""
  for i in \$(seq 1 \$UP_TIMEOUT); do
    if alive; then ok=1; break; fi
    sleep 1
  done
  if [ -z "\$ok" ]; then
    # Диагноз — В ЖУРНАЛ, до отката: после него причина исчезнет вместе с
    # новым кодом. Первый настоящий откат у заказчика (11.09) оставил только
    # «служба не ответила», и разобрать его было не по чему.
    { echo "=== служба не поднялась за \$UP_TIMEOUT с; systemctl:"; systemctl status spektr --no-pager -n 0 2>&1 | head -8
      echo "=== последние строки журнала службы:"; journalctl -u spektr -n 40 --no-pager 2>&1 | tail -40; } >>"\$LOG"
    status rollback "Служба не поднялась за \$UP_TIMEOUT с — возвращаю прежний код"; rollback=1
  fi
fi
if [ -n "\${rollback:-}" ]; then
  systemctl stop spektr 2>/dev/null || true
  for d in backend frontend third_party deploy bin VERSION PYTHON_VERSION requirements.txt; do
    if [ -e "\$prev/\$d" ]; then rm -rf "\$INSTALL_DIR/\$d"; cp -a "\$prev/\$d" "\$INSTALL_DIR/"; fi
  done
  systemctl start spektr 2>/dev/null || true
  status failed "Обновление откачено: прежняя версия возвращена. Подробности в \$LOG"
  rm -rf "\$work" "\$real" "\$real.sig"; exit 1
fi
status done "Обновлено до \$new_ver"
rm -rf "\$work" "\$real" "\$real.sig"
EOS
chmod 700 "$run"
: > "$LOG"; chown "$SVC_USER:$SVC_USER" "$LOG" 2>/dev/null || true
status installing "Запускаю установку $new_ver"
# Только для теста: всё проверено, сценарий записан, но запускать не надо
if [ -n "${SPEKTR_UPDATE_DRY_RUN:-}" ]; then echo "DRY-RUN: готово к запуску $new_ver"; exit 0; fi
# --collect: единица исчезает после завершения, даже неудачного; иначе
# следующее обновление упёрлось бы в «unit already exists»
systemd-run --unit="spektr-update-$(date +%s)" --collect --quiet \
  --description="СПЕКТР: обновление до $new_ver" \
  -p StandardOutput=append:"$LOG" -p StandardError=append:"$LOG" \
  /bin/bash "$run" \
  || die "не удалось запустить установку через systemd-run"
echo "запущено: обновление до $new_ver, ход — в $STATUS"
