#!/usr/bin/env bash
# mesh-net-pack.sh — packs your own headscale network into one file for the import into mesh-net.
#
# Run it on the server where your headscale works, as root (the knowledge base article "Move your headscale network
# to mesh-net" gives the exact line):
#   curl -fsSL https://<mesh-net>/ext/mesh-net-pack.sh | sudo bash -s -- --lang en
#
# What it does
#   1. Finds headscale: a systemd service, a running `headscale serve` process or a docker container that runs it
#      (the config and data directories of a container — from its mounts, `docker inspect`). Several or none found —
#      it explains and asks for the path to config.yaml (or to db.sqlite).
#   2. Reads the config: where the database is (database.sqlite.path; db_path in old versions). PostgreSQL instead of
#      SQLite, headscale older than 0.23 or newer than 0.28, address ranges outside 100.64.0.0/10 and
#      fd7a:115c:a1e0::/48 — explains what to do and stops.
#   3. Copies the database WITHOUT stopping headscale: `sqlite3 .backup` or python3 (sqlite3.backup); the copy is
#      checked (integrity_check) and switched to journal_mode=DELETE. Neither tool is installed — it asks whether it
#      may stop headscale for a few seconds, copies the file and starts headscale again (if headscale didn't close its
#      database cleanly, it is started and stopped once or twice more; still not clean — it asks to install sqlite3).
#   4. Writes mesh-net-import-YYYY-MM-DD.tar.gz (db.sqlite + config.yaml, mode 600) into the home directory of the
#      person who ran it (/root for root, /home/<you> under sudo) and prints how to download it.
# What it changes on this server
#   It creates the archive and the log /var/log/mesh-net-pack.log, nothing else. An archive with the same name left
#   from an earlier run is moved to /var/backups/mesh-net-<time>/. headscale's database and config are only read; in
#   the COPY of config.yaml the values of client_secret and pass are blanked. Nothing is installed.
# How to undo
#   Delete the archive (and the log). If you allowed the stop and headscale didn't come back by itself:
#   systemctl start headscale   (or: docker start <container>).
#
# RU. Упаковывает вашу сеть headscale в один файл для импорта в mesh-net. Запускать на сервере с headscale от root:
#   curl -fsSL https://<mesh-net>/ext/mesh-net-pack.sh | sudo bash -s -- --lang ru
# Находит headscale (служба systemd, процесс или контейнер docker), по конфигу — базу; копирует её, не останавливая
# headscale (sqlite3 или python3; без них — с вашего согласия останавливает headscale на пару секунд); пишет
# mesh-net-import-ГГГГ-ММ-ДД.tar.gz в домашний каталог запустившего. Меняет только это: архив и журнал
# /var/log/mesh-net-pack.log; прежний архив с тем же именем переносится в /var/backups/mesh-net-<время>/. База и
# конфиг headscale не меняются. Откат — удалить архив и журнал.
set -euo pipefail
umask 077

MAX_MB_DEFAULT=64            # mesh-net's import limit, app/config.py IMPORT_MAX_MB (tests compare the two)
HS_MIN=23                    # headscale 0.23 … 0.28: the database versions the import accepts
HS_MAX=28
LOG=/var/log/mesh-net-pack.log
BACKUP_ROOT=/var/backups
PROC=/proc                   # tests point these two at fixtures
SYSTEMD_RUN=/run/systemd/system
TTY=/dev/tty
RETRY_PAUSE=3                # seconds headscale runs between two stops when the first one wasn't clean

LNG=en ASSUME_YES=0 MAX_MB=$MAX_MB_DEFAULT ARG_CONFIG="" ARG_DB="" ARG_OUT=""
LOG_ON=0 WORK="" RESTART_CMD="" DONE=0 SQL_TOOL="" ANSWER=""
CHANGED=() NOTES=()

# Candidates: one index per headscale found. kind: systemd | process | docker | path.
CN=0
C_KIND=() C_NAME=() C_CONF=() C_VER=() C_CTL=() C_RUN=() C_CWD=() C_HOME=() C_ENV=() C_MOUNTS=() C_MERGED=()
SEL=-1
# What was found in the chosen one.
CONF="" DB_PATH="" DB_SHOWN="" DB_TYPE="" HS_VER=""

# ---------------------------------------------------------------- words and output

L() { if [ "$LNG" = ru ]; then printf '%s' "$2"; else printf '%s' "$1"; fi; }

log() {
    [ "$LOG_ON" = 1 ] || return 0
    printf '%s %s\n' "$(date '+%F %T')" "$*" >>"$LOG" 2>/dev/null || true
}
say() { printf '%s\n' "$*"; log "$*"; }
item() { printf '      %s\n' "$*"; log "  $*"; }
warn() { printf '  ! %s\n' "$*"; log "WARN $*"; }
step() { printf '\n[%s/3] %s\n' "$1" "$2"; log "STEP $1 $2"; }

# Refusal: the reason and what to do; nothing is left behind; exit code 1.
die() {
    local line
    printf '\n%s\n' "$1" >&2
    log "STOP $1"
    shift
    for line in "$@"; do printf '%s\n' "$line" >&2; log "  $line"; done
    if [ "$LOG_ON" = 1 ]; then
        printf '%s\n' "$(L "Nothing was changed on this server; only this run's log was written: $LOG" \
                           "На сервере ничего не изменено, записан только журнал этого запуска: $LOG")" >&2
    else
        printf '%s\n' "$(L "Nothing was changed on this server." "На сервере ничего не изменено.")" >&2
    fi
    exit 1
}

human_size() {   # bytes -> "52 KB" / "1.4 MB"
    awk -v b="$1" -v kb="$(L KB КБ)" -v mb="$(L MB МБ)" -v by="$(L bytes байт)" -v comma="$(L . ,)" 'BEGIN {
        if (b < 1024) { printf "%d %s", b, by; exit }
        if (b < 1048576) { printf "%d %s", (b + 1023) / 1024, kb; exit }
        s = sprintf("%.1f", b / 1048576); sub(/\./, comma, s); printf "%s %s", s, mb }'
}

usage() {
    if [ "$LNG" = ru ]; then cat <<'EOF'
Как запускать: curl -fsSL <адрес>/ext/mesh-net-pack.sh | sudo bash -s -- [параметры]
Упаковывает вашу сеть headscale (базу SQLite и config.yaml) в один архив для импорта в mesh-net.
  --lang en|ru     язык сообщений (по умолчанию en)
  --yes            не задавать вопросов: взять найденное (нашлось несколько или ни одного — остановиться)
  --config ФАЙЛ    config.yaml вашего headscale, если скрипт не найдёт его сам
  --db ФАЙЛ        db.sqlite вашего headscale, если конфига нет
  --out КАТАЛОГ    куда положить архив (по умолчанию — ваш домашний каталог)
  --max-mb N       предел импорта mesh-net в МБ (по умолчанию 64)
EOF
    else cat <<'EOF'
Usage: curl -fsSL <address>/ext/mesh-net-pack.sh | sudo bash -s -- [options]
Packs your headscale network (its SQLite database and config.yaml) into one archive for the import into mesh-net.
  --lang en|ru     language of the messages (default: en)
  --yes            ask nothing: take what is found (several or none found — stop)
  --config FILE    your headscale's config.yaml, if the script doesn't find it itself
  --db FILE        your headscale's db.sqlite, if there is no config
  --out DIR        where to put the archive (default: your home directory)
  --max-mb N       mesh-net's import limit in MB (default: 64)
EOF
    fi
}

