#!/usr/bin/env bash # gper-linux — меню-установщик для Ubuntu / Debian / Proxmox # Версия: 0.1.5 # Издание: guest # Собран 2026-09-08T21:07:00Z. Не править этот файл — собери заново. # set -e не ставим: сбой пункта не должен ронять остальные. GPER_EDITION='guest' GPER_VERSION='0.1.5' # === src/private/apt_ensure.sh === # Идемпотентная установка пакетов дистрибутива. # Уже стоит — не трогать. Нет — DEBIAN_FRONTEND=noninteractive. # Факт проверяем dpkg -s, не код выхода apt в одиночку. # Если зеркало не отдаёт архивы — повтор с --fix-missing, потом # локальный helper 127.0.0.1:18799 (как Windows proxy-on), apt, proxy-off. _gper_pkg_installed() { dpkg -s "$1" >/dev/null 2>&1 } _gper_apt_opts=( -o Acquire::ForceIPv4=true -o Acquire::Retries=3 ) _gper_apt_update() { DEBIAN_FRONTEND=noninteractive apt-get update "${_gper_apt_opts[@]}" } _gper_apt_install() { DEBIAN_FRONTEND=noninteractive apt-get install -y "${_gper_apt_opts[@]}" "$@" } _gper_apt_proxy_ours=0 _gper_apt_proxy_file=/etc/apt/apt.conf.d/99gper-proxy # Только на время apt: локальный helper + файл apt, потом снять. # Если enizproxy уже держит 99gper-proxy — не трогаем, не снимаем. _gper_apt_proxy_on() { [[ -d /etc/apt/apt.conf.d ]] || return 1 if [[ -f $_gper_apt_proxy_file ]] && _gper_local_proxy_listening; then write_menu_log INFO 'apt уже через 99gper-proxy' return 0 fi local url=${GPER_APT_PROXY:-} if [[ -n $url && $url != *127.0.0.1* ]]; then : else _gper_local_proxy_start || return 1 url='http://127.0.0.1:18799' fi printf 'Acquire::http::Proxy "%s";\nAcquire::https::Proxy "%s";\nAcquire::http::Pipeline-Depth "0";\n' \ "$url" "$url" > "$_gper_apt_proxy_file" || return 1 export http_proxy=$url https_proxy=$url HTTP_PROXY=$url HTTPS_PROXY=$url _gper_apt_proxy_ours=1 write_menu_log INFO "apt через $url" return 0 } _gper_apt_proxy_off() { ((_gper_apt_proxy_ours == 1)) || return 0 rm -f "$_gper_apt_proxy_file" unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY _gper_local_proxy_stop _gper_apt_proxy_ours=0 write_menu_log INFO "apt proxy снят" } _gper_all_installed() { local pkg for pkg in "$@"; do _gper_pkg_installed "$pkg" || return 1 done return 0 } apt_ensure() { if ! gper_is_debian; then printf 'не Debian/Ubuntu — apt_ensure пропущен\n' write_menu_log WARN 'не Debian/Ubuntu — apt_ensure пропущен' return 0 fi local pkg local -a missing=() for pkg in "$@"; do if _gper_pkg_installed "$pkg"; then write_menu_log INFO "уже стоит: $pkg" continue fi missing+=("$pkg") done if ((${#missing[@]} == 0)); then return 0 fi write_menu_log INFO "ставлю: ${missing[*]}" _gper_apt_proxy_ours=0 if [[ -f $_gper_apt_proxy_file ]]; then _gper_apt_update || true _gper_apt_install "${missing[@]}" || true if _gper_all_installed "${missing[@]}"; then return 0 fi write_menu_log WARN "повтор apt --fix-missing (уже через proxy)" printf ' повтор apt --fix-missing\n' _gper_apt_update || true _gper_apt_install --fix-missing "${missing[@]}" || true else _gper_apt_update || true _gper_apt_install "${missing[@]}" || true if _gper_all_installed "${missing[@]}"; then return 0 fi write_menu_log WARN "повтор apt --fix-missing" printf ' повтор apt --fix-missing\n' _gper_apt_update || true _gper_apt_install --fix-missing "${missing[@]}" || true if _gper_all_installed "${missing[@]}"; then return 0 fi if _gper_apt_proxy_on; then _gper_apt_update || true _gper_apt_install --fix-missing "${missing[@]}" || true _gper_apt_proxy_off fi fi local bad=0 for pkg in "${missing[@]}"; do if ! _gper_pkg_installed "$pkg"; then printf 'пакет не встал: %s\n' "$pkg" >&2 write_menu_log ERROR "пакет не встал: $pkg" bad=1 fi done _gper_apt_proxy_off return "$bad" } # === src/private/dump_gper_menu_tree.sh === # Дамп дерева для тестов и отладки. Не вход curl. dump_gper_menu_tree() { get_menu_data printf 'gper-linux %s (%s)\n' "$(get_menu_version)" "$(get_menu_edition)" printf 'пунктов: %s\n' "${#GPER_ITEM_KEYS[@]}" local i for i in "${!GPER_ITEM_KEYS[@]}"; do if [[ ${GPER_ITEM_AUTORUN[i]} == 1 ]]; then printf ' [auto] %s / %s\n' "${GPER_ITEM_PATH[i]}" "${GPER_ITEM_NAME[i]}" else printf ' %s / %s\n' "${GPER_ITEM_PATH[i]}" "${GPER_ITEM_NAME[i]}" fi done } invoke_menu_item() { local key=$1 get_menu_data local i for i in "${!GPER_ITEM_KEYS[@]}"; do if [[ ${GPER_ITEM_KEYS[i]} == "$key" ]]; then local action=${GPER_ITEM_ACTION[i]} if [[ -n $action ]]; then "$action" return $? fi return 0 fi done printf 'Пункт %s не найден\n' "$key" >&2 return 1 } # === src/private/get_gper_config.sh === # Константы клиента в одном месте. Ни один путь витрины не должен # быть вписан в пункт напрямую: пунктов будет десятки, а витрина # переезжает целиком. # # Хост публикации, ssh-ключ и внутренние пути склада сюда не входят: # этот файл целиком уезжает в раздаваемую сборку. get_gper_config() { declare -gA GPER_CONFIG=() GPER_CONFIG[temp_dir]='/var/tmp/gper' GPER_CONFIG[web_root]='https://gper.ru' GPER_CONFIG[script_root]='https://ps.gper.ru' GPER_CONFIG[bootstrap_url]='https://sh.gper.ru' GPER_CONFIG[log_dir]='/var/log/gper' GPER_CONFIG[ssh_keys_url]='https://sh.gper.ru/authorized_keys' # apt смотрит на локальный helper (как WinHTTP на 127.0.0.1:18799). GPER_CONFIG[apt_proxy]='http://127.0.0.1:18799' GPER_CONFIG[apt_proxy_upstream]='185.239.48.145:8899' local machine=${GPER_MACHINE_CONFIG:-/etc/gper/config.json} local user=${GPER_USER_CONFIG:-${HOME}/.config/gper/config.json} _gper_merge_config_file "$machine" _gper_merge_config_file "$user" } # Файл с диска — недоверенный: битый JSON не должен ронять отрисовку. # Не разобрали — оставили умолчание, как будто файла не было. _gper_merge_config_file() { local file=$1 [[ -f $file && -r $file ]] || return 0 local raw raw=$(cat "$file" 2>/dev/null) || return 0 raw=${raw#$'\xef\xbb\xbf'} if [[ $raw != *'{'* || $raw != *'}'* ]]; then return 0 fi local key for key in temp_dir web_root script_root bootstrap_url log_dir ssh_keys_url apt_proxy apt_proxy_upstream; do if [[ $raw =~ \"${key}\"[[:space:]]*:[[:space:]]*\"([^\"]*)\" ]]; then GPER_CONFIG[$key]=${BASH_REMATCH[1]} fi done } # === src/private/get_menu_branch.sh === # Дочерние ветки узла. Дерево нигде не строится как структура: # ветки вычисляются из путей пунктов. Регистр не создаёт дублей. get_menu_branch() { GPER_BRANCHES=() local depth=${#GPER_CURSOR[@]} local i name found existing for i in "${!GPER_ITEM_KEYS[@]}"; do get_menu_segment "${GPER_ITEM_PATH[i]}" if ((${#GPER_SEGMENTS[@]} <= depth)); then continue fi if ! test_menu_cursor_match; then continue fi name=${GPER_SEGMENTS[depth]} found=0 if ((${#GPER_BRANCHES[@]} > 0)); then for existing in "${GPER_BRANCHES[@]}"; do if [[ ${existing,,} == "${name,,}" ]]; then found=1 break fi done fi if ((found == 0)); then GPER_BRANCHES+=("$name") fi done return 0 } # === src/private/get_menu_data.sh === # Собирает пункты меню из всех функций item_*. # Пункт описывается одним файлом и нигде не регистрируется: # склейка кладёт функцию в скрипт, а эта функция её находит. _gper_reset_item_fields() { _gper_item_path= _gper_item_name= _gper_item_order= _gper_item_descr= _gper_item_action= _gper_item_autorun= } get_menu_data() { GPER_ITEM_KEYS=() GPER_ITEM_PATH=() GPER_ITEM_NAME=() GPER_ITEM_ORDER=() GPER_ITEM_DESCR=() GPER_ITEM_ACTION=() GPER_ITEM_AUTORUN=() local -a found=() local line fn while IFS= read -r line; do fn=${line##* } [[ $fn == item_* ]] || continue found+=("$fn") done < <(declare -F) local -a keys=() paths=() names=() orders=() descrs=() actions=() autoruns=() for fn in "${found[@]}"; do _gper_reset_item_fields "$fn" if [[ -z ${_gper_item_name:-} ]]; then printf 'Пункт %s не вернул name — пропущен\n' "$fn" >&2 write_menu_log WARN "Пункт $fn не вернул name — пропущен" continue fi if [[ -z ${_gper_item_path:-} ]]; then printf 'Пункт %s не вернул path — пропущен\n' "$fn" >&2 write_menu_log WARN "Пункт $fn не вернул path — пропущен" continue fi get_menu_segment "$_gper_item_path" if ((${#GPER_SEGMENTS[@]} == 0)); then printf 'Пункт %s имеет пустой path — пропущен\n' "$fn" >&2 write_menu_log WARN "Пункт $fn имеет пустой path — пропущен" continue fi keys+=("${fn#item_}") paths+=("$_gper_item_path") names+=("$_gper_item_name") orders+=("${_gper_item_order:-100}") descrs+=("${_gper_item_descr:-}") actions+=("${_gper_item_action:-}") autoruns+=("${_gper_item_autorun:-0}") done local -a lines=() local i for i in "${!keys[@]}"; do printf -v lines[i] '%s\t%05d\t%s\t%d' \ "${paths[i]}" "${orders[i]}" "${names[i]}" "$i" done local -a sorted=() if ((${#lines[@]} > 0)); then mapfile -t sorted < <(printf '%s\n' "${lines[@]}" | sort -f) fi local idx for line in "${sorted[@]}"; do idx=${line##*$'\t'} GPER_ITEM_KEYS+=("${keys[idx]}") GPER_ITEM_PATH+=("${paths[idx]}") GPER_ITEM_NAME+=("${names[idx]}") GPER_ITEM_ORDER+=("${orders[idx]}") GPER_ITEM_DESCR+=("${descrs[idx]}") GPER_ITEM_ACTION+=("${actions[idx]}") GPER_ITEM_AUTORUN+=("${autoruns[idx]}") done } # === src/private/get_menu_edition.sh === get_menu_edition() { if [[ ${GPER_EDITION:-} == admin ]]; then printf '%s' 'admin' else printf '%s' 'guest' fi } # === src/private/get_menu_leaf.sh === # Пункты, лежащие непосредственно в узле, без вложенных веток. get_menu_leaf() { GPER_LEAVES=() local depth=${#GPER_CURSOR[@]} local i for i in "${!GPER_ITEM_KEYS[@]}"; do get_menu_segment "${GPER_ITEM_PATH[i]}" if ((${#GPER_SEGMENTS[@]} != depth)); then continue fi if test_menu_cursor_match; then GPER_LEAVES+=("$i") fi done } # === src/private/get_menu_segment.sh === # Разбирает Path пункта на сегменты дерева. # Принимает и прямой, и обратный слэш — опечатка в направлении # не должна порождать отдельную ветку. get_menu_segment() { local path=$1 GPER_SEGMENTS=() [[ -n ${path:-} ]] || return 0 path=${path//\\//} local -a raw local IFS='/' read -ra raw <<< "$path" local part for part in "${raw[@]}"; do part="${part#"${part%%[![:space:]]*}"}" part="${part%"${part##*[![:space:]]}"}" if [[ -n $part ]]; then GPER_SEGMENTS+=("$part") fi done } # === src/private/get_menu_version.sh === get_menu_version() { printf '%s' "${GPER_VERSION:-0.0.0}" } # === src/private/gper_ensure_file.sh === # Файл на диске нужен ровно для одного: передать себя в sudo. curl|bash и # bash -c файла не оставляют — тогда качаем себя с витрины и перезапускаемся. # # Под root файл не нужен, и качать нельзя: повторный прогон подменил бы уже # загруженный код версией с витрины. Именно так свежая сборка, запущенная # пайпом вручную, молча выполнялась как старая. gper_ensure_file() { local src=${BASH_SOURCE[0]:-} if [[ -n $src && -f $src ]]; then GPER_SCRIPT_PATH=$(cd "$(dirname "$src")" && pwd)/$(basename "$src") return 0 fi if [[ $(id -u) -eq 0 ]]; then return 0 fi get_gper_config local url=${GPER_CONFIG[bootstrap_url]:-https://sh.gper.ru} # После прогонов под root каталог витрины принадлежит root, и обычный # пользователь в него не запишет. Тогда кладём себя в личный /tmp: # sudo дальше этот файл прочитает. local dir=${GPER_CONFIG[temp_dir]} if ! mkdir -p "$dir" 2>/dev/null || [[ ! -w $dir ]]; then dir=${TMPDIR:-/tmp} fi local dest="${dir}/gper-linux-$(id -u).sh" rm -f "$dest" 2>/dev/null if ! command -v curl >/dev/null; then printf 'curl не найден — не могу сохранить меню на диск\n' >&2 return 1 fi if ! curl -fsSL --max-time 60 --connect-timeout 10 "$url" -o "$dest"; then printf 'Не удалось скачать меню с витрины\n' >&2 return 1 fi if [[ ! -s $dest ]]; then printf 'Витрина отдала пустой файл\n' >&2 return 1 fi exec bash "$dest" } # === src/private/gper_local_proxy.sh === # Локальный HTTP-прокси как Windows gper-local-proxy: # apt смотрит на 127.0.0.1 без пароля, helper сам добавляет # Proxy-Authorization на tinyproxy izvps. Пароль не в исходниках. GPER_LOCAL_PORT=18799 GPER_PROXY_PID_FILE='/var/tmp/gper/local-proxy.pid' GPER_PROXY_HELPER='/var/tmp/gper/gper-local-proxy.py' _gper_write_local_proxy_helper() { mkdir -p /var/tmp/gper cat > "$GPER_PROXY_HELPER" <<'PY' #!/usr/bin/env python3 # Слушает 127.0.0.1, на апстрим tinyproxy добавляет Basic. # Пароль только из env GPER_PROXY_AUTH (base64 user:pass). import os, socket, threading, sys, base64 listen = int(os.environ.get("GPER_LISTEN", "18799")) up = os.environ.get("GPER_UPSTREAM", "185.239.48.145:8899") up_host, up_port = up.rsplit(":", 1) up_port = int(up_port) auth = os.environ.get("GPER_PROXY_AUTH", "").strip().replace("\r", "").replace("\n", "") if not auth: sys.stderr.write("GPER_PROXY_AUTH пуст\n") sys.exit(1) try: decoded = base64.b64decode(auth) except Exception: sys.stderr.write("GPER_PROXY_AUTH не base64\n") sys.exit(1) if b":" not in decoded: sys.stderr.write("GPER_PROXY_AUTH не user:pass\n") sys.exit(1) auth_line = "Proxy-Authorization: Basic " + auth def read_headers(sock): buf = b"" while b"\r\n\r\n" not in buf and b"\n\n" not in buf and len(buf) < 65536: chunk = sock.recv(1) if not chunk: return None buf += chunk return buf def inject(raw): sep = "\r\n" if b"\r\n" in raw else "\n" text = raw.decode("iso-8859-1") parts = text.split(sep) out = [parts[0], auth_line] for line in parts[1:]: low = line.lower() if low.startswith("proxy-authorization:"): continue if low.startswith("proxy-connection:"): continue if low.startswith("connection:"): continue out.append(line) while out and out[-1] == "": out.pop() out.extend(["Connection: close", "", ""]) return sep.join(out).encode("iso-8859-1") def pump(src, dst): try: while True: data = src.recv(8192) if not data: break dst.sendall(data) except Exception: pass try: dst.shutdown(socket.SHUT_WR) except Exception: pass def handle(client): upsock = None try: client.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) hdr = read_headers(client) if not hdr: return upsock = socket.create_connection((up_host, up_port), 15) upsock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) upsock.sendall(inject(hdr)) t = threading.Thread(target=pump, args=(client, upsock), daemon=True) t.start() pump(upsock, client) t.join(timeout=600) except Exception as exc: sys.stderr.write("proxy handle: %s\n" % exc) finally: try: client.close() except Exception: pass if upsock is not None: try: upsock.close() except Exception: pass def main(): srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind(("127.0.0.1", listen)) srv.listen(32) sys.stderr.write("helper listen 127.0.0.1:%s auth_len=%s\n" % (listen, len(auth))) while True: c, _ = srv.accept() threading.Thread(target=handle, args=(c,), daemon=True).start() if __name__ == "__main__": main() PY chmod 700 "$GPER_PROXY_HELPER" } _gper_proxy_creds() { # stdout: user\tpass. Пароль в журнал не пишем. local user pass json user=${GPER_PROXY_USER:-${PROXY_USER:-tarakan}} pass=${GPER_PROXY_PASS:-${PROXY_PASS:-}} user=${user//$'\r'/} pass=${pass//$'\r'/} if [[ -z $pass && -f /etc/gper/proxy.json ]]; then json=$(cat /etc/gper/proxy.json 2>/dev/null) || json= if [[ $json =~ \"user\"[[:space:]]*:[[:space:]]*\"([^\"]*)\" ]]; then user=${BASH_REMATCH[1]} fi if [[ $json =~ \"password\"[[:space:]]*:[[:space:]]*\"([^\"]*)\" ]]; then pass=${BASH_REMATCH[1]} fi fi if [[ -z $pass && -t 0 ]]; then printf ' прокси izvps, логин %s\n' "$user" >&2 read -r -s -p ' пароль proxy: ' pass >&2 printf '\n' >&2 fi if [[ -z $pass ]]; then return 1 fi printf '%s\t%s' "$user" "$pass" } _gper_proxy_b64() { local raw=$1 if command -v base64 >/dev/null; then printf '%s' "$raw" | base64 -w0 2>/dev/null || printf '%s' "$raw" | base64 return 0 fi python3 -c 'import base64,sys; sys.stdout.write(base64.b64encode(sys.argv[1].encode()).decode())' "$raw" } _gper_local_proxy_stop() { local pid if [[ -f $GPER_PROXY_PID_FILE ]]; then pid=$(cat "$GPER_PROXY_PID_FILE" 2>/dev/null || true) if [[ -n $pid ]] && kill -0 "$pid" 2>/dev/null; then kill "$pid" 2>/dev/null || true sleep 0.2 kill -9 "$pid" 2>/dev/null || true fi rm -f "$GPER_PROXY_PID_FILE" fi pkill -f '/var/tmp/gper/gper-local-proxy.py' 2>/dev/null || true } _gper_local_proxy_listening() { python3 -c "import socket; socket.create_connection(('127.0.0.1', int('${GPER_LOCAL_PORT}')), 0.3).close()" 2>/dev/null } # wget через helper: tinyproxy должен отдать InRelease, не 401. _gper_local_proxy_smoke() { local body body=$(wget -qO- --timeout=20 -e use_proxy=yes -e http_proxy=http://127.0.0.1:${GPER_LOCAL_PORT} \ http://deb.debian.org/debian/dists/stable/InRelease 2>/dev/null | head -c 800 || true) if printf '%s' "$body" | grep -q 'Origin:'; then return 0 fi if printf '%s' "$body" | grep -qi 'Unauthorized'; then write_menu_log ERROR 'tinyproxy 401 — пароль не принят' printf ' tinyproxy 401 — PROXY_PASS не принят\n' >&2 return 1 fi write_menu_log ERROR 'proxy не отдаёт InRelease' printf ' proxy не отдаёт InRelease\n' >&2 return 1 } _gper_local_proxy_start() { command -v python3 >/dev/null || { write_menu_log WARN 'python3 нет — локальный proxy не запустить' return 1 } local creds user pass creds=$(_gper_proxy_creds) || { printf ' нет пароля proxy: PROXY_PASS или /etc/gper/proxy.json\n' write_menu_log WARN 'нет пароля proxy — PROXY_PASS или /etc/gper/proxy.json' return 1 } user=${creds%%$'\t'*} pass=${creds#*$'\t'} get_gper_config local upstream=${GPER_CONFIG[apt_proxy_upstream]:-185.239.48.145:8899} local auth auth=$(_gper_proxy_b64 "${user}:${pass}") _gper_local_proxy_stop _gper_write_local_proxy_helper mkdir -p /var/tmp/gper GPER_PROXY_AUTH=$auth GPER_UPSTREAM=$upstream GPER_LISTEN=$GPER_LOCAL_PORT \ python3 -u "$GPER_PROXY_HELPER" >/var/tmp/gper/local-proxy.log 2>&1 & echo $! > "$GPER_PROXY_PID_FILE" local i for i in 1 2 3 4 5 6 7 8 9 10; do if _gper_local_proxy_listening; then if _gper_local_proxy_smoke; then write_menu_log INFO "локальный proxy 127.0.0.1:${GPER_LOCAL_PORT} -> ${upstream}" printf ' локальный proxy 127.0.0.1:%s (izvps)\n' "$GPER_LOCAL_PORT" return 0 fi _gper_local_proxy_stop return 1 fi sleep 0.2 done _gper_local_proxy_stop write_menu_log ERROR 'локальный proxy не слушает' printf ' локальный proxy не поднялся\n' >&2 return 1 } # === src/private/gper_os.sh === # Проверка целевой ОС. Сборка идёт с Windows — шаги с apt/locale # на не-Debian пропускаются, а не валят конвейер. gper_is_debian() { [[ -f /etc/debian_version ]] } # === src/private/gper_write_if_changed.sh === # Пишет файл только если содержимое изменилось — иначе повторный # прогон не трогает inode и не дёргает службы зря. gper_write_if_changed() { local dest=$1 local tmp tmp=$(mktemp) cat > "$tmp" if [[ -f $dest ]] && cmp -s "$tmp" "$dest"; then rm -f "$tmp" return 0 fi mkdir -p "$(dirname "$dest")" cat "$tmp" > "$dest" rm -f "$tmp" return 0 } # === src/private/run_gper_bootstrap.sh === # То, ради чего curl. Выполняет пункты с _gper_item_autorun=1. # Сбой одного не отменяет остальные. run_gper_bootstrap() { get_menu_data write_menu_log INFO "Старт bootstrap gper-linux $(get_menu_version)" printf 'gper-linux %s — настройка новой ОС\n' "$(get_menu_version)" local i name action rc local ok=0 fail=0 for i in "${!GPER_ITEM_KEYS[@]}"; do if [[ ${GPER_ITEM_AUTORUN[i]} != 1 ]]; then continue fi name=${GPER_ITEM_NAME[i]} action=${GPER_ITEM_ACTION[i]} printf '→ %s\n' "$name" write_menu_log INFO "пункт: $name" if [[ -z $action ]]; then printf ' пропуск: нет action\n' write_menu_log WARN "нет action: $name" continue fi rc=0 "$action" || rc=$? if [[ $rc -eq 0 ]]; then printf ' ок\n' write_menu_log OK "$name" ok=$((ok + 1)) else printf ' сбой (код %s)\n' "$rc" write_menu_log ERROR "$name код $rc" fail=$((fail + 1)) fi done printf 'готово: ок %s, сбой %s\n' "$ok" "$fail" write_menu_log INFO "готово: ок $ok, сбой $fail" if ((fail > 0)); then return 1 fi return 0 } # === src/private/start_menu_log.sh === # Заводит файл журнала на текущий прогон. # Сначала каталог из конфига, не вышло — каталог загрузок. # Нигде не записалось — пустая строка, установка продолжается. start_menu_log() { get_gper_config local stamp name stamp=$(date +%Y%m%d) name="gper-linux_${stamp}.log" GPER_LOG_PATH= local dir path for dir in "${GPER_CONFIG[log_dir]}" "${GPER_CONFIG[temp_dir]}" '/var/tmp/gper'; do [[ -n $dir ]] || continue mkdir -p "$dir" 2>/dev/null || continue path="${dir}/${name}" if printf '' 2>/dev/null >> "$path"; then GPER_LOG_PATH=$path return 0 fi done return 0 } get_menu_log_path() { printf '%s' "${GPER_LOG_PATH:-}" } # === src/private/test_menu_cursor_match.sh === # Лежит ли пункт внутри ветки, заданной курсором. # Сравнение регистронезависимое: иначе опечатка в регистре # расщепит дерево надвое. ${var,,} — bash 4+, без shopt: # shopt nocasematch нельзя гнездить при set -e (bats). test_menu_cursor_match() { local n=${#GPER_CURSOR[@]} if ((n > ${#GPER_SEGMENTS[@]})); then return 1 fi local i for ((i = 0; i < n; i++)); do if [[ ${GPER_SEGMENTS[i],,} != "${GPER_CURSOR[i],,}" ]]; then return 1 fi done return 0 } # === src/private/write_menu_log.sh === # Пишет строку в журнал текущего прогона. # Пустой путь или сбой записи — не повод прерывать установку. write_menu_log() { local level=$1 shift local path=${GPER_LOG_PATH:-} [[ -n $path ]] || return 0 local ts ts=$(date '+%Y-%m-%d %H:%M:%S') printf '[%s] [%s] %s\n' "$ts" "$level" "$*" >> "$path" 2>/dev/null || true } # === src/items/item-apt-base.sh === item_apt_base() { _gper_item_path='Система/Гигиена' _gper_item_name='Базовые пакеты' _gper_item_order=20 _gper_item_descr='curl ca-certificates sudo htop tmux nano' _gper_item_action=ensure_apt_base _gper_item_autorun=1 } ensure_apt_base() { if ! gper_is_debian; then printf ' пропуск: не Debian/Ubuntu\n' write_menu_log WARN 'apt-base: не Debian/Ubuntu' return 0 fi apt_ensure curl ca-certificates sudo htop tmux nano } # === src/items/item-echo-demo.sh === # Учебный пункт очереди 1. Ничего не ставит — доказывает, что файл # в src/items/ сам попадает в дерево без правок ядра. item_echo_demo() { _gper_item_path='Учебные/Каркас' _gper_item_name='Эхо' _gper_item_order=10 _gper_item_descr='Учебный пункт очереди 1. Ничего не ставит' _gper_item_action=run_echo_demo } run_echo_demo() { printf '%s\n' 'учебный пункт очереди 1 отработал' } # === src/items/item-fish.sh === # Fish из репозитория дистрибутива. Плагины — с витрины, не с GitHub. # Login-shell: root и tarakan — fish; uai — bash (рабочий логин для скриптов). item_fish() { _gper_item_path='Система/Гигиена' _gper_item_name='Fish' _gper_item_order=40 _gper_item_descr='fish + fzf, плагины с витрины; shell: root/tarakan=fish, uai=bash' _gper_item_action=ensure_fish _gper_item_autorun=1 } # Имена архивов на витрине https://sh.gper.ru/fish/.tar.gz GPER_FISH_PLUGIN_NAMES='fisher z autopair replay puffer-fish abbreviation-tips fzf-fish gitnow docker-compose' # Распакованные архивы — источник для fisher, и он к ним обращается при # fisher update. Поэтому не /var/tmp (его чистит systemd-tmpfiles), а /var/lib. GPER_FISH_SRC_DIR='/var/lib/gper/fish' ensure_fish() { if ! gper_is_debian; then printf ' пропуск: не Debian/Ubuntu\n' write_menu_log WARN 'fish: не Debian/Ubuntu' return 0 fi apt_ensure fish fzf local fish_path fish_path=$(command -v fish) || { printf 'fish не найден после установки\n' >&2 return 1 } if ! grep -qxF "$fish_path" /etc/shells; then printf '%s\n' "$fish_path" >> /etc/shells write_menu_log INFO "добавлен в /etc/shells: $fish_path" fi ensure_fish_dotfiles local plugins_rc=0 ensure_fish_plugins || plugins_rc=$? local u for u in ${GPER_FISH_USERS:-root tarakan}; do _gper_set_login_shell "$u" "$fish_path" done local bash_path bash_path=$(command -v bash) for u in ${GPER_BASH_USERS:-uai}; do _gper_set_login_shell "$u" "$bash_path" done for u in ${GPER_FISH_USERS:-root tarakan} ${GPER_BASH_USERS:-uai}; do _gper_fix_fish_home "$u" done write_menu_log OK "fish $("$fish_path" --version 2>/dev/null | head -n1)" return "$plugins_rc" } _gper_set_login_shell() { local user=$1 local shell=$2 [[ -n $shell ]] || return 0 id -u "$user" >/dev/null 2>&1 || return 0 local current current=$(getent passwd "$user" | cut -d: -f7) if [[ $current == "$shell" ]]; then return 0 fi if chsh -s "$shell" "$user"; then write_menu_log INFO "shell $user -> $shell" printf ' shell %s -> %s\n' "$user" "$shell" else write_menu_log WARN "не сменил shell у $user" return 1 fi } # sudo -E сохраняет HOME вызвавшего, поэтому fish под root успевает # насоздавать в чужом доме файлов от root — дальше пользователь получает # "can not save universal variables" и теряет учёт плагинов. Возвращаем # владельца: чинит и следы прошлых прогонов. _gper_fix_fish_home() { local user=$1 home grp d owner home=$(getent passwd "$user" | cut -d: -f6) || return 0 [[ -n $home && -d $home ]] || return 0 grp=$(id -gn "$user" 2>/dev/null) || return 0 for d in "$home/.config/fish" "$home/.local/share/fish" "$home/.local/state/fish"; do [[ -d $d ]] || continue owner=$(stat -c %U "$d" 2>/dev/null) || continue [[ $owner == "$user" ]] && continue chown -R "$user:$grp" "$d" 2>/dev/null || continue write_menu_log INFO "вернул владельца $d -> $user" done } ensure_fish_dotfiles() { mkdir -p /etc/fish/conf.d gper_write_if_changed /etc/fish/vars.fish <<'FISH' set -gx xl /var/log/xray set -gx fish_config_file /etc/fish/conf.d/00-gper.fish set -gx fish_alias_file /etc/fish/alias.fish set -gx fish_keys_file /etc/fish/keys.fish set -gx fish_vars_file /etc/fish/vars.fish set -gx fish_functions_file /etc/fish/functions.fish FISH gper_write_if_changed /etc/fish/alias.fish <<'FISH' alias fish-config 'nano $fish_config_file' alias fish-keybind 'nano $fish_keys_file' alias fish-alias 'nano $fish_alias_file' alias fish-vars 'nano $fish_vars_file' alias fish-functions 'nano $fish_functions_file' alias fish-apply 'source $fish_config_file' alias update 'sudo apt update && sudo apt upgrade -y' alias llah 'sudo ls -lah --color=auto' alias own 'sudo chown -R root:root .' FISH gper_write_if_changed /etc/fish/keys.fish <<'FISH' # бинды. F10 = CSI 21~ if status is-interactive bind \e\[21~ exit end FISH # Имя с 00- обязательно. fish грузит conf.d по алфавиту, а conf.d # плагинов читают $fisher_path и $fish_snippets. Пока они не выставлены, # плагин молча берёт ~/.config/fish: docker-compose не заводит abbr, # gitnow на каждом старте лезет за .gitnow на GitHub через curl. rm -f /etc/fish/conf.d/gper.fish gper_write_if_changed /etc/fish/conf.d/00-gper.fish <<'FISH' # gper-linux: системные умолчания. Перезаписывается bootstrap'ом. # Плагины лежат в /etc/fish, а не в ~/.config/fish — путь объявляем здесь, # до того как conf.d плагинов начнут его читать. set -q fisher_path; or set -g fisher_path /etc/fish set -q fish_snippets; or set -g fish_snippets /etc/fish/conf.d set -q XDG_DATA_HOME; or set -gx XDG_DATA_HOME $HOME/.local/share set -q XDG_STATE_HOME; or set -gx XDG_STATE_HOME $HOME/.local/state set -q XDG_CONFIG_HOME; or set -gx XDG_CONFIG_HOME $HOME/.config set -q XDG_CACHE_HOME; or set -gx XDG_CACHE_HOME $HOME/.cache if status is-interactive fish_add_path -m $HOME/bin $HOME/.local/bin set -g fish_key_bindings fish_default_key_bindings set -g fish_escape_delay_ms 300 end test -f /etc/fish/vars.fish; and source /etc/fish/vars.fish test -f /etc/fish/keys.fish; and source /etc/fish/keys.fish test -f /etc/fish/alias.fish; and source /etc/fish/alias.fish FISH } ensure_fish_plugins() { get_gper_config local base="${GPER_CONFIG[bootstrap_url]}/fish" local tmp="${GPER_CONFIG[temp_dir]}/fish" local src=$GPER_FISH_SRC_DIR mkdir -p "$tmp" "$src" local name tar dir local fail=0 total=0 local -a ready=() for name in $GPER_FISH_PLUGIN_NAMES; do total=$((total + 1)) tar="${tmp}/${name}.tar.gz" dir="${src}/${name}" if ! curl -fsSL --max-time 60 --connect-timeout 10 "${base}/${name}.tar.gz" -o "$tar"; then printf ' нет плагина %s на витрине\n' "$name" write_menu_log WARN "fish plugin не скачался: $name" fail=1 continue fi rm -rf "$dir" "${tmp}/${name}" mkdir -p "$dir" if ! tar --no-same-owner -xzf "$tar" -C "$dir"; then printf ' битый архив плагина %s\n' "$name" write_menu_log WARN "fish plugin битый архив: $name" rm -rf "$dir" fail=1 continue fi ready+=("$dir") done if ((${#ready[@]} == 0)); then printf ' с витрины не приехал ни один плагин\n' write_menu_log ERROR 'fish: с витрины не приехал ни один плагин' return 1 fi local d if [[ -d $src/fisher/functions ]]; then _gper_fisher_install "${ready[@]}" || fail=1 else # Без самого fisher ставить нечем — раскладываем копированием, чтобы # машина осталась рабочей, но учёта плагинов тогда не будет. write_menu_log WARN 'fish: fisher не приехал — копирую плагины без учёта' for d in "${ready[@]}"; do _gper_install_fish_tree "$d" done fi # fisher копирует conf.d/*, а glob в fish не берёт файлы с точки. Из-за # этого gitnow остаётся без conf.d/.gitnow и на каждом старте лезет за # ним на GitHub через curl. Докладываем такие файлы руками. for d in "${ready[@]}"; do _gper_install_fish_dotfiles "$d" done write_menu_log INFO "fish плагинов в /etc/fish: ${#ready[@]} из $total" if ((fail > 0)); then write_menu_log ERROR 'fish: часть плагинов не встала' fi return "$fail" } # Ставит плагины самим fisher: он ведёт учёт в universal-переменных, и после # этого работают fisher list / update / remove. Источник — распакованные # каталоги с витрины, а не GitHub. _gper_fisher_install() { local -a dirs=("$@") local fish_path fish_path=$(command -v fish) || return 1 # Учёт fisher — universal-переменные fish, они лежат в доме пользователя. # start_gper_elevated поднимает права через sudo -E, а -E протаскивает # HOME вызвавшего: без этого учёт уезжает в /home/<кто запустил>. # Плагины системные, ставит их root — значит и учёт держим у root. local root_home root_home=$(getent passwd root | cut -d: -f6) root_home=${root_home:-/root} local -a fish_env=(env -u XDG_DATA_HOME -u XDG_CONFIG_HOME -u XDG_STATE_HOME -u XDG_CACHE_HOME "HOME=$root_home") # fisher отказывается писать поверх файлов, которых нет в его учёте # ("Cannot install ... conflicting files"). Убираем прежнюю раскладку. local d for d in "${dirs[@]}"; do _gper_clean_fish_tree "$d" done # Сам fisher ещё не в /etc/fish — подкладываем его каталог в # fish_function_path, иначе нечем поставить в том числе и его самого. # stdin закрываем: без tty fisher дочитывает его и берёт прочитанное за # имена плагинов, а bootstrap сам приходит из пайпа curl|bash. local rc=0 out out=$("${fish_env[@]}" "$fish_path" -c " set -g fisher_path /etc/fish set -p fish_function_path ${GPER_FISH_SRC_DIR}/fisher/functions fisher install ${dirs[*]} " &1) || rc=$? # fisher красит вывод даже без tty, а мы пишем в журнал и в pipe. out=$(printf '%s' "$out" | sed $'s/\033\\[[0-9;]*m//g') if ((rc != 0)); then printf '%s\n' "$out" | sed 's/^/ /' write_menu_log ERROR "fisher install код $rc" return 1 fi local listed listed=$("${fish_env[@]}" "$fish_path" -c 'fisher list' /dev/null | grep -c .) || listed=0 write_menu_log OK "fisher ведёт учёт плагинов ($root_home): $listed" printf ' fisher list: %s\n' "$listed" return 0 } # Зеркало _gper_install_fish_tree: убирает из /etc/fish ровно те файлы, # которые принёс архив. Чужого не трогает. _gper_clean_fish_tree() { local src=$1 sub f rel for sub in functions conf.d completions themes; do [[ -d $src/$sub ]] || continue while IFS= read -r f; do rel=${f#"$src/$sub/"} rm -f "/etc/fish/${sub}/${rel}" done < <(find "$src/$sub" -type f) done } _gper_install_fish_dotfiles() { _gper_install_fish_tree "$1" '.*' } # Второй аргумент — маска имени для find. По умолчанию берём всё дерево; # после fisher нужны только пропущенные им файлы с точки. _gper_install_fish_tree() { local src=$1 local mask=${2:-*} local sub f dest rel for sub in functions conf.d completions themes; do [[ -d $src/$sub ]] || continue mkdir -p "/etc/fish/${sub}" while IFS= read -r f; do rel=${f#"$src/$sub/"} dest="/etc/fish/${sub}/${rel}" mkdir -p "$(dirname "$dest")" if [[ -f $dest ]] && cmp -s "$f" "$dest"; then continue fi cp "$f" "$dest" done < <(find "$src/$sub" -type f -name "$mask") done } # === src/items/item-locale.sh === item_locale() { _gper_item_path='Система/Гигиена' _gper_item_name='Locale и часовой пояс' _gper_item_order=10 _gper_item_descr='ru_RU.UTF-8 + en_US.UTF-8, Europe/Moscow' _gper_item_action=ensure_locale_tz _gper_item_autorun=1 } ensure_locale_tz() { if ! gper_is_debian; then printf ' пропуск: не Debian/Ubuntu\n' write_menu_log WARN 'locale: не Debian/Ubuntu' return 0 fi local loc_ru=${GPER_LOCALE:-ru_RU.UTF-8} local loc_en=${GPER_LOCALE_EN:-en_US.UTF-8} local tz=${GPER_TIMEZONE:-Europe/Moscow} apt_ensure locales tzdata if [[ -f /etc/locale.gen ]]; then _gper_enable_locale "$loc_ru" _gper_enable_locale "$loc_en" if ! locale -a 2>/dev/null | grep -qiE "^${loc_ru%%.*}.*(utf8|UTF-8)$"; then locale-gen "$loc_ru" "$loc_en" fi fi update-locale LANG="$loc_ru" 2>/dev/null || true local cur_tz='' cur_tz=$(cat /etc/timezone 2>/dev/null || true) if [[ $cur_tz != "$tz" && -e /usr/share/zoneinfo/$tz ]]; then ln -sf "/usr/share/zoneinfo/$tz" /etc/localtime printf '%s\n' "$tz" > /etc/timezone DEBIAN_FRONTEND=noninteractive dpkg-reconfigure -f noninteractive tzdata fi write_menu_log OK "locale $loc_ru, tz $tz" } _gper_enable_locale() { local loc=$1 local gen=/etc/locale.gen [[ -f $gen ]] || return 0 if grep -qxF "${loc} UTF-8" "$gen"; then return 0 fi if grep -qxF "# ${loc} UTF-8" "$gen"; then sed -i "s/^# ${loc} UTF-8/${loc} UTF-8/" "$gen" return 0 fi printf '%s UTF-8\n' "$loc" >> "$gen" } # === src/items/item-unattended.sh === item_unattended() { _gper_item_path='Система/Гигиена' _gper_item_name='Автообновления' _gper_item_order=50 _gper_item_descr='unattended-upgrades, без вопросов' _gper_item_action=ensure_unattended _gper_item_autorun=1 } ensure_unattended() { if ! gper_is_debian; then printf ' пропуск: не Debian/Ubuntu\n' write_menu_log WARN 'unattended: не Debian/Ubuntu' return 0 fi apt_ensure unattended-upgrades local f=/etc/apt/apt.conf.d/20auto-upgrades if [[ ! -f $f ]]; then printf '%s\n' \ 'APT::Periodic::Update-Package-Lists "1";' \ 'APT::Periodic::Unattended-Upgrade "1";' > "$f" write_menu_log INFO "записан $f" fi } # === src/items/item-users.sh === # Пользователи bootstrap: uai, tarakan. Ключи ещё и root. # Ключи не литералом: GPER_SSH_KEYS или https://sh.gper.ru/authorized_keys. # Root не создаём и sudoers ему не пишем. item_users() { _gper_item_path='Система/Гигиена' _gper_item_name='Пользователи' _gper_item_order=30 _gper_item_descr='uai и tarakan: группа sudo + NOPASSWD, ключи; root — только ключи' _gper_item_action=ensure_gper_users _gper_item_autorun=1 } get_gper_ssh_keys() { if [[ -n ${GPER_SSH_KEYS:-} ]]; then printf '%s\n' "$GPER_SSH_KEYS" return 0 fi get_gper_config local url=${GPER_CONFIG[ssh_keys_url]:-} [[ -n $url ]] || return 1 command -v curl >/dev/null || return 1 local body body=$(curl -fsSL --max-time 30 --connect-timeout 10 "$url" 2>/dev/null) || return 1 [[ -n $body ]] || return 1 printf '%s\n' "$body" } ensure_gper_users() { if ! gper_is_debian; then printf ' пропуск: не Debian/Ubuntu\n' write_menu_log WARN 'users: не Debian/Ubuntu' return 0 fi apt_ensure sudo local users=${GPER_BOOTSTRAP_USERS:-'uai tarakan'} local u for u in $users; do _gper_ensure_login_user "$u" done _gper_ensure_user_keys root } _gper_ensure_login_user() { local user=$1 if [[ $user == root ]]; then _gper_ensure_user_keys root return 0 fi if ! id -u "$user" >/dev/null 2>&1; then if command -v adduser >/dev/null; then adduser --disabled-password --gecos '' --shell /bin/bash "$user" else useradd -m -s /bin/bash "$user" fi write_menu_log INFO "создан пользователь $user" printf ' создан %s\n' "$user" else write_menu_log INFO "пользователь $user уже есть" fi local sudo_file="/etc/sudoers.d/90-gper-${user}" local desired="${user} ALL=(ALL) NOPASSWD:ALL" if [[ -f $sudo_file ]] && grep -qxF "$desired" "$sudo_file"; then : else printf '%s\n' "$desired" > "$sudo_file" chmod 0440 "$sudo_file" write_menu_log INFO "sudo NOPASSWD для $user" fi # sudoers.d даёт NOPASSWD, но `groups` и пакеты смотрят членство. # adduser без -G sudo пользователя в группу не кладёт. if getent group sudo >/dev/null; then if id -nG "$user" 2>/dev/null | tr ' ' '\n' | grep -qx sudo; then : else usermod -aG sudo "$user" write_menu_log INFO "$user в группе sudo" printf ' %s в группе sudo\n' "$user" fi else write_menu_log WARN "группы sudo нет — только $sudo_file" fi _gper_ensure_user_keys "$user" } _gper_ensure_user_keys() { local user=$1 local home home=$(getent passwd "$user" | cut -d: -f6) if [[ -z $home ]]; then write_menu_log WARN "нет дома у $user" return 0 fi mkdir -p "$home/.ssh" chmod 700 "$home/.ssh" local auth="$home/.ssh/authorized_keys" touch "$auth" chmod 600 "$auth" local keys k added=0 keys=$(get_gper_ssh_keys) || keys= if [[ -z $keys ]]; then get_gper_config write_menu_log WARN "нет SSH-ключей для $user — env GPER_SSH_KEYS или ${GPER_CONFIG[ssh_keys_url]:-URL}" else while IFS= read -r k; do [[ -z $k || $k == \#* ]] && continue if grep -qFx -- "$k" "$auth"; then continue fi printf '%s\n' "$k" >> "$auth" added=$((added + 1)) done <<< "$keys" write_menu_log INFO "$user: ключей добавлено $added" fi chown -R "$user:$user" "$home/.ssh" 2>/dev/null || chown -R "$user:" "$home/.ssh" } # === src/public/start_gper_elevated.sh === # Права сразу. sudo без пароля (NOPASSWD) — exec. # Иначе не висим на приглашении: curl с пайпа и bats tty не дают. start_gper_elevated() { local uid uid=$(id -u) if [[ $uid -eq 0 ]]; then return 0 fi if ! command -v sudo >/dev/null; then return 0 fi if [[ -z ${GPER_SCRIPT_PATH:-} || ! -f $GPER_SCRIPT_PATH ]]; then printf 'Нет файла скрипта — не могу запросить sudo\n' >&2 return 0 fi if sudo -n true >/dev/null 2>&1; then exec sudo -n -E bash "$GPER_SCRIPT_PATH" fi printf 'Нет root и sudo без пароля — шаги, которым нужен root, пропустятся\n' >&2 return 0 } # === src/public/start_gper_menu.sh === # Главный вход curl: не TUI, а bootstrap новой ОС. start_gper_menu() { start_menu_log write_menu_log INFO "Старт gper-linux $(get_menu_version) ($(get_menu_edition))" run_gper_bootstrap } # --- автозапуск --- if [[ ${GPER_NOAUTORUN:-} != 1 ]]; then gper_ensure_file || true start_gper_elevated start_gper_menu fi