Netpulse_SasS/netpulse
byrsapty 3f6bcbc5a0 Установник: підказка про зайнятий 9443 більше не шле шукати nginx
Перевірка портів давала на всі три випадки один текст: «майже завжди
сторонній веб-сервер (nginx, apache)». Для 80 і 443 це правда, для
9443 — ні: це порт колектора зондів, і найімовірніша причина там —
інший NetPulse на тій самій машині.

Знайшлось сухим прогоном на стенді, де бойова система тримає всі три
порти. Формально повідомлення «правильне» — порт справді зайнятий, — і
саме тому воно небезпечне: людина піде шукати nginx, якого немає.

Заодно зафіксовано, що сухий прогін працює: 12 кроків, обчислені
значення (shared_buffers 1024MB, стеля кешу 512mb, вісім фонових
робітників на чотирьох ядрах), і чесне «на справжній установці це
зупинило б» замість тихого продовження.
2026-08-27 17:43:05 +03:00

1833 lines
79 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env sh
#
# NetPulse — установка й обслуговування однією командою.
#
# Чому це існує. Розгортання складалося з вісімнадцяти змінних оточення
# й шестисот рядків інструкції. Рішень людини серед тих вісімнадцяти —
# п'ять; решта це або секрети, які не можна давати вводити руками, або
# значення, у яких є одна правильна відповідь, або PG_SHARED_BUFFERS,
# який мусить рахуватись із пам'яті машини: помилка в ньому на хості з
# 4 ГБ — це не «повільніше», це «Postgres не піднявся».
#
# Процедуру, яку клієнт не може виконати правильно з першого разу, не
# рятує докладніша інструкція. Її рятує те, що виконувати нічого.
#
# ./netpulse install поставити (ідемпотентно: можна повторювати)
# ./netpulse check лікар: ті самі твердження на живій системі
# ./netpulse backup дамп бази + ключі, без яких дамп марний
# ./netpulse restore -f відновлення з рамкою TimescaleDB
# ./netpulse upgrade перезбирання, міграції, перевірка
# ./netpulse logs [service]
#
# Головне про install — останній крок. Установник НЕ МАЄ ПРАВА сказати
# «готово» на підставі того, що контейнери піднялись. Рівно так уже
# було: усі служби «Started», а вхід у систему повертав 403, бо під
# новою роллю база віддавала нуль кабінетів. Тому останнє, що робить
# install, — заходить у систему справжнім паролем через HTTP і звіряє,
# що кабінет назвався, а переліки, які наливають міграції, не порожні.
# Не пройшло — не «готово з попередженням», а зупинка з назвою кроку.
#
# POSIX sh, не bash: сервер клієнта може бути будь-яким, і dash тут
# зустрічається частіше, ніж здається.
set -u
# CDPATH= — не помилка з пробілом, а гасіння CDPATH: якщо він виставлений
# в оточенні (а на робочих машинах буває), cd мовчки піде не туди, і скрипт
# правитиме .env у чужому каталозі.
# shellcheck disable=SC1007
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
ENV_FILE="$ROOT/.env"
CONF_FILE="$ROOT/netpulse.conf"
# Порт API всередині мережі docker. Самоперевірка ходить саме сюди, а не
# крізь проксі: нас цікавить ланцюг «HTTP → автентифікація → база», і
# додавати до нього TLS і DNS означало б плутати дві різні поломки.
API_INTERNAL="http://127.0.0.1:8080"
DRY=0
ASSUME_YES=0
OWNER_PASSWORD=""
RESTORE_FILE=""
# Заповнюється кроками; підсумок друкується один раз у кінці.
OUT_URL=""
OUT_OWNER_PW=""
OUT_ENROLL=""
OUT_OWNER_EXISTED=0
# ---------------------------------------------------------------------
# Вивід
# ---------------------------------------------------------------------
#
# Кольори лише в терміналі: у `./netpulse check > лист.txt`, який
# клієнт надсилає в підтримку, escape-послідовності перетворюють
# зрозумілий звіт на кашу.
if [ -t 1 ]; then
C_B=$(printf '\033[1m'); C_R=$(printf '\033[31m')
C_Y=$(printf '\033[33m'); C_G=$(printf '\033[32m'); C_0=$(printf '\033[0m')
else
C_B=''; C_R=''; C_Y=''; C_G=''; C_0=''
fi
STEP_NAME=""
STEP_NO=0
say() { printf '%s\n' "$*"; }
ok() { printf ' %sok%s %s\n' "$C_G" "$C_0" "$*"; }
warn() { printf ' %sувага%s %s\n' "$C_Y" "$C_0" "$*"; }
bad() { printf ' %sні%s %s\n' "$C_R" "$C_0" "$*"; }
step() {
STEP_NO=$((STEP_NO + 1))
STEP_NAME=$1
printf '\n%s== крок %d · %s%s\n' "$C_B" "$STEP_NO" "$STEP_NAME" "$C_0"
}
# die зупиняє все й називає КРОК. Назва кроку тут не оздоба: людина
# читає її вголос у телефон, і від неї залежить, чи розбирається далі
# вона сама, чи надсилає вивід.
die() {
printf '\n%sЗУПИНКА на кроці «%s»%s\n' "$C_R" "$STEP_NAME" "$C_0"
for _l in "$@"; do printf ' %s\n' "$_l"; done
if [ "${CMD:-}" = install ]; then
printf '\n Нічого незворотного не сталося: install можна запускати повторно —\n'
printf ' наявні секрети він підхопить із .env, а не перевипустить.\n'
fi
exit 1
}
# ---------------------------------------------------------------------
# docker compose
# ---------------------------------------------------------------------
#
# Дві несумісні реалізації з однаковою назвою. Обгортка визначається
# один раз, щоб решта скрипта не знала, яка з них тут.
DC_KIND=""
pick_compose() {
if docker compose version >/dev/null 2>&1; then
DC_KIND="plugin"
elif command -v docker-compose >/dev/null 2>&1; then
DC_KIND="legacy"
else
DC_KIND=""
fi
}
dc() {
case "$DC_KIND" in
plugin) ( cd "$ROOT" && docker compose "$@" ) ;;
legacy) ( cd "$ROOT" && docker-compose "$@" ) ;;
*) return 127 ;;
esac
}
# ---------------------------------------------------------------------
# Дрібні помічники
# ---------------------------------------------------------------------
have() { command -v "$1" >/dev/null 2>&1; }
# Версія у вигляді числа, щоб порівнювати без sort -V (його немає в
# busybox-середовищах, а установник має працювати й там).
ver_num() {
printf '%s' "$1" | sed 's/^[vV]//; s/[^0-9.].*//' |
awk -F. '{ printf "%d%03d%03d", $1, $2, $3 }'
}
ver_ge() { [ "$(ver_num "$1")" -ge "$(ver_num "$2")" ] 2>/dev/null; }
# Значення з .env. Береться ОСТАННЄ входження: саме так читає docker
# compose, і розходження тут означало б, що установник і compose бачать
# різні паролі.
env_get() {
[ -f "$ENV_FILE" ] || return 1
sed -n "s/^$1=//p" "$ENV_FILE" | tail -1
}
# Рядкове поле JSON. Ключ обов'язково після «{» або «,», інакше пошук
# "token" знаходить хвіст "access_token" і повертає не той рядок.
json_str() {
sed -n "s/.*[{,][[:space:]]*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" |
head -1
}
# Чи є у відповіді хоч один елемент переліку.
#
# Рахувати елементи в sh дорого й крихко, а нам потрібне рівно одне
# твердження: «перелік не порожній». Після прибирання пробілів масив
# об'єктів завжди починається з «[{», а порожній — з «[]». Працює і для
# голого масиву, і для загорнутого в об'єкт, тобто не залежить від
# того, як саме назвали поле.
json_nonempty() {
printf '%s' "$1" | tr -d ' \n\r\t' | grep -q '\[{'
}
# Секрети — ТІЛЬКИ hex.
#
# `openssl rand -base64 24` дає символи «/», «+» і «=». Пароль їде
# всередині DSN виду postgres://user:пароль@db, де «/» починає ім'я
# бази: пароль обривається мовчки, а помилка приходить як
# «password authentication failed» — тобто вказує не туди. Ця вада вже
# ловилась у цьому проєкті; hex не має жодного символу, який щось
# означає в URL, і коштує нам лише довжини рядка.
rand_hex() {
if have openssl; then
openssl rand -hex "$1"
elif [ -r /dev/urandom ]; then
od -An -tx1 -N "$1" /dev/urandom | tr -d ' \n'
else
return 1
fi
}
# Пароль для людини: його читають з екрана й набирають у браузері.
# Алфавіт без 0/O/1/l/I — не з ввічливості, а тому що переплутана
# літера тут виглядає як «пароль не підходить», і розбирають це годину.
rand_pass() {
if [ -r /dev/urandom ]; then
LC_ALL=C tr -dc 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789' \
< /dev/urandom 2>/dev/null | head -c 24
printf '\n'
else
return 1
fi
}
# ---------------------------------------------------------------------
# Стан хоста
# ---------------------------------------------------------------------
host_mem_mb() {
if [ -r /proc/meminfo ]; then
awk '/^MemTotal:/ { printf "%d", $2 / 1024; exit }' /proc/meminfo
elif have sysctl && sysctl -n hw.memsize >/dev/null 2>&1; then
sysctl -n hw.memsize | awk '{ printf "%d", $1 / 1048576 }'
else
printf '0'
fi
}
# Кількість ядер. nproc є не всюди (busybox його не має), тому далі
# /proc/cpuinfo і sysctl. Нуль означає «не знаємо» — і викликач має
# обрати обережне значення, а не вдавати, що ядро одне.
host_cpus() {
if have nproc; then
nproc 2>/dev/null || printf '0'
elif [ -r /proc/cpuinfo ]; then
awk '/^processor/ { c++ } END { printf "%d", c }' /proc/cpuinfo
elif have sysctl && sysctl -n hw.ncpu >/dev/null 2>&1; then
sysctl -n hw.ncpu
else
printf '0'
fi
}
# Вільне місце в МБ для каталогу. Питаємо і про репозиторій, і про
# /var/lib/docker: томи бази лежать там, і на розрізаному диску це
# різні файлові системи.
free_mb() {
df -Pk "$1" 2>/dev/null | awk 'NR==2 { printf "%d", $4 / 1024 }'
}
# Чи слухає хтось порт. Три способи, бо ss немає на старих системах,
# netstat викинули з нових, а /proc/net є на будь-якому Linux.
port_busy() {
_p=$1
_proto=${2:-tcp}
if have ss; then
if [ "$_proto" = udp ]; then
ss -lunH 2>/dev/null | awk '{print $5}' | grep -q "[:.]$_p\$" && return 0
else
ss -ltnH 2>/dev/null | awk '{print $4}' | grep -q "[:.]$_p\$" && return 0
fi
return 1
fi
if have netstat; then
if [ "$_proto" = udp ]; then
netstat -lun 2>/dev/null | awk '{print $4}' | grep -q "[:.]$_p\$" && return 0
else
netstat -ltn 2>/dev/null | awk '{print $4}' | grep -q "[:.]$_p\$" && return 0
fi
return 1
fi
_hex=$(printf ':%04X' "$_p")
for _f in "/proc/net/$_proto" "/proc/net/${_proto}6"; do
[ -r "$_f" ] || continue
if awk -v p="$_hex" -v pr="$_proto" '
NR > 1 && substr($2, length($2) - 4) == p &&
(pr == "udp" || $4 == "0A") { found = 1 }
END { exit !found }' "$_f"; then
return 0
fi
done
return 1
}
host_tz() {
if [ -r /etc/timezone ]; then
tr -d ' \n\r' < /etc/timezone
elif [ -L /etc/localtime ]; then
readlink /etc/localtime | sed 's|.*/zoneinfo/||'
else
printf ''
fi
}
# Адреса, якою цю машину видно ззовні. Не питаємо інтернет: установник
# не має права ходити в мережу за спиною того, хто його запустив, а на
# закритому контурі такий запит ще й повисне на хвилину.
host_ip() {
if have ip; then
_a=$(ip route get 1.1.1.1 2>/dev/null | sed -n 's/.*src \([0-9.]*\).*/\1/p' | head -1)
[ -n "$_a" ] && { printf '%s' "$_a"; return 0; }
fi
if have hostname; then
_a=$(hostname -I 2>/dev/null | awk '{print $1}')
[ -n "$_a" ] && { printf '%s' "$_a"; return 0; }
fi
if have ifconfig; then
ifconfig 2>/dev/null |
sed -n 's/.*inet \(addr:\)\{0,1\}\([0-9.]*\).*/\2/p' |
grep -v '^127\.' | head -1
return 0
fi
printf ''
}
# ---------------------------------------------------------------------
# КРОК 1. Передпольотна перевірка
# ---------------------------------------------------------------------
#
# Усе, що тут перевіряється, ламається пізніше і дорожче: без місця на
# диску Postgres падає посеред міграцій, без пам'яті не піднімається
# зовсім, а зайнятий 443 виявляється тоді, коли все інше вже працює й
# треба розбирати, чому не відкривається сторінка.
preflight() {
step "передпольотна перевірка"
_fatal=0
if ! have docker; then
_fatal=1
bad "docker не знайдено"
else
_dv=$(docker version --format '{{.Server.Version}}' 2>/dev/null)
if [ -z "$_dv" ]; then
_fatal=1
bad "docker є, але демон не відповідає (docker info падає)"
elif ver_ge "$_dv" 20.10; then
ok "docker $_dv"
else
_fatal=1
bad "docker $_dv — потрібен 20.10 або новіший"
fi
fi
pick_compose
case "$DC_KIND" in
plugin)
_cv=$(docker compose version --short 2>/dev/null)
if ver_ge "$_cv" 2.0; then
ok "docker compose $_cv"
else
_fatal=1
bad "docker compose $_cv — потрібен 2.0 або новіший"
fi
;;
legacy)
_fatal=1
bad "знайдено лише docker-compose v1; compose-файл проєкту використовує
можливості v2 (profiles, depends_on.condition). Потрібен пакет
docker-compose-plugin"
;;
*)
_fatal=1
bad "docker compose не знайдено"
;;
esac
_mem=$(host_mem_mb)
if [ "$_mem" -eq 0 ]; then
warn "не вдалося визначити обсяг пам'яті — розрахунок shared_buffers буде обережним"
elif [ "$_mem" -lt 2048 ]; then
_fatal=1
bad "пам'яті $_mem МБ. Postgres, TimescaleDB, кеш, API і колектор на
такій машині не вміщуються: перший запис у базу впаде на OOM.
Мінімум — 2 ГБ, робоче значення — 4 ГБ"
elif [ "$_mem" -lt 4096 ]; then
warn "пам'яті $_mem МБ — вистачить на десятки хостів, не на сотні"
else
ok "пам'ять: $_mem МБ"
fi
_free=$(free_mb "$ROOT")
[ -n "$_free" ] || _free=0
if [ -d /var/lib/docker ]; then
_freed=$(free_mb /var/lib/docker)
[ -n "$_freed" ] || _freed=0
[ "$_freed" -lt "$_free" ] && _free=$_freed
fi
if [ "$_free" -eq 0 ]; then
warn "не вдалося виміряти вільне місце"
elif [ "$_free" -lt 20480 ]; then
_fatal=1
bad "вільно $_free МБ. Образи займають близько 2 ГБ, і це разова
витрата; решту з'їдає телеметрія, яка росте щодня. Нижче 20 ГБ
установка закінчиться переповненим томом за тиждень"
elif [ "$_free" -lt 51200 ]; then
warn "вільно $_free МБ — стартувати вистачить, але профіль зберігання
беріть economy й дивіться на сторінку «Сховище»"
else
ok "вільно на диску: $_free МБ"
fi
# Порти, зайняті НАШИМИ ж контейнерами, — не конфлікт, а повторний
# запуск. Розрізняти обов'язково, інакше ідемпотентність втрачається
# на другому ж install.
if dc ps --services --filter status=running 2>/dev/null | grep -q '^proxy$'; then
ok "порти 80/443/9443 тримає вже піднятий proxy цієї ж інсталяції"
else
_busy=0
for _port in 80 443 9443; do
if port_busy "$_port" tcp; then
_fatal=1
_busy=1
# Причина в 80/443 і в 9443 різна, тож і підказка різна:
# порада «пошукайте nginx» на порті колектора відправляє
# людину не туди, а це той самий сорт впевненої неправди, з
# якого починається загублена година.
case "$_port" in
9443)
bad "порт 9443/tcp уже зайнятий. Це порт колектора зондів.
Найімовірніше тут уже працює інший NetPulse — перевірте
docker ps. Якщо це справді друга інсталяція, ставте її в
окремий каталог і змініть порт у docker-compose.yml" ;;
*)
bad "порт $_port/tcp уже зайнятий. Це майже завжди сторонній
веб-сервер (nginx, apache) — його треба зупинити або перенести,
бо проксі NetPulse займає 80 і 443 цілком" ;;
esac
fi
done
if [ "$_busy" -eq 0 ]; then
ok "порти 80, 443, 9443 вільні"
fi
fi
if [ "$_fatal" -ne 0 ]; then
if [ "$DRY" -eq 1 ]; then
warn "у сухому прогоні це не зупиняє — на справжній установці зупинило б"
else
die "Перелічене вище треба виправити до установки."
fi
fi
}
# ---------------------------------------------------------------------
# КРОК 2. Відповіді
# ---------------------------------------------------------------------
CFG_DOMAIN=""
CFG_EMAIL=""
CFG_TRAPS=""
CFG_RETENTION=""
CFG_TZ=""
read_conf() {
step "відповіді"
if [ -f "$CONF_FILE" ]; then
CFG_DOMAIN=$(sed -n 's/^[[:space:]]*DOMAIN=//p' "$CONF_FILE" | tail -1 | tr -d ' \r')
CFG_EMAIL=$(sed -n 's/^[[:space:]]*ADMIN_EMAIL=//p' "$CONF_FILE" | tail -1 | tr -d ' \r')
CFG_TRAPS=$(sed -n 's/^[[:space:]]*TRAPS_FROM=//p' "$CONF_FILE" | tail -1 | tr -d ' \r')
CFG_RETENTION=$(sed -n 's/^[[:space:]]*RETENTION=//p' "$CONF_FILE" | tail -1 | tr -d ' \r')
CFG_TZ=$(sed -n 's/^[[:space:]]*TZ=//p' "$CONF_FILE" | tail -1 | tr -d ' \r')
ok "прочитано $CONF_FILE"
else
ok "netpulse.conf немає — беруться типові відповіді"
say " (зразок із поясненнями: cp netpulse.conf.example netpulse.conf)"
fi
case "$CFG_RETENTION" in
"") CFG_RETENTION=normal ;;
economy|normal|archive) ;;
*) die "RETENTION=$CFG_RETENTION — такого профілю немає." \
"Дозволені: economy, normal, archive." ;;
esac
if [ -n "$CFG_EMAIL" ] && [ -z "$CFG_DOMAIN" ]; then
warn "ADMIN_EMAIL заданий без DOMAIN. Сертифікат буде самопідписаний,
Let's Encrypt не задіяний, пошта нікуди не піде — прибрано"
CFG_EMAIL=""
fi
if [ -n "$CFG_DOMAIN" ]; then
case "$CFG_DOMAIN" in
*[!a-zA-Z0-9.-]*) die "DOMAIN=$CFG_DOMAIN містить неприпустимі символи." ;;
*.*) ok "домен: $CFG_DOMAIN" ;;
*) die "DOMAIN=$CFG_DOMAIN не схожий на доменне ім'я (немає крапки)." \
"Якщо домену ще немає — лишіть порожнім, буде самопідписаний сертифікат." ;;
esac
if [ -n "$CFG_EMAIL" ]; then
ok "пошта для Let's Encrypt: $CFG_EMAIL"
else
warn "ADMIN_EMAIL порожній: про проблеми з продовженням сертифіката
ніхто не дізнається листом"
fi
else
ok "домену немає — самопідписаний сертифікат на IP"
fi
ok "строки зберігання: $CFG_RETENTION"
}
# ---------------------------------------------------------------------
# КРОК 3. Обчислені значення
# ---------------------------------------------------------------------
VAL_DOMAIN=""
VAL_TZ=""
VAL_SHBUF=""
VAL_DFMEM=""
VAL_PGBGW=""
VAL_TRAPS_BIND=""
VAL_TRAPS_SRC=""
compute() {
step "обчислені значення"
if [ -n "$CFG_DOMAIN" ]; then
VAL_DOMAIN=$CFG_DOMAIN
else
VAL_DOMAIN=$(host_ip)
if [ -z "$VAL_DOMAIN" ]; then
if [ "$DRY" -eq 1 ]; then
VAL_DOMAIN="<IP-цієї-машини>"
warn "IP визначити не вдалося; у сухому прогоні це не зупиняє"
else
die "Не вдалося визначити IP-адресу цієї машини," \
"а без адреси проксі не знає, на що відповідати." \
"Впишіть DOMAIN у netpulse.conf — можна й голий IP."
fi
else
ok "адреса системи: $VAL_DOMAIN (IP цієї машини)"
fi
fi
VAL_TZ=$CFG_TZ
if [ -z "$VAL_TZ" ]; then
VAL_TZ=$(host_tz)
[ -n "$VAL_TZ" ] || VAL_TZ=Europe/Kyiv
ok "часовий пояс: $VAL_TZ (з хоста)"
else
ok "часовий пояс: $VAL_TZ"
fi
# shared_buffers — чверть пам'яті, і саме тут установник заробляє
# своє існування. Значення з .env.example (512MB) на машині з 4 ГБ
# разом із рештою служб дає перевищення пам'яті: Postgres резервує
# буфери одразу, ядро вбиває його першим, і виглядає це як «база
# іноді падає», а не як помилка налаштування.
_mem=$(host_mem_mb)
if [ "$_mem" -eq 0 ]; then
VAL_SHBUF=256MB
warn "пам'ять невідома — shared_buffers=256MB (свідомо мало)"
else
_sb=$((_mem / 4))
[ "$_sb" -lt 128 ] && _sb=128
[ "$_sb" -gt 8192 ] && _sb=8192
VAL_SHBUF="${_sb}MB"
ok "shared_buffers=$VAL_SHBUF (чверть від $_mem МБ)"
fi
# Стеля кешу — з тієї самої цифри, а не окремим розрахунком.
#
# Dragonfly без стелі бере пам'ять із доступної на машині, тобто
# рахує ТУ САМУ, яку щойно порахував shared_buffers. Два незалежні
# «візьму чверть» від одного пирога дають суму більшу за пиріг, і
# ядро вбиває когось третього — найімовірніше API, бо він найлегший.
# Восьма частина: кеш тут допоміжний, у ньому живуть сесії й
# короткочасні лічильники, а не дані.
if [ "$_mem" -eq 0 ]; then
VAL_DFMEM=256mb
else
_df=$((_mem / 8))
[ "$_df" -lt 128 ] && _df=128
[ "$_df" -gt 2048 ] && _df=2048
VAL_DFMEM="${_df}mb"
fi
ok "стеля кешу=$VAL_DFMEM"
# Фонові робітники TimescaleDB конкурують за ядра з самими запитами.
# Вісім на двох ядрах означає, що стиснення чанків заважає опитуванню
# хостів — а опитування тут головне.
_cpu=$(host_cpus)
if [ "$_cpu" -le 0 ]; then
VAL_PGBGW=4
warn "кількість ядер невідома — фонових робітників 4"
else
VAL_PGBGW=$((_cpu * 2))
[ "$VAL_PGBGW" -lt 2 ] && VAL_PGBGW=2
[ "$VAL_PGBGW" -gt 8 ] && VAL_PGBGW=8
ok "фонових робітників TimescaleDB=$VAL_PGBGW (ядер: $_cpu)"
fi
compute_traps
}
compute_traps() {
VAL_TRAPS_SRC=""
case "$CFG_TRAPS" in
"")
VAL_TRAPS_BIND=127.0.0.1
ok "трапи: лише з цієї машини (порт не виставлений назовні)"
;;
any|ANY|any/0|0.0.0.0/0)
VAL_TRAPS_BIND=0.0.0.0
warn "трапи: приймаються ВІД БУДЬ-КОГО на 162/udp."
say " Порт не має автентифікації: хто знає адресу, той пише вам у базу."
say " Якщо це не було свідомим рішенням — TRAPS_FROM=<ваша підмережа>."
;;
*/*)
VAL_TRAPS_BIND=0.0.0.0
VAL_TRAPS_SRC=$CFG_TRAPS
ok "трапи: з підмережі $CFG_TRAPS (обмеження — правилом DOCKER-USER)"
;;
*)
# Адреса цієї машини — прив'язуємось до інтерфейсу, і жодного
# правила у файрволі не треба. Чужа адреса — це «приймати лише
# звідти», а прив'язкою docker такого не висловити.
if host_has_addr "$CFG_TRAPS"; then
VAL_TRAPS_BIND=$CFG_TRAPS
ok "трапи: лише на інтерфейсі $CFG_TRAPS"
else
VAL_TRAPS_BIND=0.0.0.0
VAL_TRAPS_SRC="$CFG_TRAPS/32"
ok "трапи: лише від $CFG_TRAPS (обмеження — правилом DOCKER-USER)"
fi
;;
esac
if [ -n "$VAL_TRAPS_SRC" ] && [ "$DRY" -eq 0 ]; then
have iptables || die \
"TRAPS_FROM=$CFG_TRAPS означає «приймати лише звідти», а зробити це" \
"можна лише правилом у ланцюжку DOCKER-USER — iptables на цьому хості немає." \
"" \
"Варіанти: поставити iptables; або вказати в TRAPS_FROM адресу" \
"внутрішнього інтерфейсу цієї машини (тоді обмеження дає сама прив'язка);" \
"або TRAPS_FROM=any, якщо відкрити порт усім — свідоме рішення."
fi
if [ -n "$VAL_TRAPS_BIND" ] && [ "$VAL_TRAPS_BIND" != 127.0.0.1 ]; then
if port_busy 162 udp; then
warn "порт 162/udp уже хтось слухає (snmptrapd?) — зонд його не займе"
fi
fi
}
host_has_addr() {
if have ip; then
ip -o addr show 2>/dev/null | grep -q "inet6\{0,1\} $1/"
elif have hostname; then
hostname -I 2>/dev/null | tr ' ' '\n' | grep -qx "$1"
else
return 1
fi
}
# Правило джерела для трапів. Ідемпотентне: -C перевіряє наявність,
# і повторний install не плодить копій.
apply_traps_firewall() {
[ -n "$VAL_TRAPS_SRC" ] || return 0
_added=0
if ! iptables -C DOCKER-USER -p udp --dport 162 ! -s "$VAL_TRAPS_SRC" -j DROP 2>/dev/null; then
if iptables -I DOCKER-USER 1 -p udp --dport 162 ! -s "$VAL_TRAPS_SRC" -j DROP 2>/dev/null; then
_added=1
else
die "Не вдалося додати правило в ланцюжок DOCKER-USER." \
"Найчастіша причина — запуск не від root." \
"Правило, яке треба додати руками:" \
" iptables -I DOCKER-USER 1 -p udp --dport 162 ! -s $VAL_TRAPS_SRC -j DROP"
fi
fi
if [ "$_added" -eq 1 ]; then
ok "правило DOCKER-USER додано: 162/udp лише з $VAL_TRAPS_SRC"
warn "правило живе до перезавантаження. Щоб пережило —
iptables-save (пакет iptables-persistent) або власний unit"
else
ok "правило DOCKER-USER уже на місці"
fi
}
# ---------------------------------------------------------------------
# КРОК 4. Секрети
# ---------------------------------------------------------------------
#
# Найважливіше тут — те, чого крок НЕ робить: він не перевипускає
# NETPULSE_DEK, якщо той уже є. Новий ключ шифрування означає, що всі
# збережені паролі SSH і SNMP-community перетворюються на нечитний
# шифротекст, і назад їх не дістати ніяк. Тому повторний install
# підхоплює наявні значення, а не генерує свіжі.
SEC_PG=""
SEC_APP=""
SEC_WORKER=""
SEC_DEK=""
SEC_JWT=""
SEC_REUSED=0
# Пароль їде всередині postgres://user:ПАРОЛЬ@db:5432/netpulse. «/» там
# починає ім'я бази, «@» — адресу хоста, «:» — порт: пароль обривається
# на першому такому символі МОВЧКИ, і застосунок доповідає
# «password authentication failed», тобто вказує зовсім не туди.
assert_dsn_safe() {
case "$2" in
*[/+=@:?\#\&]*)
die "У значенні $1 є символ, який щось означає всередині DSN" \
"(«/», «+», «=», «@», «:», «?», «#», «&»)." \
"Такий пароль обірветься при розборі postgres://user:пароль@db" \
"і дасть «password authentication failed» — помилку не про те." \
"Виправлення: приберіть рядок $1 з .env і повторіть install," \
"установник згенерує hex, у якому таких символів немає." ;;
esac
}
secrets() {
step "секрети"
if [ -f "$ENV_FILE" ]; then
SEC_PG=$(env_get POSTGRES_PASSWORD)
SEC_APP=$(env_get NETPULSE_APP_PASSWORD)
SEC_WORKER=$(env_get NETPULSE_WORKER_PASSWORD)
SEC_DEK=$(env_get NETPULSE_DEK)
SEC_JWT=$(env_get NETPULSE_JWT_SECRET)
if [ -n "$SEC_DEK" ]; then
SEC_REUSED=1
ok "знайдено .env — наявні секрети зберігаються без змін"
say " (перевипуск NETPULSE_DEK знищив би всі збережені паролі)"
fi
fi
if ! have openssl && [ ! -r /dev/urandom ]; then
die "Немає ані openssl, ані /dev/urandom — випадкові значення взяти" \
"нізвідки, а вигадувати секрети скрипт не має права."
fi
[ -n "$SEC_PG" ] || SEC_PG=$(rand_hex 32)
[ -n "$SEC_APP" ] || SEC_APP=$(rand_hex 32)
[ -n "$SEC_WORKER" ] || SEC_WORKER=$(rand_hex 32)
[ -n "$SEC_JWT" ] || SEC_JWT=$(rand_hex 32)
[ -n "$SEC_DEK" ] || SEC_DEK="np1=$(rand_hex 32)"
# Перевірка, а не припущення: одного разу пароль із «/» уже обрізав
# DSN, і поламалось воно не тут, а через три кроки, з повідомленням
# про невірний пароль.
assert_dsn_safe POSTGRES_PASSWORD "$SEC_PG"
assert_dsn_safe NETPULSE_APP_PASSWORD "$SEC_APP"
assert_dsn_safe NETPULSE_WORKER_PASSWORD "$SEC_WORKER"
[ "$SEC_REUSED" -eq 1 ] || ok "згенеровано: пароль БД, паролі ролей netpulse_app і
netpulse_worker, ключ шифрування секретів, ключ підпису сесій"
ok "формат — hex: жоден символ не має значення всередині DSN"
}
# ---------------------------------------------------------------------
# КРОК 5. .env
# ---------------------------------------------------------------------
write_env() {
step ".env"
_enroll=""
[ -f "$ENV_FILE" ] && _enroll=$(env_get NETPULSE_ENROLL)
if [ "$DRY" -eq 1 ]; then
ok "у сухому прогоні файл не пишеться; вміст (секрети приховані):"
env_body "СХОВАНО" "СХОВАНО" "СХОВАНО" "np1=СХОВАНО" "СХОВАНО" "$_enroll" |
sed 's/^/ /'
return 0
fi
# umask до створення, а не chmod після: між створенням і chmod файл
# із паролями видно всім, і це не теоретично — саме в цю щілину
# потрапляють резервні копії каталогу.
_old=$(umask)
umask 077
env_body "$SEC_PG" "$SEC_APP" "$SEC_WORKER" "$SEC_DEK" "$SEC_JWT" "$_enroll" \
> "$ENV_FILE.tmp" || die "Не вдалося записати $ENV_FILE.tmp"
umask "$_old"
chmod 600 "$ENV_FILE.tmp"
mv "$ENV_FILE.tmp" "$ENV_FILE" || die "Не вдалося замінити $ENV_FILE"
ok "$ENV_FILE записано, права 0600"
}
env_body() {
cat <<ENVEOF
# Створено ./netpulse install. Правити руками можна, але наступний
# install перечитає цей файл і збереже з нього секрети — тобто ваші
# зміни в секретах переживуть переустановлення, а в решті ні.
NETPULSE_DOMAIN=$VAL_DOMAIN
ACME_EMAIL=$CFG_EMAIL
POSTGRES_PASSWORD=$1
# Ролі під RLS. На свіжій установці вмикаються одразу: даних, які можна
# зіпсувати переходом, ще немає, а самоперевірка в кінці установки
# доводить, що вхід у систему під цими ролями працює. Саме її бракувало
# тоді, коли перехід на живому стенді дав 403 на вході.
NETPULSE_APP_PASSWORD=$2
NETPULSE_WORKER_PASSWORD=$3
# ВТРАТА ЦЬОГО КЛЮЧА — ВТРАТА ВСІХ ЗБЕРЕЖЕНИХ ПАРОЛІВ.
NETPULSE_DEK=$4
NETPULSE_JWT_SECRET=$5
TZ=$VAL_TZ
NETPULSE_ALERT_INTERVAL=30s
NETPULSE_LOG_LEVEL=info
# Пораховано з пам'яті хоста, а не взято з прикладу.
PG_SHARED_BUFFERS=$VAL_SHBUF
DRAGONFLY_MAXMEMORY=$VAL_DFMEM
PG_BG_WORKERS=$VAL_PGBGW
NETPULSE_VERSION=dev
NETPULSE_COMMIT=none
# Локальний зонд: базовий моніторинг самої інсталяції.
NETPULSE_ENROLL=$6
NETPULSE_AGENT_NAME=локальний зонд
NETPULSE_TRAPS_BIND=$VAL_TRAPS_BIND
NETPULSE_TRAPS_LISTEN=:162
ENVEOF
}
# ---------------------------------------------------------------------
# КРОК 6. База й кеш
# ---------------------------------------------------------------------
bring_db() {
step "база й кеш"
if [ "$DRY" -eq 1 ]; then
ok "виконалося б: docker compose up -d --build db cache"
return 0
fi
dc up -d --build db cache || die "docker compose не підняв db і cache." \
"Повний текст помилки — вище; найчастіше це нестача місця під образи."
ok "чекаємо, поки Postgres відповість (до 120 с)"
_i=0
while [ "$_i" -lt 60 ]; do
if dc exec -T db pg_isready -U netpulse -d netpulse >/dev/null 2>&1; then
ok "Postgres відповідає"
return 0
fi
_i=$((_i + 1))
sleep 2
done
die "Postgres не піднявся за дві хвилини." \
"Подивитись причину: ./netpulse logs db" \
"Найчастіше це shared_buffers більший за пам'ять машини — тоді в журналі" \
"буде рядок про неможливість виділити спільну пам'ять."
}
# ---------------------------------------------------------------------
# КРОК 7. Схема й ролі
# ---------------------------------------------------------------------
#
# Порядок тут не довільний: міграція 0063 СТВОРЮЄ ролі netpulse_app і
# netpulse_worker без паролів, і лише після неї їм є що видавати. А
# піднімати API до видачі паролів не можна взагалі: у DSN уже стоїть
# netpulse_app, і застосунок отримає відмову автентифікації.
migrate_schema() {
step "схема й ролі"
if [ "$DRY" -eq 1 ]; then
ok "виконалося б: docker compose run --rm migrate"
ok "далі: ALTER ROLE netpulse_app / netpulse_worker з новими паролями"
ok "далі: звірка rolbypassrls — у netpulse_app має бути f"
return 0
fi
dc run --rm migrate || die \
"Міграції не накотились. Схема лишилась у тому стані, у якому була:" \
"кожна міграція йде окремою транзакцією, недокочена не залишається." \
"Текст помилки вище називає файл, на якому зупинилось."
ok "схема накочена"
dc exec -T db psql -v ON_ERROR_STOP=1 -U netpulse -d netpulse >/dev/null <<SQL || die \
"Не вдалося видати паролі ролям netpulse_app і netpulse_worker."
ALTER ROLE netpulse_app PASSWORD '$SEC_APP';
ALTER ROLE netpulse_worker PASSWORD '$SEC_WORKER';
SQL
ok "паролі ролей видані"
_bypass=$(dc exec -T db psql -tAX -U netpulse -d netpulse \
-c "SELECT rolbypassrls FROM pg_roles WHERE rolname='netpulse_app'" 2>/dev/null | tr -d ' \r')
if [ "$_bypass" != "f" ]; then
die "У ролі netpulse_app стоїть BYPASSRLS (rolbypassrls=$_bypass)." \
"Це означає, що політики ізоляції кабінетів не діятимуть узагалі —" \
"усе наступне пройде й нічого не змінить."
fi
ok "netpulse_app без BYPASSRLS — політики RLS діють"
}
# ---------------------------------------------------------------------
# КРОК 8. Служби
# ---------------------------------------------------------------------
bring_services() {
step "служби"
if [ "$DRY" -eq 1 ]; then
ok "виконалося б: docker compose up -d --build api collector proxy"
return 0
fi
dc up -d --build api collector proxy || die \
"Не вдалося підняти api, collector або proxy." \
"Стан служб: ./netpulse logs"
ok "чекаємо, поки API відповість на /healthz (до 120 с)"
_i=0
while [ "$_i" -lt 60 ]; do
if dc exec -T api wget -q -O- "$API_INTERNAL/healthz" >/dev/null 2>&1; then
ok "API відповідає"
return 0
fi
_i=$((_i + 1))
sleep 2
done
die "API не відповів за дві хвилини." \
"Подивитись причину: ./netpulse logs api" \
"Якщо в журналі «password authentication failed» — паролі ролей і DSN" \
"розійшлись; повторіть install, він перевидасть паролі з .env."
}
# ---------------------------------------------------------------------
# КРОК 9. Власник
# ---------------------------------------------------------------------
create_owner() {
step "власник"
if [ "$DRY" -eq 1 ]; then
ok "виконалося б: docker compose run --rm --entrypoint netpulse-user cli \\"
say " -tenant default -create-tenant \"NetPulse\" -login admin -role owner"
ok "пароль — 24 символи з /dev/urandom, показується один раз"
return 0
fi
# Роллю ВЛАСНИКА, а не netpulse_app: заведення кабінету — рівно те,
# чого роль під RLS не може за побудовою (0063, розділ про
# core.tenants). Без явного DSN утиліта мовчки нічого не знайшла б.
_dsn="postgres://netpulse:$SEC_PG@db:5432/netpulse?sslmode=disable"
# --entrypoint обов'язковий: у службі api вже прописаний
# entrypoint netpulse-api, і без заміни утиліта потрапила б до нього
# аргументом, а не запустилась. (deploy/README.md у цьому місці
# помиляється — там команда без --entrypoint.)
if dc run --rm -e "NETPULSE_DSN=$_dsn" --entrypoint netpulse-user cli \
-tenant default -list 2>/dev/null | grep -q '^ admin '; then
OUT_OWNER_EXISTED=1
ok "власник admin уже є — пароль не змінюється"
return 0
fi
OUT_OWNER_PW=$(rand_pass) || die "Не вдалося згенерувати пароль власника."
# Пароль іде через stdin, а не прапорцем: аргументи командного рядка
# видно в ps будь-кому на машині, і осідають вони ще й в історії
# оболонки.
printf '%s\n' "$OUT_OWNER_PW" | dc run --rm -T -e "NETPULSE_DSN=$_dsn" \
--entrypoint netpulse-user cli \
-tenant default -create-tenant "NetPulse" \
-login admin -role owner -name "Адміністратор" \
|| die "Не вдалося завести власника." \
"Якщо в тексті «тенанта не знайдено» — база порожня, і це означає," \
"що міграції відпрацювали не до кінця."
ok "заведено кабінет NetPulse і власника admin"
}
# ---------------------------------------------------------------------
# КРОК 10. Строки зберігання
# ---------------------------------------------------------------------
#
# Профіль накладається лише на першій установці. Причина та сама, що й у
# самої міграції 0064: наш «правильний» строк на чужій інсталяції — це
# чиясь втрачена історія. Якщо строки вже правили, повторний install їх
# не чіпає.
retention_rows() {
case "$1" in
economy)
cat <<'EOF'
metrics_raw 7
metrics_5m 30
metrics_1h 180
icmp_raw 7
icmp_5m 30
icmp_1h 180
ifc_raw 7
ifc_5m 30
ifc_1h 180
link_status 30
device_status 30
syslog 7
traps 7
agent_health 7
alerts_history 90
notifications 30
login_attempts 90
audit_log 180
command_runs 30
ncm_jobs 30
discovery_runs 30
EOF
;;
normal)
cat <<'EOF'
metrics_raw 35
metrics_5m 180
metrics_1h 730
icmp_raw 35
icmp_5m 180
icmp_1h 730
ifc_raw 35
ifc_5m 180
ifc_1h 730
link_status 180
device_status 180
syslog 30
traps 30
agent_health 14
alerts_history 365
notifications 90
login_attempts 180
audit_log 365
command_runs 90
ncm_jobs 90
discovery_runs 90
EOF
;;
archive)
cat <<'EOF'
metrics_raw 90
metrics_5m 365
metrics_1h 1825
icmp_raw 90
icmp_5m 365
icmp_1h 1825
ifc_raw 90
ifc_5m 365
ifc_1h 1825
link_status 730
device_status 730
syslog 180
traps 180
agent_health 30
alerts_history 1825
notifications 365
login_attempts 365
audit_log 1825
command_runs 365
ncm_jobs 365
discovery_runs 365
EOF
;;
esac
}
apply_retention() {
step "строки зберігання"
if [ "$DRY" -eq 1 ]; then
ok "профіль $CFG_RETENTION розклався б у core.retention_settings так:"
retention_rows "$CFG_RETENTION" | awk '{ printf " %-16s %s діб\n", $1, $2 }'
ok "потім SELECT core.apply_retention_policies() — накласти політики TimescaleDB"
return 0
fi
if [ "$OUT_OWNER_EXISTED" -eq 1 ]; then
ok "система вже стояла — строки зберігання не чіпаємо"
say " (змінити: сторінка «Сховище» в інтерфейсі)"
return 0
fi
{
printf 'BEGIN;\n'
retention_rows "$CFG_RETENTION" | while read -r _kind _days; do
[ -n "$_kind" ] || continue
printf "UPDATE core.retention_settings SET keep_days = %s WHERE kind = '%s';\n" \
"$_days" "$_kind"
done
printf 'COMMIT;\n'
printf 'SELECT core.apply_retention_policies();\n'
} | dc exec -T db psql -v ON_ERROR_STOP=1 -U netpulse -d netpulse >/dev/null \
|| die "Не вдалося накласти строки зберігання." \
"Дані від цього не постраждали: невдала транзакція нічого не змінює." \
"Строки можна виставити в інтерфейсі: «Сховище»."
ok "профіль $CFG_RETENTION накладено на 21 вид даних"
}
# ---------------------------------------------------------------------
# HTTP до API зсередини контейнера
# ---------------------------------------------------------------------
#
# Ходимо wget-ом усередині контейнера api, а не curl-ом з хоста, і це
# свідомо. На чужому сервері curl може бути відсутній, а wget у образі є
# гарантовано — на ньому тримається healthcheck самого compose. Заразом
# зникає питання самопідписаного сертифіката: усередині це звичайний
# http до 127.0.0.1.
API_TOKEN=""
api_wget_ok() {
dc exec -T api sh -s <<'INNER' >/dev/null 2>&1
wget --help 2>&1 | grep -q -- --post-data
INNER
}
api_get() {
if [ -n "$API_TOKEN" ]; then
dc exec -T api sh -s <<INNER 2>&1
wget -q -O- --header='Authorization: Bearer $API_TOKEN' '$API_INTERNAL$1'
INNER
else
dc exec -T api sh -s <<INNER 2>&1
wget -q -O- '$API_INTERNAL$1'
INNER
fi
}
api_post() {
if [ -n "$API_TOKEN" ]; then
dc exec -T api sh -s <<INNER 2>&1
wget -q -O- --header='Content-Type: application/json' \
--header='Authorization: Bearer $API_TOKEN' \
--post-data='$2' '$API_INTERNAL$1'
INNER
else
dc exec -T api sh -s <<INNER 2>&1
wget -q -O- --header='Content-Type: application/json' \
--post-data='$2' '$API_INTERNAL$1'
INNER
fi
}
# ---------------------------------------------------------------------
# КРОК 11. Локальний зонд
# ---------------------------------------------------------------------
#
# Зонд на самому сервері ставиться не для краси. Він проходить увесь
# ланцюг, якого не бачить жодна перевірка HTTP: обмін запрошення на
# постійний токен, gRPC до колектора, реєстрація в core.agents. Якщо
# після установки в системі нуль зондів, вона показує порожні екрани, і
# людина не може відрізнити «ще нічого не налаштовано» від «колектор не
# працює».
enroll_local_agent() {
step "локальний зонд"
if [ "$DRY" -eq 1 ]; then
ok "виконалося б: вхід під власником, POST /api/v1/agent-enrollments,"
say " docker compose --profile agent up -d agent"
return 0
fi
_have=$(env_get NETPULSE_ENROLL)
if [ -n "$_have" ] && dc ps --services --filter status=running 2>/dev/null | grep -q '^agent$'; then
ok "локальний зонд уже працює"
return 0
fi
if ! login_as_owner; then
warn "пароля власника немає (система вже стояла) — нове запрошення не
видається. Додати зонд можна в інтерфейсі: Зонди → Додати зонд"
return 0
fi
_r=$(api_post /api/v1/agent-enrollments \
'{"name_hint":"локальний зонд","modules":["icmp","snmp","topology","ncm","traps"]}')
_tok=$(printf '%s' "$_r" | json_str token)
[ -n "$_tok" ] || die "API не видав запрошення для зонда." \
"Відповідь: $_r"
# Запрошення лягає в .env, бо зонд обмінює його на постійний токен при
# ПЕРШОМУ старті, а compose читає змінну на кожному up.
sed -i.bak "s|^NETPULSE_ENROLL=.*|NETPULSE_ENROLL=$_tok|" "$ENV_FILE" \
|| die "Не вдалося записати запрошення в .env"
rm -f "$ENV_FILE.bak"
dc --profile agent up -d --build agent \
|| die "Не вдалося підняти локальний зонд."
ok "локальний зонд запущено"
apply_traps_firewall
}
login_as_owner() {
[ -n "$API_TOKEN" ] && return 0
_pw=$OWNER_PASSWORD
[ -n "$_pw" ] || _pw=$OUT_OWNER_PW
if [ -z "$_pw" ]; then
return 1
fi
_r=$(api_post /api/v1/auth/login "{\"login\":\"admin\",\"password\":\"$_pw\"}")
API_TOKEN=$(printf '%s' "$_r" | json_str access_token)
if [ -z "$API_TOKEN" ]; then
die "Вхід під власником не вдався — тобто зламане саме те, заради чого" \
"ця перевірка існує." \
"" \
"Відповідь API: $_r" \
"" \
"Якщо там 403 і no_membership — база віддала нуль кабінетів під роллю" \
"netpulse_app. Найчастіша причина: порожній NETPULSE_DSN_WORKER, тобто" \
"не заданий NETPULSE_WORKER_PASSWORD у .env. Шлях входу читає" \
"core.memberships JOIN core.tenants пулом воркера, і без нього" \
"падає назад у пул застосунку, який кабінетів не бачить."
fi
return 0
}
# ---------------------------------------------------------------------
# КРОК 12. Самоперевірка
# ---------------------------------------------------------------------
#
# Взірець — перевірка, написана після живої поломки: перший її варіант
# питав «чи не видно чужого» і був зелений, бо RLS справді ховає чуже.
# Зламалось протилежне — не видно СВОГО. Тест на ізоляцію дивиться повз
# це місце за побудовою.
#
# Тому тут перевіряється не ізоляція, а ПРОХІДНІСТЬ: шлях, яким людина
# заходить у систему, і переліки, які після цього мають бути непорожні.
# Порожній перелік тут — привід зупинитись, а не «даних ще немає»: усе
# перелічене наливають міграції, тобто воно є на будь-якій інсталяції
# через хвилину після установки.
SELFCHECK_FAILED=""
sc_fail() { SELFCHECK_FAILED="$SELFCHECK_FAILED|$1"; bad "$1"; }
# Переліки, які МАЮТЬ бути непорожні одразу після установки. Кожен —
# із міграцій, і кожен читається через окремий шлях у store.
sc_must_lists() {
cat <<'EOF'
/api/v1/templates шаблони
/api/v1/ncm/profiles профілі_NCM
/api/v1/check-types типи_перевірок
/api/v1/roles ролі
/api/v1/permissions права
/api/v1/team користувачі
/api/v1/storage/retention строки_зберігання
EOF
}
# Тут порожньо законно: свіжа система ще не має ані хостів, ані мап.
# Перевіряємо лише те, що ендпоїнт ВІДПОВІДАЄ, — 4xx або 5xx означав
# би, що під роллю без BYPASSRLS зламався запит, а не що даних немає.
sc_may_lists() {
cat <<'EOF'
/api/v1/me я
/api/v1/devices хости
/api/v1/device-groups групи
/api/v1/credentials доступи
/api/v1/maps мапи
/api/v1/dashboards панелі
/api/v1/alerts алерти
/api/v1/alert-rules тригери
/api/v1/audit?limit=20 журнал_аудиту
/api/v1/queues черги
/api/v1/agent-enrollments запрошення_зондів
/api/v1/storage сховище
EOF
}
selfcheck() {
step "самоперевірка"
if [ "$DRY" -eq 1 ]; then
ok "справжній вхід POST /api/v1/auth/login справжнім паролем"
ok "звірка, що кабінет НАЗВАВСЯ (tenant_name), а не лише має id"
ok "звірка, що вхід повернув перелік прав"
sc_must_lists | awk '{ gsub(/_/, " ", $2); printf " непорожньо: %s\n", $2 }'
sc_may_lists | awk '{ gsub(/_/, " ", $2); printf " відповідає: %s\n", $2 }'
ok "зонд зареєструвався: /api/v1/agents не порожній"
ok "проксі відповідає HTTPS на публічній адресі"
return 0
fi
SELFCHECK_FAILED=""
api_wget_ok || die \
"wget у образі api не вміє --post-data, тому справжній вхід звідси не зробити." \
"Установник не має права оголосити систему готовою без цієї перевірки." \
"Зробіть її з машини, де є curl:" \
" curl -sk -X POST https://$VAL_DOMAIN/api/v1/auth/login \\" \
" -H 'Content-Type: application/json' \\" \
" -d '{\"login\":\"admin\",\"password\":\"<пароль>\"}'"
# --- 1. Вхід: рівно той шлях, що ламався --------------------------
_pw=$OWNER_PASSWORD
[ -n "$_pw" ] || _pw=$OUT_OWNER_PW
if [ -z "$_pw" ]; then
warn "пароля власника немає — повний вхід не перевіряється"
say " (система вже стояла; щоб перевірити вхід: ./netpulse check -p <пароль>)"
else
API_TOKEN=""
_r=$(api_post /api/v1/auth/login "{\"login\":\"admin\",\"password\":\"$_pw\"}")
API_TOKEN=$(printf '%s' "$_r" | json_str access_token)
if [ -z "$API_TOKEN" ]; then
die "ВХІД НЕ ПРОЙШОВ. Контейнери працюють, сторінка відкривається," \
"а зайти в систему не можна — саме цей стан колись назвали «готово»." \
"" \
"Відповідь API: $_r" \
"" \
"403 no_membership означає, що база віддала нуль кабінетів під роллю" \
"netpulse_app. Перевірити NETPULSE_WORKER_PASSWORD у .env: шлях входу" \
"читає кабінети пулом воркера, і без пароля цей пул стає пулом" \
"застосунку, який кабінетів не бачить за політикою RLS."
fi
ok "вхід: пройшов"
# Не наявність ключа tenant, а НАЗВА. id приїжджає з членства й
# лишився б на місці навіть тоді, коли рядок кабінету не віддався,
# — це тиха половина тієї самої поломки.
_tname=$(printf '%s' "$_r" | json_str tenant_name)
if [ -n "$_tname" ]; then
ok "кабінет назвався: $_tname"
else
sc_fail "вхід не повернув назви кабінету — core.tenants не віддалась"
fi
if printf '%s' "$_r" | tr -d ' \n' | grep -q '"permissions":\["'; then
ok "вхід повернув права"
else
sc_fail "вхід не повернув прав"
fi
fi
if [ -z "$API_TOKEN" ]; then
warn "далі йдуть лише перевірки, що не потребують входу"
else
# --- 2. Переліки, які МАЮТЬ бути непорожні ----------------------
#
# Через тимчасовий файл, а не конвеєром: тіло циклу, запущене в
# конвеєрі, працює в підоболонці, і sc_fail дописував би провали в
# її власну змінну. Підсумок після цього виявився б зеленим при
# червоних рядках вище — рівно та брехня, проти якої вся перевірка.
sc_must_lists > /tmp/np_must.$$
while read -r _path _label; do
[ -n "$_path" ] || continue
_b=$(api_get "$_path")
if json_nonempty "$_b"; then
ok "$(printf '%s' "$_label" | tr '_' ' '): непорожньо"
else
sc_fail "$(printf '%s' "$_label" | tr '_' ' '): ПОРОЖНЬО (наливається міграціями, тобто має бути)"
fi
done < /tmp/np_must.$$
rm -f /tmp/np_must.$$
sc_may_lists > /tmp/np_may.$$
while read -r _path _label; do
[ -n "$_path" ] || continue
if api_get "$_path" >/dev/null 2>&1; then
ok "$(printf '%s' "$_label" | tr '_' ' '): відповідає"
else
sc_fail "$(printf '%s' "$_label" | tr '_' ' '): не відповів 200"
fi
done < /tmp/np_may.$$
rm -f /tmp/np_may.$$
# --- 3. Зонд дійшов до колектора ---------------------------------
# Реєстрація йде gRPC-каналом, якого HTTP-перевірки не бачать
# зовсім. Чекаємо, бо обмін запрошення на токен займає секунди.
_i=0
_agents=""
while [ "$_i" -lt 20 ]; do
_agents=$(api_get /api/v1/agents)
json_nonempty "$_agents" && break
_i=$((_i + 1))
sleep 3
done
if json_nonempty "$_agents"; then
ok "зонд зареєструвався в колекторі"
else
sc_fail "жоден зонд не зареєструвався за хвилину — колектор або запрошення"
fi
fi
# --- 4. Проксі --------------------------------------------------
check_proxy
if [ -n "$SELFCHECK_FAILED" ]; then
printf '\n'
die "Самоперевірка не пройшла. Перелічене вище — не попередження:" \
"система в такому стані виглядає працюючою й не працює." \
"$(printf '%s' "$SELFCHECK_FAILED" | tr '|' ' ')"
fi
ok "усі вхідні шляхи проходять"
}
# Проксі перевіряємо окремо й м'якше: TLS на самопідписаному
# сертифікаті поводиться по-різному в різних збірках wget, і зупиняти
# установку через версію busybox було б неправдою про стан системи.
check_proxy() {
if ! dc ps --services --filter status=running 2>/dev/null | grep -q '^proxy$'; then
sc_fail "контейнер proxy не працює — назовні система недоступна"
return
fi
if dc exec -T api sh -s <<INNER >/dev/null 2>&1
wget -q --no-check-certificate -O- --header='Host: $VAL_DOMAIN' 'https://proxy/healthz'
INNER
then
ok "проксі віддає HTTPS для $VAL_DOMAIN"
else
warn "проксі працює, але перевірити HTTPS зсередини не вдалося.
Перевірте з робочої машини: https://$VAL_DOMAIN/healthz"
fi
}
# ---------------------------------------------------------------------
# Підсумок
# ---------------------------------------------------------------------
summary() {
printf '\n%s== готово%s\n\n' "$C_B" "$C_0"
printf ' Адреса: https://%s\n' "$OUT_URL"
if [ -z "$CFG_DOMAIN" ]; then
printf ' сертифікат самопідписаний — браузер попередить, це очікувано\n'
fi
printf ' Логін: admin\n'
if [ -n "$OUT_OWNER_PW" ]; then
printf ' Пароль: %s%s%s\n' "$C_B" "$OUT_OWNER_PW" "$C_0"
else
printf ' Пароль: без змін (власник уже існував)\n'
fi
if [ -n "$OUT_ENROLL" ]; then
printf '\n Запрошення для першого віддаленого зонда (дійсне добу):\n'
printf ' %s\n' "$OUT_ENROLL"
printf ' Наступні — в інтерфейсі: Зонди → Додати зонд.\n'
fi
printf '\n %sЦе показано востаннє.%s Пароль і запрошення ніде не зберігаються\n' "$C_R" "$C_0"
printf ' у відкритому вигляді: у базі лежать лише їхні хеші.\n'
printf '\n Ключі шифрування — у .env (права 0600). Втрата NETPULSE_DEK\n'
printf ' означає втрату всіх збережених паролів SSH і SNMP: ./netpulse backup\n'
printf ' кладе їх поруч із дампом, і зберігати їх треба ОКРЕМО від нього.\n\n'
}
# ---------------------------------------------------------------------
# install
# ---------------------------------------------------------------------
cmd_install() {
printf '%sNetPulse · установка%s\n' "$C_B" "$C_0"
[ "$DRY" -eq 1 ] && printf 'Сухий прогін: нічого не запускається й не пишеться.\n'
preflight
read_conf
compute
secrets
write_env
bring_db
migrate_schema
bring_services
create_owner
apply_retention
enroll_local_agent
selfcheck
OUT_URL=$VAL_DOMAIN
if [ "$DRY" -eq 1 ]; then
printf '\n%s== сухий прогін завершено%s\n' "$C_B" "$C_0"
printf ' Жодного контейнера не запущено, .env не змінено.\n'
printf ' Прибрати --dry-run, щоб поставити насправді.\n\n'
return 0
fi
# Друге запрошення — для першого зонда клієнта. Перше вже витрачене
# локальним зондом, а показувати витрачений токен означає навчити
# людину не вірити тому, що написано на екрані.
if [ -n "$API_TOKEN" ]; then
_r=$(api_post /api/v1/agent-enrollments \
'{"name_hint":"перший зонд","modules":["icmp","snmp","topology","ncm"]}')
OUT_ENROLL=$(printf '%s' "$_r" | json_str token)
fi
summary
}
# ---------------------------------------------------------------------
# check
# ---------------------------------------------------------------------
#
# Ті самі твердження, але на живій системі й у вигляді, придатному для
# «надішліть мені вивід цієї команди»: кожен рядок каже, ЩО саме не так,
# а не «помилка».
#
# Пароля власника тут зазвичай немає, тому перевірки поділені на дві
# групи. Ті, що не потребують пароля, б'ють у те саме місце з боку бази:
# запит, яким шлях входу дістає кабінети, виконується роллю воркера
# напряму. Якщо він порожній — вхід поверне 403, і це видно без входу.
cmd_check() {
printf '%sNetPulse · перевірка%s\n' "$C_B" "$C_0"
SELFCHECK_FAILED=""
step "оточення"
pick_compose
[ -n "$DC_KIND" ] || die "docker compose не знайдено."
[ -f "$ENV_FILE" ] || die "Немає $ENV_FILE — систему тут не встановлювали."
ok "docker compose на місці, .env знайдено"
# ls, а не stat: формат stat різний у GNU, BusyBox і BSD, а нам треба
# рівно десять символів прав — і вони однакові скрізь. Ім'я файлу тут
# наше власне й незмінне, тобто застереження про дивні імена не про цей
# випадок.
# shellcheck disable=SC2012
_perm=$(ls -l "$ENV_FILE" 2>/dev/null | cut -c1-10)
case "$_perm" in
-rw-------) ok ".env має права 0600" ;;
*) sc_fail ".env має права $_perm — паролі бази й ключі шифрування читає будь-хто" ;;
esac
VAL_DOMAIN=$(env_get NETPULSE_DOMAIN)
OUT_URL=$VAL_DOMAIN
CFG_DOMAIN=$VAL_DOMAIN
step "служби"
_running=$(dc ps --services --filter status=running 2>/dev/null)
for _s in db cache api collector proxy; do
if printf '%s\n' "$_running" | grep -q "^$_s\$"; then
ok "$_s працює"
else
sc_fail "$_s НЕ працює"
fi
done
if printf '%s\n' "$_running" | grep -q '^agent$'; then
ok "локальний зонд працює"
else
warn "локального зонда немає (не помилка, якщо зонди стоять окремо)"
fi
step "місце на диску"
_free=$(free_mb "$ROOT")
[ -n "$_free" ] || _free=0
if [ "$_free" -lt 5120 ]; then
sc_fail "вільно лише $_free МБ. Від переповненого тому першим падає Postgres,
тобто весь продукт одночасно. Сторінка «Сховище» показує приріст"
elif [ "$_free" -lt 20480 ]; then
warn "вільно $_free МБ — час подивитись на строки зберігання"
else
ok "вільно $_free МБ"
fi
step "ролі бази"
_app_pw=$(env_get NETPULSE_APP_PASSWORD)
_worker=$(env_get NETPULSE_WORKER_PASSWORD)
if [ -n "$_app_pw" ]; then
ok "застосунок ходить роллю netpulse_app (RLS діє)"
if [ -z "$_worker" ]; then
sc_fail "NETPULSE_WORKER_PASSWORD порожній при заданому NETPULSE_APP_PASSWORD.
Це рівно та комбінація, у якій вхід повертає 403: шлях входу читає
кабінети пулом воркера, а без пароля цей пул стає пулом застосунку,
який кабінетів не бачить за політикою RLS"
else
ok "NETPULSE_DSN_WORKER заповнений"
fi
_b=$(dc exec -T db psql -tAX -U netpulse -d netpulse \
-c "SELECT rolbypassrls FROM pg_roles WHERE rolname='netpulse_app'" 2>/dev/null | tr -d ' \r')
case "$_b" in
f) ok "netpulse_app без BYPASSRLS" ;;
t) sc_fail "netpulse_app має BYPASSRLS — політики ізоляції не діють узагалі" ;;
*) sc_fail "не вдалося спитати pg_roles: база не відповідає" ;;
esac
else
warn "застосунок ходить роллю netpulse (суперкористувач) — RLS не діє.
Ізоляцію кабінетів тримає лише предикат tenant_id у коді"
fi
step "шлях входу з боку бази"
# Той самий запит, що й у store.userMemberships. Порожній результат
# тут — це 403 на формі входу, і побачити його можна без пароля.
_n=$(dc exec -T db psql -tAX -U netpulse -d netpulse -c "
SELECT count(*) FROM core.memberships m
JOIN core.tenants t ON t.id = m.tenant_id
JOIN core.roles r ON r.id = m.role_id
WHERE m.accepted_at IS NOT NULL" 2>/dev/null | tr -d ' \r')
if [ -z "$_n" ]; then
sc_fail "не вдалося виконати запит до бази"
elif [ "$_n" = "0" ]; then
sc_fail "нуль членств у кабінетах: зайти в систему не зможе ніхто.
Завести власника: docker compose run --rm --entrypoint netpulse-user cli
-tenant default -login admin -role owner"
else
ok "членств у кабінетах: $_n"
fi
step "API"
if dc exec -T api wget -q -O- "$API_INTERNAL/healthz" >/dev/null 2>&1; then
ok "/healthz відповідає"
else
sc_fail "/healthz не відповідає — API живий як контейнер, але не як застосунок"
fi
# Повний вхід — лише якщо пароль дали. Без нього перевіряємо, що
# форма входу взагалі доходить до звірки пароля: 401 тут означає, що
# core.users читається, а 500 — що ні.
if [ -n "$OWNER_PASSWORD" ]; then
step "справжній вхід"
selfcheck_live
else
step "форма входу"
_r=$(api_post /api/v1/auth/login '{"login":"admin","password":"__свідомоевірний__"}')
case "$_r" in
*bad_credentials*) ok "форма входу доходить до звірки пароля" ;;
*too_many_attempts*) warn "вхід тимчасово заблокований після невдалих спроб" ;;
*) sc_fail "форма входу відповіла не тим: $_r" ;;
esac
say " Повний вхід перевіряється лише з паролем: ./netpulse check -p <пароль>"
fi
step "проксі"
check_proxy
printf '\n%s== підсумок%s\n' "$C_B" "$C_0"
if [ -n "$SELFCHECK_FAILED" ]; then
printf ' %sНЕ ГАРАЗД:%s\n' "$C_R" "$C_0"
printf '%s' "$SELFCHECK_FAILED" | tr '|' '\n' | sed '/^$/d' | sed 's/^/ - /'
printf '\n Цей вивід можна надіслати цілком: у ньому немає паролів.\n\n'
exit 1
fi
printf ' %sУсе гаразд.%s Адреса системи: https://%s\n\n' "$C_G" "$C_0" "$OUT_URL"
}
# Повний вхід у режимі check: те саме, що робить установка, але без
# права зупиняти світ — тут це діагностика, а не установка.
selfcheck_live() {
API_TOKEN=""
_r=$(api_post /api/v1/auth/login "{\"login\":\"admin\",\"password\":\"$OWNER_PASSWORD\"}")
API_TOKEN=$(printf '%s' "$_r" | json_str access_token)
if [ -z "$API_TOKEN" ]; then
sc_fail "вхід не пройшов: $_r"
return
fi
ok "вхід: пройшов"
_tname=$(printf '%s' "$_r" | json_str tenant_name)
if [ -n "$_tname" ]; then
ok "кабінет назвався: $_tname"
else
sc_fail "вхід не повернув назви кабінету — core.tenants не віддалась"
fi
sc_must_lists > /tmp/np_must.$$
while read -r _path _label; do
[ -n "$_path" ] || continue
_b=$(api_get "$_path")
if json_nonempty "$_b"; then
ok "$(printf '%s' "$_label" | tr '_' ' '): непорожньо"
else
sc_fail "$(printf '%s' "$_label" | tr '_' ' '): ПОРОЖНЬО"
fi
done < /tmp/np_must.$$
rm -f /tmp/np_must.$$
sc_may_lists > /tmp/np_may.$$
while read -r _path _label; do
[ -n "$_path" ] || continue
if api_get "$_path" >/dev/null 2>&1; then
ok "$(printf '%s' "$_label" | tr '_' ' '): відповідає"
else
sc_fail "$(printf '%s' "$_label" | tr '_' ' '): не відповів 200"
fi
done < /tmp/np_may.$$
rm -f /tmp/np_may.$$
}
# ---------------------------------------------------------------------
# backup / restore / upgrade / logs
# ---------------------------------------------------------------------
cmd_backup() {
printf '%sNetPulse · бекап%s\n' "$C_B" "$C_0"
pick_compose
[ -f "$ENV_FILE" ] || die "Немає $ENV_FILE."
_dir="$ROOT/backups"
mkdir -p "$_dir" || die "Не вдалося створити $_dir"
_stamp=$(date +%Y-%m-%d-%H%M)
_dump="$_dir/netpulse-$_stamp.dump"
_keys="$_dir/netpulse-$_stamp.keys"
step "дамп бази"
# Формат custom, а не простий SQL: стискається і дозволяє відновлювати
# вибірково. --no-owner — бо ролі на цільовій машині можуть бути інші.
dc exec -T db pg_dump -U netpulse -d netpulse -Fc --no-owner > "$_dump" \
|| die "pg_dump не відпрацював; неповний файл лишився як $_dump"
_size=$(wc -c < "$_dump" | tr -d ' ')
[ "${_size:-0}" -gt 1024 ] || die \
"Дамп вийшов розміром $_size байтів — це порожній або обірваний файл." \
"Такий файл виглядає як бекап і не є ним."
ok "$_dump, $((_size / 1024)) КБ"
step "ключі"
# Без цих двох рядків дамп не відновлюється в робочу систему: у базі
# лежить лише шифротекст секретів.
_old=$(umask); umask 077
{
printf '# Ключі до дампа netpulse-%s.dump\n' "$_stamp"
printf '# ЗБЕРІГАТИ ОКРЕМО ВІД ДАМПА: разом вони — готовий доступ до всіх\n'
printf '# паролів SSH і SNMP-community з інвентарю.\n'
printf 'NETPULSE_DEK=%s\n' "$(env_get NETPULSE_DEK)"
printf 'NETPULSE_JWT_SECRET=%s\n' "$(env_get NETPULSE_JWT_SECRET)"
} > "$_keys"
umask "$_old"
chmod 600 "$_keys"
ok "$_keys (права 0600)"
printf '\n Дамп без ключів не відновлюється, ключі поруч із дампом\n'
printf ' скасовують шифрування. Переносьте їх різними шляхами.\n\n'
}
cmd_restore() {
printf '%sNetPulse · відновлення%s\n' "$C_B" "$C_0"
pick_compose
[ -n "$RESTORE_FILE" ] || die "Не вказано файл: ./netpulse restore -f <дамп>"
[ -f "$RESTORE_FILE" ] || die "Файл $RESTORE_FILE не знайдено."
if [ "$ASSUME_YES" -eq 0 ]; then
printf '\n %sПоточна база буде знищена%s і замінена вмістом\n' "$C_R" "$C_0"
printf ' %s\n' "$RESTORE_FILE"
printf ' Повторіть із -y, якщо це саме те, що потрібно.\n\n'
exit 1
fi
step "зупинка застосунку"
dc stop api collector || die "Не вдалося зупинити api і collector."
ok "api і collector зупинені"
step "наливання"
# Рамка timescaledb_pre_restore/post_restore обов'язкова: без неї
# фонові процеси агрегації втручаються в наливання, і дамп лягає
# пошкодженим — мовчки.
dc exec -T db psql -v ON_ERROR_STOP=1 -U netpulse -d postgres \
-c 'DROP DATABASE IF EXISTS netpulse; CREATE DATABASE netpulse;' >/dev/null \
|| die "Не вдалося перестворити базу."
dc exec -T db psql -v ON_ERROR_STOP=1 -U netpulse -d netpulse \
-c 'CREATE EXTENSION IF NOT EXISTS timescaledb; SELECT timescaledb_pre_restore();' >/dev/null \
|| die "Не вдалося перевести TimescaleDB у режим відновлення."
dc exec -T db pg_restore -U netpulse -d netpulse --no-owner < "$RESTORE_FILE" \
|| warn "pg_restore повернув помилки — перегляньте їх перед тим, як користуватись"
dc exec -T db psql -v ON_ERROR_STOP=1 -U netpulse -d netpulse \
-c 'SELECT timescaledb_post_restore();' >/dev/null \
|| die "Не вдалося вивести TimescaleDB із режиму відновлення."
ok "дані налиті"
step "запуск"
dc up -d api collector || die "Не вдалося підняти api і collector."
ok "api і collector підняті"
printf '\n У .env має лежати ТОЙ САМИЙ NETPULSE_DEK, що й на момент дампа.\n'
printf ' Інакше система підніметься, а кожна спроба скористатись збереженим\n'
printf ' паролем поверне помилку розшифрування — і виглядатиме це як\n'
printf ' зламані креденшели, а не як втрачений ключ.\n\n'
printf ' Перевірити стан: ./netpulse check\n\n'
}
cmd_upgrade() {
printf '%sNetPulse · оновлення%s\n' "$C_B" "$C_0"
pick_compose
[ -f "$ENV_FILE" ] || die "Немає $ENV_FILE."
# Відкат схеми не передбачений: зворотні міграції на телеметрії
# коштують дорожче, ніж відновлення з дампа. Тому дамп тут не
# ввічливість, а єдиний шлях назад.
cmd_backup
step "перезбирання"
dc build || die "Збірка образів не вдалася."
ok "образи зібрані"
step "міграції"
dc run --rm migrate || die \
"Міграції не накотились — API навмисно не піднімається зі старою схемою." \
"Уже застосований файл зі зміненою контрольною сумою зупиняє запуск:" \
"це захист від мовчазного розходження схеми з кодом."
ok "схема накочена"
step "перезапуск"
dc up -d || die "Не вдалося перезапустити служби."
ok "служби перезапущені"
printf '\n Перевірити: ./netpulse check\n\n'
}
cmd_logs() {
pick_compose
if [ $# -gt 0 ]; then
dc logs -f --tail=200 "$@"
else
dc logs -f --tail=200
fi
}
# ---------------------------------------------------------------------
# Розбір командного рядка
# ---------------------------------------------------------------------
usage() {
cat <<'USAGE'
NetPulse — установка й обслуговування.
./netpulse install [--dry-run] поставити; повторний запуск безпечний
./netpulse check [-p ПАРОЛЬ] перевірити живу систему
./netpulse backup дамп бази + ключі до нього
./netpulse restore -f ДАМП -y відновити з дампа
./netpulse upgrade перезібрати, накотити міграції
./netpulse logs [служба] журнали
Відповіді на п'ять питань, яких система не може вирішити сама, —
у netpulse.conf. Зразок із поясненнями: netpulse.conf.example.
Без цього файлу install ставить робочу систему на типових відповідях.
USAGE
}
CMD=${1:-}
[ $# -gt 0 ] && shift
while [ $# -gt 0 ]; do
case "$1" in
--dry-run|-n) DRY=1 ;;
-y|--yes) ASSUME_YES=1 ;;
-p) shift; OWNER_PASSWORD=${1:-} ;;
-f) shift; RESTORE_FILE=${1:-} ;;
-c) shift; CONF_FILE=${1:-} ;;
-h|--help) usage; exit 0 ;;
*) break ;;
esac
shift
done
case "$CMD" in
install) cmd_install ;;
check) cmd_check ;;
backup) cmd_backup ;;
restore) cmd_restore ;;
upgrade) cmd_upgrade ;;
logs) cmd_logs "$@" ;;
""|-h|--help|help) usage ;;
*) printf 'невідома команда: %s\n\n' "$CMD"; usage; exit 2 ;;
esac