usage_error() {
    printf '%s\n\n' "$(L "Unknown or incomplete option: $1" "Непонятный или неполный параметр: $1")" >&2
    usage >&2
    exit 2
}

parse_args() {
    local a
    while [ $# -gt 0 ]; do
        a=$1
        case $a in
            --*=*) set -- "${a%%=*}" "${a#*=}" "${@:2}"; continue ;;
            --yes|-y) ASSUME_YES=1; shift; continue ;;
            -h|--help) usage; exit 0 ;;
        esac
        [ $# -ge 2 ] || usage_error "$a"
        case $a in
            --lang) LNG=$2 ;;
            --config) ARG_CONFIG=$2 ;;
            --db) ARG_DB=$2 ;;
            --out) ARG_OUT=$2 ;;
            --max-mb) MAX_MB=$2 ;;
            *) usage_error "$a" ;;
        esac
        shift 2
    done
    case $LNG in en|ru) ;; *) LNG=en; usage_error "--lang" ;; esac
    [[ $MAX_MB =~ ^[1-9][0-9]{0,5}$ ]] || usage_error "--max-mb"
}

# ---------------------------------------------------------------- questions (the script itself comes through a pipe)

have_tty() { ( : <"$TTY" ) 2>/dev/null; }

ask() {   # question, default -> ANSWER
    ANSWER=""
    if [ "$ASSUME_YES" = 1 ]; then
        ANSWER=$2
        log "ASK $1 -> (--yes) $ANSWER"
        return 0
    fi
    have_tty || die "$(L "There is no terminal to ask you questions." "Нет терминала, чтобы задать вопросы.")" \
                    "$(L "Run the command in an SSH session, or add --yes to take what is found." \
                         "Запустите команду в сеансе SSH или добавьте --yes, чтобы взять найденное.")"
    printf '%s ' "$1" >"$TTY"
    IFS= read -r ANSWER <"$TTY" || ANSWER=""
    ANSWER=${ANSWER#"${ANSWER%%[![:space:]]*}"}
    ANSWER=${ANSWER%"${ANSWER##*[![:space:]]}"}
    [ -n "$ANSWER" ] || ANSWER=$2
    log "ASK $1 -> $ANSWER"
}

yes_no() {   # question (default: yes) -> 0 for yes
    ask "$1 $(L '[Y/n]' '[Д/н]')" y
    case $ANSWER in
        y|Y|yes|Yes|YES|д|Д|да|Да|ДА) return 0 ;;
        *) return 1 ;;
    esac
}

# ---------------------------------------------------------------- reading config.yaml and the like

# cfg_get FILE dotted.key — the scalar value of a key in block-style YAML (what headscale's config uses): nested
# mappings by indentation, values on the same line, quotes and trailing comments stripped. Lists are skipped.
cfg_get() {
    [ -n "$1" ] && [ -r "$1" ] || return 0
    awk -v want="$2" -v q="'" '
        function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); return s }
        {
            line = $0; sub(/\r$/, "", line)
            if (line ~ /^[ \t]*(#|$)/ || line ~ /^---/ || line ~ /^[ \t]*-/) next
            match(line, /^ */); ind = RLENGTH
            rest = substr(line, ind + 1); c = index(rest, ":")
            if (c == 0) next
            key = trim(substr(rest, 1, c - 1)); val = trim(substr(rest, c + 1))
            if (key ~ /^".*"$/ || key ~ ("^" q ".*" q "$")) key = substr(key, 2, length(key) - 2)
            while (depth > 0 && ind <= inds[depth]) depth--
            depth++; inds[depth] = ind; keys[depth] = key
            path = keys[1]; for (i = 2; i <= depth; i++) path = path "." keys[i]
            if (path != want || val == "") next
            if (substr(val, 1, 1) == "\"") { val = substr(val, 2); sub(/".*$/, "", val) }
            else if (substr(val, 1, 1) == q) { val = substr(val, 2); sub(q ".*$", "", val) }
            else sub(/[ \t]+#.*$/, "", val)
            print val; exit
        }' "$1" 2>/dev/null || true
}

env_get() {   # NAME, lines NAME=VALUE -> the last value of NAME
    local line out=""
    while IFS= read -r line; do
        case $line in "$1="*) out=${line#*=} ;; esac
    done <<<"$2"
    printf '%s' "$out"
}

conf_from_argv() {   # argv (one word per line) -> the value of -c / --config
    local prev="" a
    while IFS= read -r a; do
        case $prev in -c|--config) printf '%s' "$a"; return 0 ;; esac
        case $a in --config=*|-c=*) printf '%s' "${a#*=}"; return 0 ;; esac
        prev=$a
    done <<<"$1"
}

ver_of() {   # any text -> its first version number as X.Y.Z, or nothing
    if [[ $1 =~ v?([0-9]+)\.([0-9]+)(\.([0-9]+))? ]]; then
        printf '%s.%s.%s' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[4]:-0}"
    fi
}

tag_version() {   # docker image reference -> the version in its tag, or nothing (latest, a digest…)
    local ref=${1%%@*} tag
    tag=${ref##*/}
    case $tag in *:*) ver_of "${tag##*:}" ;; esac
}

bin_version() {   # headscale binary -> its version, or nothing
    local out
    out=$("$1" version 2>/dev/null </dev/null) || return 0
    ver_of "${out%%$'\n'*}"
}

version_verdict() {   # X.Y.Z -> ok | old | new | unknown
    local v=$1 maj min
    [ -n "$v" ] || { echo unknown; return 0; }
    maj=${v%%.*}; min=${v#*.}; min=${min%%.*}
    if [ "$maj" -eq 0 ] && [ "$min" -lt "$HS_MIN" ]; then echo old
    elif [ "$maj" -eq 0 ] && [ "$min" -le "$HS_MAX" ]; then echo ok
    else echo new; fi
}

in_v4_range() {   # 100.64.0.0/10 or anything inside it
    local p=$1 a b len
    [[ $p =~ ^([0-9]+)\.([0-9]+)\.[0-9]+\.[0-9]+/([0-9]+)$ ]] || return 1
    a=${BASH_REMATCH[1]}; b=${BASH_REMATCH[2]}; len=${BASH_REMATCH[3]}
    [ "$a" -eq 100 ] && [ "$b" -ge 64 ] && [ "$b" -le 127 ] && [ "$len" -ge 10 ] && [ "$len" -le 32 ]
}

in_v6_range() {   # fd7a:115c:a1e0::/48 or anything inside it
    local p len
    p=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')
    [[ $p =~ /([0-9]+)$ ]] || return 1
    len=${BASH_REMATCH[1]}
    case $p in fd7a:115c:a1e0:*) [ "$len" -ge 48 ] && [ "$len" -le 128 ] ;; *) return 1 ;; esac
}

# The copy of config.yaml for the archive: the same text, secret values blanked (mesh-net reads only prefixes).
redact_config() {
    sed -E 's/^([[:space:]]*(client_secret|pass|password)[[:space:]]*:)[[:space:]]*[^[:space:]#].*$/\1 "" # removed by mesh-net-pack/' "$1"
}

