#!/usr/bin/env bash # gper-linux — меню-установщик для Ubuntu / Debian / Proxmox # Версия: 0.1.6 # Издание: guest # Собран 2026-10-26T00:52:23Z. Не править этот файл — собери заново. # set -e не ставим: сбой пункта не должен ронять остальные. GPER_EDITION='guest' GPER_VERSION='0.1.6' # === 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/ensure_fish_my_disks.sh === # Функция fish my_disks (диски, разделы, ZFS со свободным местом), алиас dsk. # Источник: _Scripts/sh/my_disks/my_disks_V03.fish — новую версию переносить # сюда целиком. Кладём в /etc/fish/functions: fish подгружает её сам при # первом вызове, у всех пользователей. ensure_fish_my_disks() { gper_write_if_changed /etc/fish/functions/my_disks.fish <<'FISH' function __my_disks_use_color --argument-names pct warn crit # Prints color escape for usage percent ("-" = unknown) if test "$pct" = "-" set_color normal else if test $pct -ge $crit set_color --bold red else if test $pct -ge $warn set_color yellow else set_color green end end function __my_disks_pad --argument-names width text # Left-aligned column padded by display width (printf in fish 4.0 pads by bytes) string pad -r -w $width -- $text end function my_disks --description "Красивый вывод дисков, разделов и ZFS со свободным местом" # --- Config --- set -l exclude_re '^(loop|zd|zram|ram|sr|fd)' # virtual/unwanted devices set -l warn_pct 70 # usage % -> yellow set -l crit_pct 90 # usage % -> red argparse h/help d/disks -- $argv; or return 1 if set -q _flag_help echo "Использование: my_disks [-d|--disks]" echo " -d, --disks только диски, без разделов и ZFS-датасетов" return 0 end if not command -q lsblk echo (set_color red)"Ошибка: lsblk не найден"(set_color normal) >&2 return 1 end # --- Colors --- set -l c_head (set_color --bold cyan) set -l c_disk (set_color --bold yellow) set -l c_part (set_color yellow) set -l c_fs (set_color green) set -l c_free (set_color magenta) set -l c_none (set_color normal) # --- ZFS: map vdev device -> pool info (lsblk knows nothing about ZFS mounts) --- set -l zfs_devs set -l zfs_pools set -l zfs_free set -l zfs_cap set -l zfs_mnt if command -q zpool set -l pool; set -l pfree; set -l pcap; set -l pmnt # -H: tab-separated, -P: full paths, -L: resolve symlinks (by-id -> /dev/sdX) for line in (zpool list -vHPL 2>/dev/null) set -l f (string split \t -- $line) if test -n "$f[1]" # Pool line: name size alloc free ckpoint expandsz frag cap ... set pool $f[1] set pfree $f[4] set pcap $f[8] set -l mps (zfs list -H -r -t filesystem -o mounted,mountpoint -- $pool 2>/dev/null | string match -rg '^yes\t(.*)') if contains -- / $mps set pmnt / else if test (count $mps) -gt 0 set pmnt $mps[1] else set pmnt - end else if string match -q '/dev/*' -- $f[2] # Vdev line with a real device set -a zfs_devs (string replace '/dev/' '' -- $f[2]) set -a zfs_pools $pool set -a zfs_free $pfree set -a zfs_cap $pcap set -a zfs_mnt $pmnt end end end # --- Block devices --- # -P: key="value" pairs, safe with empty columns set -l lsblk_args -P -n -o NAME,TYPE,SIZE,FSTYPE,FSAVAIL,FSUSE%,MOUNTPOINT set -q _flag_disks; and set -a lsblk_args -d echo echo " $c_head"(__my_disks_pad 14 "ДИСК")" "(__my_disks_pad 8 "РАЗМЕР")" "(__my_disks_pad 9 "СВОБОДНО")" "(__my_disks_pad 5 "ИСП%")" "(__my_disks_pad 12 "ФС")" ТОЧКА МОНТИРОВАНИЯ$c_none" echo " "(string repeat -n 76 ─) set -l seen lsblk $lsblk_args 2>/dev/null | while read -l line # Empty values -> "-", then extract values in column order set -l v (string replace -a '=""' '="-"' -- $line | string match -rag '="([^"]*)"') test (count $v) -eq 7; or continue set -l name $v[1] set -l type $v[2] set -l size $v[3] set -l fs $v[4] set -l free $v[5] set -l use $v[6] set -l mount (string replace -a '\x20' ' ' -- $v[7]) # Skip virtual devices, optical drives and duplicates (RAID/LVM children repeat per member) string match -qr -- $exclude_re $name; and continue contains -- $type loop rom; and continue contains -- $name $seen; and continue set -a seen $name # ZFS vdev: take pool data if set -l i (contains -i -- $name $zfs_devs) set fs "ZFS:$zfs_pools[$i]" set free $zfs_free[$i] set use $zfs_cap[$i] set mount $zfs_mnt[$i] end # Friendly FS names switch $fs case linux_raid_member; set fs "RAID" case LVM2_member; set fs "LVM" case zfs_member; set fs "ZFS" case crypto_LUKS; set fs "LUKS" end # Tree-like name for non-disk devices set -l c_name $c_disk if test "$type" != disk set name "└─$name" set c_name $c_part end set -l c_use (__my_disks_use_color (string replace '%' '' -- $use) $warn_pct $crit_pct) echo " $c_name"(__my_disks_pad 14 $name)" $c_none"(__my_disks_pad 8 $size)" $c_free"(__my_disks_pad 9 $free)" $c_use"(__my_disks_pad 5 $use)" $c_fs"(__my_disks_pad 12 $fs)" $c_none$mount" end # --- ZFS datasets --- if not set -q _flag_disks; and command -q zfs set -l ds (zfs list -Hp -t filesystem -o name,used,avail,mountpoint,mounted 2>/dev/null) if test (count $ds) -gt 0 echo echo " $c_head"(__my_disks_pad 30 "ZFS ДАТАСЕТ")" "(__my_disks_pad 9 "СВОБОДНО")" "(__my_disks_pad 9 "ЗАНЯТО")" "(__my_disks_pad 5 "ИСП%")" ТОЧКА МОНТИРОВАНИЯ$c_none" echo " "(string repeat -n 76 ─) for line in $ds set -l f (string split \t -- $line) test "$f[5]" = yes; or continue set -l total (math "$f[2] + $f[3]") set -l pct - test $total -gt 0; and set pct (math -s0 "$f[2] * 100 / $total") set -l c_use (__my_disks_use_color $pct $warn_pct $crit_pct) test "$pct" != "-"; and set pct "$pct%" echo " $c_part"(__my_disks_pad 30 $f[1])" $c_free"(__my_disks_pad 9 (numfmt --to=iec $f[3]))" $c_none"(__my_disks_pad 9 (numfmt --to=iec $f[2]))" $c_use"(__my_disks_pad 5 $pct)" $c_none$f[4]" end end end echo end FISH } # === 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 4: из дистрибутива, если он даёт 4.x, иначе из репозитория проекта # 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 4 + fzf, плагины с витрины; shell: root/tarakan=fish, uai=bash' _gper_item_action=ensure_fish _gper_item_autorun=1 } # Ниже этой мажорной версии fish не оставляем. Debian 12 и Ubuntu 24.04 # дают 3.x — тогда репозиторий самого проекта: Debian — OBS, Ubuntu — PPA # (на OBS сборок под Ubuntu нет). GPER_FISH_MIN_MAJOR=4 GPER_FISH_OBS_URL='https://download.opensuse.org/repositories/shells:/fish:/release:/4' GPER_FISH_PPA_URL='https://ppa.launchpadcontent.net/fish-shell/release-4/ubuntu' # Ключ PPA — по полному отпечатку из API Launchpad, не по короткому id. GPER_FISH_PPA_KEY_URL='https://keyserver.ubuntu.com/pks/lookup?op=get&options=mr&search=0x88421E703EDC7AF54967DED473C9FCC9E2BB48DA' GPER_FISH_APT_LIST='/etc/apt/sources.list.d/gper-fish.list' # .asc: apt сам читает armored-ключ, а gnupg в минимальном Debian нет. GPER_FISH_APT_KEY='/etc/apt/keyrings/gper-fish.asc' # Имена архивов на витрине 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 fzf # fish без нужной версии — не повод бросать настройку: плагины и # дотфайлы встанут и на старый, а код ошибки вернём в конце. local fish_rc=0 ensure_fish_major || fish_rc=$? 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)" ((fish_rc == 0)) || return "$fish_rc" return "$plugins_rc" } # Уже стоит нужная версия — не трогаем. Иначе дистрибутив, если он даёт # 4.x (Debian 13+), а нет — репозиторий проекта. Стоящий 3.x обновляем # поверх: удалять нельзя, это login-shell root. ensure_fish_major() { local have have=$(_gper_fish_installed_major) if ((have >= GPER_FISH_MIN_MAJOR)); then # Свой репозиторий держим в порядке: ключи OBS протухают, а после # смены релиза Debian ветка в .list уже чужая. if [[ -f $GPER_FISH_APT_LIST ]]; then _gper_add_fish_repo || true fi write_menu_log INFO "fish ${have}.x уже стоит" return 0 fi _gper_apt_update || true if (($(_gper_fish_candidate_major) < GPER_FISH_MIN_MAJOR)); then _gper_add_fish_repo || return 1 _gper_apt_update || true fi if ((have == 0)); then apt_ensure fish || true else write_menu_log INFO "обновляю fish ${have}.x" printf ' обновляю fish %s.x\n' "$have" _gper_apt_install fish || true fi have=$(_gper_fish_installed_major) if ((have < GPER_FISH_MIN_MAJOR)); then printf ' fish %s не встал\n' "$GPER_FISH_MIN_MAJOR" >&2 write_menu_log ERROR "fish ${GPER_FISH_MIN_MAJOR} не встал, сейчас мажорная версия: ${have}" return 1 fi return 0 } _gper_fish_installed_major() { local v v=$(fish --version 2>/dev/null) || v= _gper_ver_major "$v" } # apt-cache переводит заголовки: при ru_RU вместо Candidate будет «Кандидат». _gper_fish_candidate_major() { local v v=$(LC_ALL=C apt-cache policy fish 2>/dev/null | awk '$1 == "Candidate:" { print $2 }') _gper_ver_major "$v" } # Мажорная версия из "fish, version 4.0.2" или "1:4.9.3-1~noble"; нет — 0. _gper_ver_major() { local v=${1##* } v=${v#*:} v=${v%%.*} if [[ $v =~ ^[0-9]+$ ]]; then printf '%s\n' "$v" else printf '0\n' fi } # Подключает репозиторий проекта fish под этот дистрибутив. Производные # (Mint, PVE) узнаём по ID_LIKE; Ubuntu проверяем первой — у неё ID_LIKE=debian. _gper_add_fish_repo() { local id ver codename IFS='|' read -r id ver codename < <( # shellcheck disable=SC1091 . /etc/os-release printf '%s|%s|%s\n' "${ID:-} ${ID_LIKE:-}" "${VERSION_ID:-}" \ "${UBUNTU_CODENAME:-${VERSION_CODENAME:-}}" ) local key_url= line= case " $id " in *' ubuntu '*) if [[ -n $codename ]]; then key_url=$GPER_FISH_PPA_KEY_URL line="deb [signed-by=${GPER_FISH_APT_KEY}] ${GPER_FISH_PPA_URL} ${codename} main" fi ;; *' debian '*) # У testing/sid нет VERSION_ID, но там fish 4 и так в дистрибутиве. if [[ -n $ver ]]; then key_url="${GPER_FISH_OBS_URL}/Debian_${ver}/Release.key" line="deb [signed-by=${GPER_FISH_APT_KEY}] ${GPER_FISH_OBS_URL}/Debian_${ver}/ /" fi ;; esac if [[ -z $key_url ]]; then printf ' не знаю, откуда взять fish %s для %s\n' "$GPER_FISH_MIN_MAJOR" "$id" write_menu_log WARN "fish: нет репозитория под ${id} ${ver}" return 1 fi local tmp tmp=$(mktemp) if ! curl -fsSL --max-time 60 --connect-timeout 10 "$key_url" -o "$tmp" || ! grep -q 'BEGIN PGP PUBLIC KEY BLOCK' "$tmp"; then rm -f "$tmp" printf ' не скачался ключ репозитория fish\n' write_menu_log WARN "fish: не скачался ключ $key_url" return 1 fi gper_write_if_changed "$GPER_FISH_APT_KEY" < "$tmp" rm -f "$tmp" gper_write_if_changed "$GPER_FISH_APT_LIST" <<< "$line" write_menu_log INFO "fish: репозиторий $line" return 0 } _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 .' alias dsk my_disks 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_my_disks } 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