# map_mount MOUNTS MERGED PATH: a path inside a container -> the same file on this server. MOUNTS — lines
# "destination<TAB>source" (docker inspect); the deepest mount wins; outside every mount — the container's own
# filesystem (MergedDir), if docker reports it.
map_mount() {
    local mounts=$1 merged=$2 p=$3 best="" bsrc="" dest src
    while IFS=$'\t' read -r dest src; do
        [ -n "$dest" ] && [ -n "$src" ] || continue
        [ "$dest" = / ] || dest=${dest%/}
        case $p in
            "$dest"|"$dest"/*) if [ ${#dest} -gt ${#best} ]; then best=$dest; bsrc=$src; fi ;;
        esac
    done <<<"$mounts"
    if [ -n "$best" ]; then
        printf '%s%s' "${bsrc%/}" "${p#"$best"}"
        return 0
    fi
    [ -n "$merged" ] || return 1
    printf '%s%s' "${merged%/}" "$p"
}

cand_path() {   # candidate, path as headscale sees it -> the path on this server
    local i=$1 p=$2
    case $p in /*) ;; *) p="${C_CWD[i]%/}/$p" ;; esac
    if [ "${C_KIND[i]}" = docker ]; then
        map_mount "${C_MOUNTS[i]}" "${C_MERGED[i]}" "$p"
    else
        printf '%s' "$p"
    fi
}

# The config headscale reads: -c/--config, else HEADSCALE_CONFIG, else config.yaml (or .yml) in /etc/headscale,
# ~/.headscale and the working directory — in that order, as headscale looks for it.
find_conf() {   # candidate, argv (lines) -> C_CONF (a path on this server) or nothing
    local i=$1 argv=$2 p d f host
    p=$(conf_from_argv "$argv")
    [ -n "$p" ] || p=$(env_get HEADSCALE_CONFIG "${C_ENV[i]}")
    if [ -n "$p" ]; then
        host=$(cand_path "$i" "$p") || host=""
        C_CONF[i]=$host
        return 0
    fi
    for d in /etc/headscale "${C_HOME[i]:-/root}/.headscale" "${C_CWD[i]:-/}"; do
        for f in config.yaml config.yml; do
            host=$(cand_path "$i" "${d%/}/$f") || continue
            if [ -f "$host" ]; then C_CONF[i]=$host; return 0; fi
        done
    done
    C_CONF[i]=""
}

# ---------------------------------------------------------------- finding headscale

add_cand() {   # kind name ctl running cwd home env mounts merged
    C_KIND[CN]=$1; C_NAME[CN]=$2; C_CTL[CN]=$3; C_RUN[CN]=$4; C_CWD[CN]=$5; C_HOME[CN]=$6; C_ENV[CN]=$7
    C_MOUNTS[CN]=$8; C_MERGED[CN]=$9; C_CONF[CN]=""; C_VER[CN]=""
    CN=$((CN + 1))
}

has_cand() {   # kind name
    local i
    for ((i = 0; i < CN; i++)); do
        [ "${C_KIND[i]}" = "$1" ] && [ "${C_NAME[i]}" = "$2" ] && return 0
    done
    return 1
}

words() { awk '{ for (i = 1; i <= NF; i++) print $i }'; }   # text -> one word per line

# systemctl show -p ExecStart --value -> the first command line: "{ path=… ; argv[]=/usr/bin/headscale serve ; … }"
exec_argv() { sed -n 's/.*argv\[\]=\([^;]*\);.*/\1/p' | sed -n '1{s/[[:space:]]*$//;p;}'; }

add_unit() {   # a systemd service that runs `headscale serve`
    local u=$1 line argv exe user home env cwd state i
    has_cand systemd "$u" && return 0
    line=$(systemctl show -p ExecStart --value -- "$u" 2>/dev/null | exec_argv) || return 0
    argv=$(printf '%s\n' "$line" | words)
    exe=${argv%%$'\n'*}
    [ "${exe##*/}" = headscale ] && [[ $'\n'"$argv"$'\n' == *$'\n'serve$'\n'* ]] || return 0
    env=$(systemctl show -p Environment --value -- "$u" 2>/dev/null | words) || env=""
    cwd=$(systemctl show -p WorkingDirectory --value -- "$u" 2>/dev/null) || cwd=""
    case $cwd in /*) ;; *) cwd=/ ;; esac
    user=$(systemctl show -p User --value -- "$u" 2>/dev/null) || user=""
    home=$(getent passwd "${user:-root}" 2>/dev/null | cut -d: -f6) || home=""
    state=$(systemctl show -p ActiveState --value -- "$u" 2>/dev/null) || state=""
    add_cand systemd "$u" "systemd:$u" "$([ "$state" = active ] && echo 1 || echo 0)" "$cwd" "${home:-/root}" "$env" "" ""
    i=$((CN - 1))
    find_conf "$i" "$argv"
    C_VER[i]=$(bin_version "$exe")
}

find_systemd() {
    command -v systemctl >/dev/null 2>&1 && [ -d "$SYSTEMD_RUN" ] || return 0
    local units u
    units=$(systemctl list-unit-files --type=service --no-legend --no-pager 2>/dev/null | awk '{print $1}') || return 0
    for u in $units; do
        case $u in headscale*.service) add_unit "$u" ;; esac
    done
    return 0
}

find_processes() {
    local d pid argv cg unit env cwd home exe i
    for d in "$PROC"/[0-9]*; do
        pid=${d##*/}
        [ "$(cat "$d/comm" 2>/dev/null)" = headscale ] || continue
        argv=$(tr '\0' '\n' 2>/dev/null <"$d/cmdline") || continue
        [[ $'\n'"$argv"$'\n' == *$'\n'serve$'\n'* ]] || continue
        cg=$(cat "$d/cgroup" 2>/dev/null) || cg=""
        case $cg in *docker*|*containerd*|*kubepods*|*libpod*) continue ;; esac   # a container: see find_docker
        unit=$(printf '%s\n' "$cg" | sed -n 's|.*/\([^/]*\.service\)$|\1|p' | sed -n '$p')
        if [ -n "$unit" ] && [[ $unit != user@* ]] && command -v systemctl >/dev/null 2>&1; then
            add_unit "$unit"
            has_cand systemd "$unit" && continue
        fi
        has_cand process "PID $pid" && continue
        env=$(tr '\0' '\n' 2>/dev/null <"$d/environ") || env=""
        cwd=$(readlink "$d/cwd" 2>/dev/null) || cwd=/
        home=$(env_get HOME "$env")
        exe=$(readlink "$d/exe" 2>/dev/null) || exe=$(command -v headscale 2>/dev/null) || exe=""
        add_cand process "PID $pid" "" 1 "$cwd" "${home:-/root}" "$env" "" ""
        i=$((CN - 1))
        find_conf "$i" "$argv"
        [ -n "$exe" ] && C_VER[i]=$(bin_version "$exe")
    done
    return 0
}

find_docker() {
    command -v docker >/dev/null 2>&1 || return 0
    docker info >/dev/null 2>&1 || { log "docker: the daemon doesn't answer"; return 0; }
    local id ids path args argv name image running wd env mounts merged ver i exe w
    ids=$(docker ps -aq --no-trunc 2>/dev/null) || return 0
    for id in $ids; do
        path=$(docker inspect --format '{{.Path}}' "$id" 2>/dev/null) || continue
        args=$(docker inspect --format '{{range .Args}}{{println .}}{{end}}' "$id" 2>/dev/null) || continue
        if [ "${path##*/}" = headscale ]; then
            argv=$(printf '%s\n%s' "$path" "$args")
        else   # `sh -c "headscale -c … serve"` and the like: the words of the command the shell runs
            argv=$(printf '%s\n' "$args" | sed '1{/^-c$/d;}' | words)
        fi
        exe=""
        while IFS= read -r w; do
            if [ "${w##*/}" = headscale ]; then exe=$w; break; fi
        done <<<"$argv"
        [ -n "$exe" ] && [[ $'\n'"$argv"$'\n' == *$'\n'serve$'\n'* ]] || continue
        name=$(docker inspect --format '{{.Name}}' "$id" 2>/dev/null) || name=$id
        name=${name#/}
        image=$(docker inspect --format '{{.Config.Image}}' "$id" 2>/dev/null) || image=""
        running=$(docker inspect --format '{{.State.Running}}' "$id" 2>/dev/null) || running=false
        wd=$(docker inspect --format '{{.Config.WorkingDir}}' "$id" 2>/dev/null) || wd=""
        env=$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$id" 2>/dev/null) || env=""
        mounts=$(docker inspect --format '{{range .Mounts}}{{.Destination}}{{"\t"}}{{.Source}}{{println}}{{end}}' "$id" 2>/dev/null) || mounts=""
        merged=$(docker inspect --format '{{if .GraphDriver.Data}}{{index .GraphDriver.Data "MergedDir"}}{{end}}' "$id" 2>/dev/null) || merged=""
        [ "$running" = true ] && [ -d "$merged" ] || merged=""
        add_cand docker "$name" "docker:$id" "$([ "$running" = true ] && echo 1 || echo 0)" "${wd:-/}" \
                 "$(env_get HOME "$env")" "$env" "$mounts" "$merged"
        i=$((CN - 1))
        find_conf "$i" "$argv"
        if [ -z "${C_CONF[i]}" ]; then   # the config lives inside the container: take a copy out of it
            conf_out_of_container "$i" "$id" "$argv"
        fi
        ver=$(tag_version "$image")
        if [ -z "$ver" ] && [ "$running" = true ]; then
            ver=$(ver_of "$(docker exec "$id" "$exe" version 2>/dev/null </dev/null | sed -n 1p)")
        fi
        C_VER[i]=$ver
    done
    return 0
}

conf_out_of_container() {   # candidate, container, argv -> C_CONF = a copy in the work directory
    local i=$1 id=$2 p f
    p=$(conf_from_argv "$3")
    [ -n "$p" ] || p=$(env_get HEADSCALE_CONFIG "${C_ENV[i]}")
    for f in ${p:+"$p"} /etc/headscale/config.yaml /etc/headscale/config.yml; do
        mkdir -p "$WORK/conf$i"
        if docker cp "$id:$f" - 2>/dev/null | tar -xf - -C "$WORK/conf$i" 2>/dev/null; then
            if [ -f "$WORK/conf$i/${f##*/}" ]; then
                C_CONF[i]="$WORK/conf$i/${f##*/}"
                return 0
            fi
        fi
    done
}

describe() {   # candidate -> one line for a person
    local i=$1 what ver
    case ${C_KIND[i]} in
        systemd) what=$(L "systemd service ${C_NAME[i]}" "служба systemd ${C_NAME[i]}") ;;
        process) what=$(L "process ${C_NAME[i]#PID } (headscale serve)" "процесс ${C_NAME[i]#PID } (headscale serve)") ;;
        docker) what=$(L "docker container ${C_NAME[i]}" "контейнер docker ${C_NAME[i]}") ;;
        *) what=${C_NAME[i]} ;;
    esac
    ver=${C_VER[i]:-$(L "version unknown" "версия неизвестна")}
    if [ "${C_KIND[i]}" != path ] && [ "${C_RUN[i]}" != 1 ]; then
        printf '%s' "$what, headscale $ver, $(L "stopped" "остановлен")"
    else
        printf '%s' "$what, headscale $ver"
    fi
}

use_path() {   # a path a person typed (or --config / --db) -> a candidate
    local p=$1 i f
    if [ -d "$p" ]; then   # a directory: its config.yaml (or db.sqlite)
        for f in config.yaml config.yml db.sqlite; do
            if [ -f "${p%/}/$f" ]; then p="${p%/}/$f"; break; fi
        done
    fi
    [ -f "$p" ] || die "$(L "There is no such file: $p" "Такого файла нет: $p")"
    for ((i = 0; i < CN; i++)); do
        if [ -n "${C_CONF[i]}" ] && [ "${C_CONF[i]}" -ef "$p" ]; then SEL=$i; return 0; fi
    done
    add_cand path "$p" "" 0 "$(dirname -- "$p")" /root "" "" ""
    SEL=$((CN - 1))
    if [ "$(head -c 15 -- "$p" 2>/dev/null)" = "SQLite format 3" ]; then ARG_DB=$p
    else C_CONF[SEL]=$p; fi
    command -v headscale >/dev/null 2>&1 && C_VER[SEL]=$(bin_version "$(command -v headscale)")
    return 0
}

choose() {
    local i
    find_systemd
    find_processes
    find_docker
    log "found $CN"
    for ((i = 0; i < CN; i++)); do log "  $i: $(describe "$i") conf=${C_CONF[i]}"; done
    if [ -n "$ARG_CONFIG" ]; then use_path "$ARG_CONFIG"; return 0; fi
    if [ -n "$ARG_DB" ] && [ "$CN" -eq 1 ]; then SEL=0; return 0; fi
    if [ -n "$ARG_DB" ]; then use_path "$ARG_DB"; return 0; fi
    if [ "$CN" -eq 1 ]; then SEL=0; return 0; fi
    if [ "$CN" -eq 0 ]; then
        say "$(L "headscale wasn't found on this server: no systemd service, no running \"headscale serve\" and no docker container with it." \
                 "headscale на этом сервере не нашёлся: нет ни службы systemd, ни запущенного «headscale serve», ни контейнера docker с ним.")"
        say "$(L "Run this on the server where your headscale works. If it is here, type the path to its config.yaml (or to db.sqlite)." \
                 "Запускайте это на сервере, где работает ваш headscale. Если он здесь — введите путь к его config.yaml (или к db.sqlite).")"
        [ "$ASSUME_YES" = 1 ] && die "$(L "headscale wasn't found." "headscale не найден.")" \
            "$(L "Add --config /path/to/config.yaml (or --db /path/to/db.sqlite) to the command." \
                 "Добавьте к команде --config /путь/к/config.yaml (или --db /путь/к/db.sqlite).")"
        ask "$(L "Path (Enter — stop):" "Путь (Enter — остановиться):")" ""
        [ -n "$ANSWER" ] || die "$(L "Stopped: headscale wasn't found." "Остановлено: headscale не найден.")"
        use_path "$ANSWER"
        return 0
    fi
    say "$(L "Several headscale servers were found:" "Нашлось несколько серверов headscale:")"
    for ((i = 0; i < CN; i++)); do item "$((i + 1))) $(describe "$i")"; done
    [ "$ASSUME_YES" = 1 ] && die "$(L "Several headscale servers were found, and --yes can't choose." \
                                     "Нашлось несколько серверов headscale, а с --yes выбрать нельзя.")" \
        "$(L "Add --config /path/to/config.yaml of the one you want to the command." \
             "Добавьте к команде --config /путь/к/config.yaml нужного.")"
    ask "$(L "Which one to pack? Type its number (or a path to config.yaml / db.sqlite):" \
             "Какой упаковать? Введите номер (или путь к config.yaml / db.sqlite):")" ""
    if [[ $ANSWER =~ ^[0-9]+$ ]] && [ "$ANSWER" -ge 1 ] && [ "$ANSWER" -le "$CN" ]; then
        SEL=$((ANSWER - 1))
    elif [ -n "$ANSWER" ]; then
        use_path "$ANSWER"
    else
        die "$(L "Stopped: nothing was chosen." "Остановлено: ничего не выбрано.")"
    fi
}

# ---------------------------------------------------------------- the chosen headscale: config, database, checks

resolve() {
    local i=$SEL env type path p v4 v6 mode ppath
    CONF=${C_CONF[i]}
    env=${C_ENV[i]}
    HS_VER=${C_VER[i]}
    if [ -n "$ARG_CONFIG" ] && [ -z "$CONF" ]; then CONF=$ARG_CONFIG; fi
    type=$(env_get HEADSCALE_DATABASE_TYPE "$env")
    [ -n "$type" ] || type=$(cfg_get "$CONF" database.type)
    [ -n "$type" ] || type=$(env_get HEADSCALE_DB_TYPE "$env")
    [ -n "$type" ] || type=$(cfg_get "$CONF" db_type)
    path=$(env_get HEADSCALE_DATABASE_SQLITE_PATH "$env")
    [ -n "$path" ] || path=$(cfg_get "$CONF" database.sqlite.path)
    [ -n "$path" ] || path=$(env_get HEADSCALE_DB_PATH "$env")
    [ -n "$path" ] || path=$(cfg_get "$CONF" db_path)
    DB_TYPE=$(printf '%s' "$type" | tr '[:upper:]' '[:lower:]')

    if [ -z "$ARG_DB" ] && [ -z "$CONF" ]; then
        die "$(L "The config of this headscale wasn't found." "Конфиг этого headscale не нашёлся.")" \
            "$(L "Add --config /path/to/config.yaml (or --db /path/to/db.sqlite) to the command." \
                 "Добавьте к команде --config /путь/к/config.yaml (или --db /путь/к/db.sqlite).")"
    fi
    case $DB_TYPE in
        postgres|postgresql|pgsql)
            die "$(L "Your headscale keeps its data in PostgreSQL, and the import into mesh-net takes only a SQLite database." \
                     "Ваш headscale хранит данные в PostgreSQL, а импорт в mesh-net принимает только базу SQLite.")" \
                "$(L "There is no ready tool to move headscale's data from PostgreSQL to SQLite. Instead, create a new network in mesh-net and add your devices to it again (Add device)." \
                     "Готового способа перенести данные headscale из PostgreSQL в SQLite нет. Вместо этого создайте в mesh-net новую сеть и добавьте в неё устройства заново (Добавить устройство).")" ;;
        ""|sqlite|sqlite3) ;;
        *) die "$(L "The config names an unknown database type: $type. The import takes only SQLite." \
                    "В конфиге неизвестный тип базы: $type. Импорт принимает только SQLite.")" ;;
    esac

    case $(version_verdict "$HS_VER") in
        old) die "$(L "Your headscale is $HS_VER, and the import takes databases of headscale 0.$HS_MIN to 0.$HS_MAX." \
                      "У вас headscale $HS_VER, а импорт принимает базы headscale от 0.$HS_MIN до 0.$HS_MAX.")" \
                 "$(L "Update headscale (to 0.$HS_MAX if you can), start it once so it updates its database, then run this command again." \
                      "Обновите headscale (лучше до 0.$HS_MAX), запустите его один раз — он обновит свою базу, — и выполните эту команду снова.")" ;;
        new) die "$(L "Your headscale is $HS_VER, newer than mesh-net runs (0.$HS_MAX): its database can't be imported yet." \
                      "У вас headscale $HS_VER — новее, чем у mesh-net (0.$HS_MAX): его базу пока не импортировать.")" \
                 "$(L "Write to mesh-net support, or create a new network in mesh-net and add the devices to it again." \
                      "Напишите в поддержку mesh-net или создайте в mesh-net новую сеть и добавьте в неё устройства заново.")" ;;
    esac

    v4=$(cfg_get "$CONF" prefixes.v4)
    v6=$(cfg_get "$CONF" prefixes.v6)
    if { [ -n "$v4" ] && ! in_v4_range "$v4"; } || { [ -n "$v6" ] && ! in_v6_range "$v6"; }; then
        die "$(L "Your headscale hands out addresses from ${v4:-—} and ${v6:-—}, and every mesh-net network lives in 100.64.0.0/10 and fd7a:115c:a1e0::/48: the devices would lose their addresses, so the import refuses such a network." \
                 "Ваш headscale раздаёт адреса из ${v4:-—} и ${v6:-—}, а каждая сеть mesh-net живёт в 100.64.0.0/10 и fd7a:115c:a1e0::/48: устройства потеряли бы адреса, поэтому импорт такую сеть не примет.")" \
            "$(L "Instead, create a new network in mesh-net and add your devices to it again (Add device)." \
                 "Вместо этого создайте в mesh-net новую сеть и добавьте в неё устройства заново (Добавить устройство).")"
    fi

    if [ -n "$ARG_DB" ]; then
        DB_SHOWN=$ARG_DB
        DB_PATH=$ARG_DB
    else
        [ -n "$path" ] || die "$(L "The config $CONF doesn't say where the database is (database.sqlite.path)." \
                                   "В конфиге $CONF не сказано, где база (database.sqlite.path).")" \
            "$(L "Add --db /path/to/db.sqlite to the command." "Добавьте к команде --db /путь/к/db.sqlite.")"
        DB_SHOWN=$path
        p=$(cand_path "$i" "$path") || p=""
        [ -n "$p" ] && [ -f "$p" ] || die "$(L "The database from the config, $path, isn't on this server." \
                                              "Базы из конфига, $path, на этом сервере нет.")" \
            "$(L "If headscale runs in a container, find db.sqlite on this server and add --db /path/to/db.sqlite to the command." \
                 "Если headscale работает в контейнере — найдите db.sqlite на этом сервере и добавьте к команде --db /путь/к/db.sqlite.")"
        DB_PATH=$p
    fi
    [ -f "$DB_PATH" ] && [ -r "$DB_PATH" ] || die "$(L "Can't read the database: $DB_PATH" "Не прочитать базу: $DB_PATH")"
    [ "$(head -c 15 -- "$DB_PATH")" = "SQLite format 3" ] || \
        die "$(L "$DB_PATH isn't a SQLite database." "$DB_PATH — не база SQLite.")"

    mode=$(cfg_get "$CONF" policy.mode)
    ppath=$(cfg_get "$CONF" policy.path)
    [ -n "$ppath" ] || ppath=$(cfg_get "$CONF" acl_policy_path)
    if [ -n "$ppath" ] && [ "$mode" != database ]; then
        NOTES+=("$(L "Your headscale keeps its access rules in the file $ppath, and the archive doesn't carry it: after the import every device of the network can reach every other one until you set the rules in mesh-net (the network's Access tab)." \
                     "Ваш headscale держит правила доступа в файле $ppath, а в архив он не попадает: после импорта каждое устройство сети видит каждое, пока вы не зададите правила в mesh-net (вкладка «Доступ» сети).")")
    fi
    if [ -n "$(cfg_get "$CONF" oidc.issuer)" ]; then
        NOTES+=("$(L "Devices that logged in through single sign-on (OIDC) keep working until their key expires; then add them again with a join key (Add device)." \
                     "Устройства, вошедшие через единый вход (OIDC), работают до истечения ключа; потом добавьте их заново ключом привязки (Добавить устройство).")")
    fi
}

# ---------------------------------------------------------------- the copy

PY_BACKUP='
import sqlite3, sys
src = sqlite3.connect(sys.argv[1], timeout=10)
dst = sqlite3.connect(sys.argv[2])
src.backup(dst)
dst.close()
src.close()
'
PY_QUERY='
import sqlite3, sys
con = sqlite3.connect(sys.argv[1], timeout=10)
for row in con.execute(sys.argv[2]):
    print("|".join("" if v is None else str(v) for v in row))
con.commit()
con.close()
'

pick_tool() {
    if command -v sqlite3 >/dev/null 2>&1; then SQL_TOOL=sqlite3
    elif command -v python3 >/dev/null 2>&1 && python3 -c 'import sqlite3' >/dev/null 2>&1; then SQL_TOOL=python3
    else SQL_TOOL=""; fi
}

db_query() {   # db, SQL -> rows "a|b"
    if [ "$SQL_TOOL" = sqlite3 ]; then
        sqlite3 -batch -noheader -cmd ".timeout 10000" "$1" "$2"
    else
        python3 -c "$PY_QUERY" "$1" "$2"
    fi
}

backup_db() {   # source, copy — a consistent copy of a database that may be in use
    if [ "$SQL_TOOL" = sqlite3 ]; then
        sqlite3 -bail -cmd ".timeout 10000" "$1" ".backup '$2'"
    else
        python3 -c "$PY_BACKUP" "$1" "$2"
    fi
}

count_rows() {   # db, table -> rows still in use (without soft-deleted ones)
    local cols where=""
    cols=$(db_query "$1" "PRAGMA table_info($2);" | cut -d'|' -f2)
    [[ $'\n'"$cols"$'\n' == *$'\n'deleted_at$'\n'* ]] && where=" WHERE deleted_at IS NULL"
    db_query "$1" "SELECT count(*) FROM $2$where;"
}

# checks the copy: intact, rollback journal, headscale's tables, a schema version -> COPY_NODES, COPY_USERS
check_copy() {
    local db=$1 mode ok tables t mig
    mode=$(db_query "$db" "PRAGMA journal_mode=DELETE;") || mode=""
    ok=$(db_query "$db" "PRAGMA integrity_check;" | sed -n 1p) || ok=""
    log "copy: journal_mode=$mode integrity=$ok"
    [ "$ok" = ok ] || die "$(L "The copy of the database is damaged (integrity check: ${ok:-no answer})." \
                              "Копия базы повреждена (проверка целостности: ${ok:-нет ответа}).")" \
        "$(L "Run the command again in a minute; if it repeats, write to mesh-net support." \
             "Выполните команду ещё раз через минуту; если повторится — напишите в поддержку mesh-net.")"
    [ "$mode" = delete ] || warn "journal_mode: $mode"
    tables=$(db_query "$db" "SELECT name FROM sqlite_master WHERE type='table';") || tables=""
    for t in nodes users migrations; do
        [[ $'\n'"$tables"$'\n' == *$'\n'"$t"$'\n'* ]] || \
            die "$(L "$DB_PATH isn't a headscale database (no table $t)." "$DB_PATH — не база headscale (нет таблицы $t).")"
    done
    mig=$(db_query "$db" "SELECT count(*) FROM migrations;") || mig=0
    [ "$mig" != 0 ] || die "$(L "The database has no schema version: it comes from headscale older than 0.$HS_MIN." \
                                "У базы нет версии схемы: она от headscale версии ниже 0.$HS_MIN.")" \
        "$(L "Update headscale (to 0.$HS_MAX if you can), start it once so it updates its database, then run this command again." \
             "Обновите headscale (лучше до 0.$HS_MAX), запустите его один раз — он обновит свою базу, — и выполните эту команду снова.")"
    COPY_NODES=$(count_rows "$db" nodes) || COPY_NODES="?"
    COPY_USERS=$(count_rows "$db" users) || COPY_USERS="?"
}

hs_is_running() {   # ctl -> 0 while headscale runs
    case $1 in
        systemd:*) [ "$(systemctl show -p ActiveState --value -- "${1#systemd:}" 2>/dev/null)" = active ] ;;
        docker:*) [ "$(docker inspect --format '{{.State.Running}}' "${1#docker:}" 2>/dev/null)" = true ] ;;
        *) return 1 ;;
    esac
}

to_log() {   # command... — its error messages go to the log instead of the screen
    if [ "$LOG_ON" = 1 ]; then "$@" 2>>"$LOG"; else "$@" 2>/dev/null; fi
}

hs_stop() {
    case $1 in
        systemd:*) to_log systemctl stop -- "${1#systemd:}" ;;
        docker:*) to_log docker stop -t 20 "${1#docker:}" >/dev/null ;;
    esac
}

hs_start() {   # -> 0 when it runs again (waits up to 30 s)
    case $1 in
        systemd:*)
            # several restarts within 10 s make systemd refuse the next one (start-limit-hit): reset-failed is the
            # standard way to lift that
            if ! to_log systemctl start -- "${1#systemd:}"; then
                to_log systemctl reset-failed -- "${1#systemd:}" || true
                sleep 1
                to_log systemctl start -- "${1#systemd:}" || return 1
            fi ;;
        docker:*) to_log docker start "${1#docker:}" >/dev/null || return 1 ;;
    esac
    for _ in $(seq 1 60); do
        hs_is_running "$1" && return 0
        sleep 0.5
    done
    return 1
}

start_line() {   # ctl -> the command a person runs to start headscale
    local s=""
    [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != root ] && s="sudo "
    case $1 in
        systemd:*) printf '%ssystemctl reset-failed %s; %ssystemctl start %s' "$s" "${1#systemd:}" "$s" "${1#systemd:}" ;;
        docker:*) printf '%sdocker start %s' "$s" "${C_NAME[SEL]}" ;;
    esac
}

# Neither sqlite3 nor python3: the file is copied while headscale is stopped (asked first). headscale that isn't run
# by systemd or docker can't be started again by the script — then it says what to install.
copy_stopped() {
    local copy=$1 ctl=${C_CTL[SEL]} t0="" stopped=0
    if [ "${C_RUN[SEL]}" = 1 ] || { [ -n "$ctl" ] && hs_is_running "$ctl"; }; then
        [ -n "$ctl" ] || die "$(L "This server has neither sqlite3 nor python3, so the database can be copied only while headscale is stopped — and this headscale isn't run by systemd or docker, so the script can't start it again." \
                                 "На сервере нет ни sqlite3, ни python3, поэтому базу можно скопировать только при остановленном headscale — а этот headscale запущен не через systemd и не через docker, и запустить его снова скрипт не сможет.")" \
            "$(L "Install sqlite3 (Debian, Ubuntu: apt install sqlite3) and run this command again." \
                 "Установите sqlite3 (Debian, Ubuntu: apt install sqlite3) и выполните эту команду снова.")"
        say "$(L "This server has neither sqlite3 nor python3, so the database can be copied only while headscale is stopped. Your devices keep working; for a few seconds they can't reach the server (new devices can't join)." \
                 "На сервере нет ни sqlite3, ни python3, поэтому базу можно скопировать только при остановленном headscale. Устройства продолжат работать; несколько секунд они не достучатся до сервера (новые не подключатся).")"
        yes_no "$(L "Stop headscale for a few seconds, copy the database and start it again?" \
                    "Остановить headscale на несколько секунд, скопировать базу и запустить снова?")" || \
            die "$(L "Stopped: headscale wasn't touched." "Остановлено: headscale не тронут.")" \
                "$(L "To copy without stopping it, install sqlite3 (Debian, Ubuntu: apt install sqlite3) and run this command again." \
                     "Чтобы копировать без остановки, установите sqlite3 (Debian, Ubuntu: apt install sqlite3) и выполните эту команду снова.")"
        RESTART_CMD=$ctl
        t0=$(date +%s)
        item "$(L "Stopping headscale…" "Останавливаем headscale…")"
        hs_stop "$ctl" || true
        stopped=1
        # headscale 0.28 sometimes exits with a database connection still open (stand, 1 stop in 5): the saved data is
        # then partly in -wal, which only sqlite can merge. Start it and stop it again — usually the next stop is clean.
        for _ in 1 2; do
            [ -s "$DB_PATH-wal" ] || break
            item "$(L "headscale didn't close its database cleanly; starting and stopping it once more…" \
                      "headscale закрыл базу не начисто; запускаем и останавливаем его ещё раз…")"
            hs_start "$ctl" || break
            sleep "$RETRY_PAUSE"
            hs_stop "$ctl" || true
        done
    fi
    if [ -s "$DB_PATH-wal" ]; then
        if [ "$stopped" = 1 ]; then restart_now || true; fi
        die "$(L "Part of the database is still in $DB_PATH-wal (headscale is running or didn't close it cleanly), and without sqlite3 or python3 it can't be put into the copy." \
                 "Часть базы ещё в $DB_PATH-wal (headscale работает или закрыл базу не начисто), а без sqlite3 или python3 её не перенести в копию.")" \
            "$(L "Install sqlite3 (Debian, Ubuntu: apt install sqlite3) and run this command again." \
                 "Установите sqlite3 (Debian, Ubuntu: apt install sqlite3) и выполните эту команду снова.")"
    fi
    cp -- "$DB_PATH" "$copy"
    if [ "$stopped" = 1 ] && restart_now; then   # didn't start — restart_now has said so and put it on the list
        local secs=$(( $(date +%s) - t0 ))
        if [ "$secs" -le 1 ]; then
            CHANGED+=("~ $(L "headscale was stopped for about a second and started again" \
                             "headscale был остановлен примерно на секунду и запущен снова")")
        else
            CHANGED+=("~ $(L "headscale was stopped for $secs s and started again" "headscale был остановлен на $secs с и запущен снова")")
        fi
    fi
    return 0
}

restart_now() {   # -> 0 when headscale runs again (or there was nothing to start)
    local ctl=$RESTART_CMD
    [ -n "$ctl" ] || return 0
    item "$(L "Starting headscale again…" "Запускаем headscale снова…")"
    RESTART_CMD=""
    if hs_start "$ctl"; then
        item "$(L "headscale is running again." "headscale снова работает.")"
        return 0
    fi
    warn "$(L "headscale didn't start again. Start it yourself: $(start_line "$ctl")" \
              "headscale не запустился. Запустите его сами: $(start_line "$ctl")")"
    CHANGED+=("! $(L "headscale is stopped — start it: $(start_line "$ctl")" "headscale остановлен — запустите: $(start_line "$ctl")")")
    return 1
}

# ---------------------------------------------------------------- where the file goes and how to fetch it

OUT_DIR="" OUT_USER=root OUT_UID="" OUT_GID=""
pick_out_dir() {
    local home=""
    if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != root ]; then
        home=$(getent passwd "$SUDO_USER" 2>/dev/null | cut -d: -f6) || home=""
        if [ -n "$home" ] && [ -d "$home" ]; then
            OUT_USER=$SUDO_USER OUT_UID=${SUDO_UID:-} OUT_GID=${SUDO_GID:-}
        else
            home=""
        fi
    fi
    OUT_DIR=${ARG_OUT:-${home:-/root}}
    [ -d "$OUT_DIR" ] || die "$(L "There is no directory $OUT_DIR." "Каталога $OUT_DIR нет.")"
}

ssh_server_ip() {   # the address this server was reached at by SSH (SSH_CONNECTION of the session above us)
    local pid=$$ n=0 line ip
    while [ -n "$pid" ] && [ "$pid" -gt 1 ] && [ "$n" -lt 40 ]; do
        line=$(tr '\0' '\n' 2>/dev/null <"$PROC/$pid/environ" | sed -n 's/^SSH_CONNECTION=//p') || line=""
        if [ -n "$line" ]; then
            read -r _ _ ip _ <<<"${line%%$'\n'*}"
            printf '%s' "${ip:-}"
            return 0
        fi
        pid=$(sed -n 's/^PPid:[[:space:]]*//p' "$PROC/$pid/status" 2>/dev/null) || pid=""
        n=$((n + 1))
    done
}

server_ip() {
    local ip
    ip=$(ssh_server_ip)
    [ -n "$ip" ] || ip=$(ip -o route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([^ ]*\).*/\1/p') || ip=""
    [ -n "$ip" ] || ip=$(hostname -I 2>/dev/null | awk '{print $1}') || ip=""
    printf '%s' "${ip#::ffff:}"
}

is_public_ip() {
    case $1 in
        ""|10.*|127.*|169.254.*|192.168.*|0.*) return 1 ;;
        172.1[6-9].*|172.2[0-9].*|172.3[01].*) return 1 ;;
        100.6[4-9].*|100.[7-9][0-9].*|100.1[01][0-9].*|100.12[0-7].*) return 1 ;;
        ::1|fe80:*|fc*|fd*|FC*|FD*|FE80:*) return 1 ;;
    esac
    return 0
}

scp_line() {   # user, ip, path -> the command for a person's computer
    local host=${2:-SERVER-IP}
    case $host in *:*) host="[$host]" ;; esac
    printf 'scp %s@%s:%s .' "$1" "$host" "$3"
}

# ---------------------------------------------------------------- the whole run

on_exit() {
    local rc=$?
    if [ -n "$RESTART_CMD" ]; then restart_now || true; fi
    if [ -n "$WORK" ] && [ -d "$WORK" ]; then rm -rf -- "$WORK"; fi
    if [ "$DONE" != 1 ] && [ "$rc" -ne 0 ]; then log "END rc=$rc"; fi
    return "$rc"
}

start_log() {
    if { : >>"$LOG"; } 2>/dev/null; then
        chmod 600 "$LOG" 2>/dev/null || true
        LOG_ON=1
        log "==== mesh-net-pack $* (lang $LNG)"
    fi
}

pack() {   # work dir with db.sqlite [+ config.yaml], archive -> the tar.gz
    local dir=$1 out=$2 files=(db.sqlite)
    [ -f "$dir/config.yaml" ] && files+=(config.yaml)
    tar -C "$dir" -czf "$out" "${files[@]}"
}

main() {
    exec </dev/null                   # the rest of the script came through this pipe; questions go to the terminal
    parse_args "$@"
    if [ "$(id -u)" -ne 0 ]; then
        printf '%s\n' "$(L "Run it as root: put sudo before bash in the command (… | sudo bash -s -- …)." \
                           "Запустите от root: в команде перед bash должно стоять sudo (… | sudo bash -s -- …).")" >&2
        exit 1
    fi
    start_log "$@"
    trap on_exit EXIT
    trap 'exit 130' INT TERM HUP
    WORK=$(mktemp -d "${TMPDIR:-/tmp}/mesh-net-pack.XXXXXX")
    local limit=$((MAX_MB * 1048576)) name archive size copy kind dbsize

    say "$(L "mesh-net: packing your headscale network for the import" "mesh-net: упаковываем вашу сеть headscale для импорта")"
    step 1 "$(L "Looking for headscale…" "Ищем headscale…")"
    choose
    kind=$(describe "$SEL")
    item "$(L "Found:    $kind" "Найден:   $kind")"
    CONF=${C_CONF[SEL]:-$ARG_CONFIG}
    [ -n "$CONF" ] && item "$(L "Config:   $CONF" "Конфиг:   $CONF")"
    resolve
    dbsize=$(stat -c %s -- "$DB_PATH")
    [ -f "$DB_PATH-wal" ] && dbsize=$((dbsize + $(stat -c %s -- "$DB_PATH-wal")))   # changes not yet in the file
    if [ "${C_KIND[SEL]}" = docker ] && [ "$DB_SHOWN" != "$DB_PATH" ]; then
        item "$(L "Database: $DB_PATH ($(human_size "$dbsize"); in the container: $DB_SHOWN)" \
                  "База:     $DB_PATH ($(human_size "$dbsize"); в контейнере: $DB_SHOWN)")"
    else
        item "$(L "Database: $DB_PATH ($(human_size "$dbsize"))" "База:     $DB_PATH ($(human_size "$dbsize"))")"
    fi
    [ -n "$HS_VER" ] || warn "$(L "The version of headscale is unknown: mesh-net will check the database itself when you upload it." \
                                  "Версия headscale неизвестна: mesh-net проверит базу сам, когда вы её загрузите.")"
    if [ "$(stat -c %s -- "$DB_PATH")" -gt "$limit" ]; then   # the copy is checked again below
        die "$(L "The database is $(human_size "$dbsize"), and mesh-net imports up to $MAX_MB MB." \
                 "База весит $(human_size "$dbsize"), а mesh-net импортирует до $MAX_MB МБ.")" \
            "$(L "Write to mesh-net support." "Напишите в поддержку mesh-net.")"
    fi
    pick_out_dir
    name="mesh-net-import-$(date +%F).tar.gz"
    archive="$OUT_DIR/$name"
    yes_no "$(L "Pack it into $archive?" "Упаковать в $archive?")" || die "$(L "Stopped." "Остановлено.")"

    step 2 "$(L "Copying the database…" "Копируем базу…")"
    mkdir -p "$WORK/pack"
    copy="$WORK/pack/db.sqlite"
    pick_tool
    if [ -n "$SQL_TOOL" ]; then
        item "$(L "Without stopping headscale ($SQL_TOOL)." "Не останавливая headscale ($SQL_TOOL).")"
        backup_db "$DB_PATH" "$copy" || die "$(L "The database couldn't be copied (see the message above)." \
                                                 "Базу скопировать не удалось (сообщение выше).")" \
            "$(L "Run the command again in a minute; if it repeats, write to mesh-net support." \
                 "Выполните команду ещё раз через минуту; если повторится — напишите в поддержку mesh-net.")"
        check_copy "$copy"
        item "$(L "The copy is intact: $COPY_NODES devices, $COPY_USERS users." \
                  "Копия цела: устройств — $COPY_NODES, пользователей — $COPY_USERS.")"
    else
        copy_stopped "$copy"
        item "$(L "Copied. mesh-net checks the copy when you upload it." "Скопировано. mesh-net проверит копию, когда вы её загрузите.")"
    fi

    step 3 "$(L "Packing…" "Упаковываем…")"
    [ -n "$CONF" ] && redact_config "$CONF" >"$WORK/pack/config.yaml"
    [ -n "$CONF" ] || item "$(L "No config: the archive holds only db.sqlite." "Конфига нет: в архиве только db.sqlite.")"
    pack "$WORK/pack" "$WORK/$name"
    size=$(stat -c %s -- "$WORK/$name")
    if [ "$size" -gt "$limit" ] || [ "$(stat -c %s -- "$copy")" -gt "$limit" ]; then
        die "$(L "The archive is $(human_size "$size") (the database inside — $(human_size "$(stat -c %s -- "$copy")")), and mesh-net imports up to $MAX_MB MB." \
                 "Архив весит $(human_size "$size") (база внутри — $(human_size "$(stat -c %s -- "$copy")")), а mesh-net импортирует до $MAX_MB МБ.")" \
            "$(L "Write to mesh-net support." "Напишите в поддержку mesh-net.")"
    fi
    if [ -e "$archive" ]; then
        local keep
        keep="$BACKUP_ROOT/mesh-net-$(date +%Y%m%d-%H%M%S)"
        mkdir -p "$BACKUP_ROOT"
        mkdir -m 700 "$keep"
        mv -- "$archive" "$keep/"
        CHANGED+=("~ $(L "the archive from an earlier run → $keep/$name" "архив прошлого запуска → $keep/$name")")
    fi
    mv -- "$WORK/$name" "$archive"
    chmod 600 "$archive"
    if [ "$OUT_USER" != root ]; then chown -- "${OUT_UID:-$OUT_USER}${OUT_GID:+:$OUT_GID}" "$archive" || true; fi
    CHANGED=("+ $archive — $(L "the archive" "архив")" ${CHANGED[@]+"${CHANGED[@]}"})
    [ "$LOG_ON" = 1 ] && CHANGED+=("+ $LOG — $(L "the log of this run" "журнал этого запуска")")
    DONE=1
    log "archive $archive $size bytes"

    local ip sure=1 n
    ip=$(server_ip)
    is_public_ip "$ip" || sure=0
    say ""
    say "$(L "Done. Your network is packed:" "Готово. Сеть упакована:")"
    if [ -n "${COPY_NODES:-}" ]; then
        item "$archive ($(human_size "$size"); $(L "devices: $COPY_NODES, users: $COPY_USERS" "устройств: $COPY_NODES, пользователей: $COPY_USERS"))"
    else
        item "$archive ($(human_size "$size"))"
    fi
    item "$(L "Keep this file like a password: it holds the keys of your devices." \
              "Храните этот файл как пароль: в нём ключи ваших устройств.")"
    for n in ${NOTES[@]+"${NOTES[@]}"}; do warn "$n"; done
    say ""
    say "$(L "What next" "Что дальше")"
    say "$(L "  1. Download the file to your computer. On YOUR computer (not on this server) open a new terminal window and run:" \
             "  1. Скачайте файл на свой компьютер. На ВАШЕМ компьютере (не на этом сервере) откройте новое окно терминала и выполните:")"
    say "       $(scp_line "$OUT_USER" "$ip" "$archive")"
    if [ -z "$ip" ]; then
        say "$(L "     Put the address you use to connect to this server by SSH in place of SERVER-IP." \
                 "     Вместо SERVER-IP подставьте адрес, по которому вы подключаетесь к этому серверу по SSH.")"
    elif [ "$sure" = 0 ]; then
        say "$(L "     $ip is the address the script sees, and it looks internal. If you connect to this server by SSH at another address, put that one instead." \
                 "     $ip — адрес, который видит скрипт, и он похож на внутренний. Если по SSH вы подключаетесь к серверу по другому адресу, подставьте его.")"
    else
        say "$(L "     $ip is this server's address. If you connect to it by SSH at another address or a name, use that one." \
                 "     $ip — адрес этого сервера. Если по SSH вы подключаетесь по другому адресу или имени, подставьте его.")"
    fi
    say "$(L "  2. In mesh-net open My networks → Import and choose this file." \
             "  2. В mesh-net откройте «Мои сети» → «Импортировать» и выберите этот файл.")"
    say ""
    say "$(L "Changed on this server:" "Что изменилось на сервере:")"
    for n in "${CHANGED[@]}"; do item "$n"; done
    item "$(L "headscale's database and config were only read." "База и конфиг headscale только прочитаны.")"
    log "END ok"
}

if [ "${1:-}" = "--source-only" ]; then   # tests: `source mesh-net-pack.sh --source-only`, then call functions
    # shellcheck disable=SC2317  # `exit` is reached when the file is run, not sourced
    return 0 2>/dev/null || exit 0
fi
main "$@"
