Last active 23 hours ago

VersityGW install script

vgwm Raw
1#!/usr/bin/env bash
2# vgwm — VersityGW Manager (AlmaLinux 8/9)
3#
4# vgwm install [--template FILE] [--rpm FILE] полная установка
5# vgwm firewall только firewall на nftables (без VersityGW): сброс всех
6# правил и создание заново в интерактиве
7# vgwm update обновление VersityGW из GitHub Releases
8# vgwm self-install только установить vgwm в /usr/local/bin
9#
10# --template FILE — файл-образец конфига: берутся только активные (не закомментированные)
11# строки, серверо-зависимые параметры запрашиваются интерактивно.
12# --rpm FILE — локальный RPM вместо загрузки последнего релиза.
13# --debug — подробный вывод (например, загруженные правила nftables).
14
15set -Eeuo pipefail
16
17INSTANCE="mnogoweb"
18CONF="/etc/versitygw.d/${INSTANCE}.conf"
19SERVICE="versitygw@${INSTANCE}"
20STATE_DIR="/etc/vgwm"
21CLIENTS_FILE="${STATE_DIR}/clients.list"
22GH_API="https://api.github.com/repos/versity/versitygw/releases/latest"
23
24ADMIN_IPS=(94.250.251.190 46.161.40.8) # SSH + 80/443
25MON_IPS=(212.237.218.213 37.228.116.152) # Zabbix + Beszel
26SSH_PORT=9999
27ZABBIX_PORT=10050
28BESZEL_PORT=45876
29
30# ---------- helpers ----------
31log() { echo -e "\e[32m[+]\e[0m $*"; }
32warn() { echo -e "\e[33m[!]\e[0m $*" >&2; }
33die() { echo -e "\e[31m[x]\e[0m $*" >&2; exit 1; }
34
35ask() { # ask "Вопрос" "default" -> stdout
36 local prompt="$1" def="${2:-}" ans
37 if [[ -n "$def" ]]; then read -rp "$prompt [$def]: " ans; echo "${ans:-$def}"
38 else read -rp "$prompt: " ans; echo "$ans"; fi
39}
40ask_secret() {
41 local prompt="$1" def="${2:-}" ans
42 read -rsp "$prompt${def:+ [оставить текущий]}: " ans; echo >&2
43 echo "${ans:-$def}"
44}
45yes_no() { local a; read -rp "$1 [y/N]: " a; [[ "$a" =~ ^[YyДд]$ ]]; }
46
47valid_ip() { # IPv4/IPv6 с опциональным /маска
48 python3 - "$1" <<'PY' 2>/dev/null
49import ipaddress, sys
50ipaddress.ip_network(sys.argv[1], strict=False)
51PY
52}
53ip_family() { [[ "$1" == *:* ]] && echo ipv6 || echo ipv4; }
54
55need_root() { [[ $EUID -eq 0 ]] || die "Запускать от root"; }
56
57# ---------- RPM ----------
58rpm_arch_regex() {
59 case "$(uname -m)" in
60 x86_64) echo 'x86_64|amd64' ;;
61 aarch64) echo 'aarch64|arm64' ;;
62 *) die "Неподдерживаемая архитектура: $(uname -m)" ;;
63 esac
64}
65
66download_latest_rpm() { # -> путь к rpm в stdout
67 local json url tag tmp
68 command -v curl >/dev/null || dnf -y install curl >&2
69 json="$(curl -fsSL "$GH_API")" || die "Не удалось получить данные релиза с GitHub"
70 tag="$(grep -oP '"tag_name":\s*"\K[^"]+' <<<"$json" | head -1)"
71 url="$(grep -oP '"browser_download_url":\s*"\K[^"]+\.rpm' <<<"$json" \
72 | grep -Ei "$(rpm_arch_regex)" | head -1)"
73 [[ -n "$url" ]] || die "В релизе $tag не найден RPM для $(uname -m)"
74 tmp="$(mktemp -d)"
75 log "Скачиваю $tag: $url" >&2
76 curl -fL --retry 3 -o "$tmp/$(basename "$url")" "$url" >&2
77 echo "$tmp/$(basename "$url")"
78}
79
80install_rpm() {
81 local rpm="$1"
82 [[ -f "$rpm" ]] || die "RPM не найден: $rpm"
83 dnf -y install "$rpm"
84}
85
86# ---------- config ----------
87set_kv() { # set_kv KEY VALUE FILE — заменить активный ключ или дописать
88 local k="$1" v="$2" f="$3"
89 if grep -qE "^[[:space:]]*${k}=" "$f"; then
90 sed -i -E "s|^[[:space:]]*${k}=.*|${k}=${v//|/\\|}|" "$f"
91 else
92 echo "${k}=${v}" >>"$f"
93 fi
94}
95get_kv() { grep -E "^[[:space:]]*$1=" "$2" 2>/dev/null | tail -1 | cut -d= -f2- | tr -d '"' || true; }
96
97# Активные параметры эталонного шаблона mnogoweb.
98# __REGION__, ключи и пути подставляются при установке.
99default_template() {
100cat <<'EOF'
101VGW_BACKEND=posix
102VGW_BACKEND_ARG=/data/s3
103ROOT_ACCESS_KEY_ID=
104ROOT_SECRET_ACCESS_KEY=
105VGW_PORT=:443
106VGW_REGION=__REGION__
107VGW_CERT=/etc/versitygw.d/ssl.cert
108VGW_KEY=/etc/versitygw.d/ssl.key
109VGW_VIRTUAL_DOMAIN=s3.bs-__REGION__.mnogoweb.com
110VGW_DISABLE_STRICT_BUCKET_NAMES=true
111VGW_MP_MAX_PARTS=100000
112VGW_COPY_OBJECT_THRESHOLD=64424509440
113VGW_WEBUI_S3_PREFIX=/ui
114VGW_WEBUI_CERT=/etc/versitygw.d/ssl.cert
115VGW_WEBUI_KEY=/etc/versitygw.d/ssl.key
116VGW_CORS_ALLOW_ORIGIN=https://bs-__REGION__.mnogoweb.com
117VGW_WEBUI_GATEWAYS=https://s3.bs-__REGION__.mnogoweb.com
118VGW_DEBUG=true
119EOF
120}
121
122gen_key() { python3 -c "import secrets,string,sys;a=string.ascii_letters+string.digits;print(''.join(secrets.choice(a) for _ in range(int(sys.argv[1]))))" "$1"; }
123
124place_tls() { # place_tls DEST "описание" — копирует файл в DEST, если его ещё нет
125 local dest="$1" what="$2" src
126 if [[ -f "$dest" ]]; then
127 src="$(ask "$what уже есть ($dest). Путь к новому файлу (пусто — оставить)" "")"
128 else
129 src="$(ask "Путь к файлу: $what (будет скопирован в $dest)" "")"
130 fi
131 if [[ -n "$src" ]]; then
132 [[ -f "$src" ]] || die "Файл не найден: $src"
133 install -m 0640 -o root -g versitygw "$src" "$dest"
134 fi
135 [[ -f "$dest" ]] || die "$what отсутствует: $dest — без него сервис не запустится"
136}
137
138configure_vgw() {
139 local template="${1:-}" tmp
140 tmp="$(mktemp)"
141 if [[ -n "$template" ]]; then
142 [[ -f "$template" ]] || die "Шаблон не найден: $template"
143 # только активные строки: без комментариев и пустых
144 grep -Ev '^[[:space:]]*(#|$)' "$template" | sed -E 's/[[:space:]]+$//' >"$tmp"
145 log "Из шаблона взято активных параметров: $(wc -l <"$tmp")"
146 else
147 default_template >"$tmp"
148 fi
149
150 # значения уже существующего конфига — как дефолты при повторном запуске
151 local old="$CONF"; [[ -f "$old" ]] || old=/dev/null
152
153 echo "--- Серверо-зависимые параметры ---"
154 local region domain backend_arg access secret def_reg cert key iam
155 def_reg="$(get_kv VGW_REGION "$old")"; def_reg="${def_reg:-$(get_kv VGW_REGION "$tmp")}"
156 [[ "$def_reg" == "__REGION__" ]] && def_reg=""
157 region="$(ask "Регион (VGW_REGION), напр. us1" "$def_reg")"
158 [[ "$region" =~ ^[a-z0-9-]+$ ]] || die "Некорректный регион: $region"
159 domain="$(ask "Базовый домен" "bs-${region}.mnogoweb.com")"
160
161 backend_arg="$(get_kv VGW_BACKEND_ARG "$old")"; backend_arg="${backend_arg:-$(get_kv VGW_BACKEND_ARG "$tmp")}"
162 backend_arg="$(ask "Каталог данных (VGW_BACKEND_ARG)" "$backend_arg")"
163
164 access="$(get_kv ROOT_ACCESS_KEY_ID "$old")"
165 secret="$(get_kv ROOT_SECRET_ACCESS_KEY "$old")"
166 if [[ -z "$access" || -z "$secret" ]] || yes_no "Сгенерировать новые root-ключи?"; then
167 if yes_no "Ввести root-ключи вручную (иначе — сгенерировать)?"; then
168 access="$(ask "Root access key" "")"
169 secret="$(ask_secret "Root secret key" "")"
170 else
171 access="$(gen_key 16)"; secret="$(gen_key 32)"
172 fi
173 fi
174 [[ -n "$access" && -n "$secret" ]] || die "Access/secret обязательны"
175
176 sed -i "s/__REGION__/${region}/g" "$tmp"
177 set_kv VGW_REGION "$region" "$tmp"
178 set_kv VGW_VIRTUAL_DOMAIN "s3.${domain}" "$tmp"
179 set_kv VGW_CORS_ALLOW_ORIGIN "https://${domain}" "$tmp"
180 set_kv VGW_WEBUI_GATEWAYS "https://s3.${domain}" "$tmp"
181 set_kv VGW_BACKEND_ARG "$backend_arg" "$tmp"
182 set_kv ROOT_ACCESS_KEY_ID "$access" "$tmp"
183 set_kv ROOT_SECRET_ACCESS_KEY "$secret" "$tmp"
184
185 mkdir -p /etc/versitygw.d
186 [[ -f "$CONF" ]] && cp -a "$CONF" "${CONF}.bak.$(date +%F_%H%M%S)"
187 install -m 0640 -o root -g versitygw "$tmp" "$CONF"
188 rm -f "$tmp"
189
190 # TLS: сертификат для s3.${domain} и *.s3.${domain} (virtual-host бакеты)
191 cert="$(get_kv VGW_CERT "$CONF")"; key="$(get_kv VGW_KEY "$CONF")"
192 [[ -n "$cert" ]] && place_tls "$cert" "TLS-сертификат (fullchain)"
193 [[ -n "$key" ]] && place_tls "$key" "TLS-ключ"
194
195 # каталоги и права
196 mkdir -p "$backend_arg"; chown versitygw:versitygw "$backend_arg"
197 iam="$(get_kv VGW_IAM_DIR "$CONF")"
198 [[ -n "$iam" ]] && { mkdir -p "$iam"; chown versitygw:versitygw "$iam"; }
199
200 log "Конфиг записан: $CONF"
201 echo " Регион: $region"
202 echo " S3: https://s3.${domain} (WebUI: https://s3.${domain}/ui)"
203 echo " Access key: $access"
204 echo " Secret key: $secret <- сохраните"
205}
206
207enable_service() {
208 # разрешаем непривилегированному пользователю слушать 80/443
209 mkdir -p "/etc/systemd/system/versitygw@.service.d"
210 cat >/etc/systemd/system/versitygw@.service.d/override.conf <<'EOF'
211[Service]
212AmbientCapabilities=CAP_NET_BIND_SERVICE
213CapabilityBoundingSet=CAP_NET_BIND_SERVICE
214EOF
215 systemctl daemon-reload
216 systemctl enable "$SERVICE"
217 systemctl restart "$SERVICE"
218 sleep 2
219 systemctl is-active --quiet "$SERVICE" \
220 && log "Сервис $SERVICE запущен" \
221 || { journalctl -u "$SERVICE" -n 30 --no-pager; die "Сервис не стартовал"; }
222}
223
224# ---------- firewall (nftables) ----------
225read_ip_list() { # интерактивный ввод списка IP -> CLIENTS[]
226 local def="" input
227 [[ -f "$CLIENTS_FILE" ]] && def="$(tr '\n' ' ' <"$CLIENTS_FILE" | sed 's/ $//')"
228 echo "Список IP/подсетей клиентов для 80/443 и FTP (через пробел или запятую)."
229 input="$(ask "IP клиентов" "$def")"
230 CLIENTS=()
231 for ip in ${input//,/ }; do
232 valid_ip "$ip" && CLIENTS+=("$ip") || warn "Пропущен некорректный адрес: $ip"
233 done
234 mkdir -p "$STATE_DIR"
235 printf '%s\n' "${CLIENTS[@]}" >"$CLIENTS_FILE"
236}
237
238NFT_RULES="/etc/nftables/vgwm.nft"
239NFT_MAIN="/etc/sysconfig/nftables.conf"
240ROLLBACK_SEC=60
241
242# элементы set'а для одного семейства: nft_elems ipv4|ipv6 IP...
243nft_elems() {
244 local fam="$1"; shift
245 local out=() ip
246 for ip in "$@"; do [[ "$(ip_family "$ip")" == "$fam" ]] && out+=("$ip"); done
247 (( ${#out[@]} )) && { local IFS=,; echo "elements = { ${out[*]} };"; }
248}
249
250gen_ruleset() { # gen_ruleset PASV -> stdout
251 local pasv="$1"
252 cat <<EOF
253#!/usr/sbin/nft -f
254# Сгенерировано vgwm $(date '+%F %T'). Ручные правки будут перезаписаны.
255flush ruleset
256
257table inet vgwm {
258 set admin4 { type ipv4_addr; flags interval; $(nft_elems ipv4 "${ADMIN_IPS[@]}") }
259 set admin6 { type ipv6_addr; flags interval; $(nft_elems ipv6 "${ADMIN_IPS[@]}") }
260 set clients4 { type ipv4_addr; flags interval; $(nft_elems ipv4 "${CLIENTS[@]}") }
261 set clients6 { type ipv6_addr; flags interval; $(nft_elems ipv6 "${CLIENTS[@]}") }
262 set mon4 { type ipv4_addr; flags interval; $(nft_elems ipv4 "${MON_IPS[@]}") }
263 set mon6 { type ipv6_addr; flags interval; $(nft_elems ipv6 "${MON_IPS[@]}") }
264
265 chain input {
266 type filter hook input priority filter; policy drop;
267
268 iif lo accept
269 ct state established,related accept
270 ct state invalid drop
271
272 # IPv6 не работает без Neighbor Discovery
273 icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert, nd-router-advert, nd-router-solicit } ip6 hoplimit 255 accept
274
275 # SSH
276 tcp dport $SSH_PORT ip saddr @admin4 accept
277 tcp dport $SSH_PORT ip6 saddr @admin6 accept
278
279 # HTTP/HTTPS: админы + клиенты
280 tcp dport { 80, 443 } ip saddr @admin4 accept
281 tcp dport { 80, 443 } ip6 saddr @admin6 accept
282 tcp dport { 80, 443 } ip saddr @clients4 accept
283 tcp dport { 80, 443 } ip6 saddr @clients6 accept
284
285 # FTP (control + passive)
286 tcp dport { 21, $pasv } ip saddr @clients4 accept
287 tcp dport { 21, $pasv } ip6 saddr @clients6 accept
288
289 # Zabbix-agent, Beszel-agent
290 tcp dport { $ZABBIX_PORT, $BESZEL_PORT } ip saddr @mon4 accept
291 tcp dport { $ZABBIX_PORT, $BESZEL_PORT } ip6 saddr @mon6 accept
292 }
293
294 chain forward {
295 type filter hook forward priority filter; policy drop;
296 }
297
298 chain output {
299 type filter hook output priority filter; policy accept;
300 }
301}
302EOF
303}
304
305ensure_nftables() {
306 rpm -q nftables >/dev/null 2>&1 || dnf -y install nftables || die "Не удалось установить nftables"
307
308 # всё, что тоже управляет netfilter, должно быть выключено
309 local svc
310 for svc in firewalld iptables ip6tables ufw; do
311 if systemctl is-active --quiet "$svc" 2>/dev/null || systemctl is-enabled --quiet "$svc" 2>/dev/null; then
312 warn "Найден конкурирующий сервис: $svc"
313 yes_no "Остановить, отключить и замаскировать $svc?" || die "Конфликт с $svc — прервано"
314 systemctl disable --now "$svc" >/dev/null 2>&1 || true
315 systemctl mask "$svc" >/dev/null 2>&1 || true
316 fi
317 done
318
319 [[ "$(systemctl is-enabled nftables 2>/dev/null)" == "masked" ]] && systemctl unmask nftables
320 command -v nft >/dev/null || die "nft не найден"
321 log "nftables $(nft --version | awk '{print $2}')"
322}
323
324apply_with_rollback() { # apply_with_rollback FILE
325 local new="$1" backup
326 backup="$(mktemp)"
327 nft list ruleset >"$backup"
328
329 nft -c -f "$new" || { rm -f "$backup"; die "Ошибка синтаксиса правил: $new"; }
330 nft -f "$new" || { rm -f "$backup"; die "Не удалось применить правила"; }
331
332 if [[ -t 0 && -n "${SSH_CONNECTION:-}" ]]; then
333 warn "Правила применены. Откройте НОВУЮ SSH-сессию и проверьте доступ."
334 local ans=""
335 read -r -t "$ROLLBACK_SEC" -p "Введите 'ok' в течение ${ROLLBACK_SEC} c, иначе откат: " ans || true
336 echo
337 if [[ "$ans" != "ok" ]]; then
338 { echo "flush ruleset"; cat "$backup"; } | nft -f -
339 rm -f "$backup"
340 die "Подтверждения нет — правила откатаны, изменения не сохранены"
341 fi
342 fi
343 rm -f "$backup"
344}
345
346persist_ruleset() {
347 install -d -m 0755 "$(dirname "$NFT_RULES")"
348 install -m 0600 "$1" "$NFT_RULES"
349 [[ -f "$NFT_MAIN" ]] && ! grep -qx "include \"$NFT_RULES\"" "$NFT_MAIN" \
350 && cp -a "$NFT_MAIN" "${NFT_MAIN}.bak.$(date +%F_%H%M%S)"
351 cat >"$NFT_MAIN" <<EOF
352# Управляется vgwm. Все правила — в $NFT_RULES
353include "$NFT_RULES"
354EOF
355 systemctl enable nftables >/dev/null 2>&1
356 systemctl restart nftables || { journalctl -u nftables -n 20 --no-pager; die "nftables.service не стартовал"; }
357}
358
359configure_firewall() {
360 ensure_nftables
361
362 read_ip_list
363 local pasv; pasv="$(ask "Диапазон пассивных портов FTP" "$(cat "$STATE_DIR/ftp_pasv" 2>/dev/null || echo 30000-31000)")"
364 [[ "$pasv" =~ ^[0-9]+-[0-9]+$ ]] || die "Неверный диапазон: $pasv"
365 echo "$pasv" >"$STATE_DIR/ftp_pasv"
366
367 # защита от потери доступа
368 local me="${SSH_CLIENT%% *}"
369 if [[ -n "$me" ]] && ! printf '%s\n' "${ADMIN_IPS[@]}" | grep -qx "$me"; then
370 warn "Ваш текущий IP $me НЕ входит в список SSH ($SSH_PORT): ${ADMIN_IPS[*]}"
371 yes_no "Продолжить?" || die "Отменено"
372 fi
373 ss -ltnH "sport = :$SSH_PORT" 2>/dev/null | grep -q . \
374 || warn "Никто не слушает порт $SSH_PORT — после применения SSH будет недоступен"
375
376 local tmp; tmp="$(mktemp)"
377 gen_ruleset "$pasv" >"$tmp"
378
379 log "Сброс всех правил и применение нового набора"
380 apply_with_rollback "$tmp"
381 persist_ruleset "$tmp"
382 rm -f "$tmp"
383
384 log "Firewall применён и сохранён в $NFT_RULES"
385 if (( DEBUG )); then
386 echo "--- [debug] загруженные правила ---"
387 nft list table inet vgwm
388 fi
389}
390
391# ---------- update ----------
392update_vgw() {
393 local cur rpm new
394 cur="$(rpm -q versitygw 2>/dev/null || echo 'не установлен')"
395 rpm="$(download_latest_rpm)"
396 new="$(rpm -qp --qf '%{NAME}-%{VERSION}-%{RELEASE}.%{ARCH}' "$rpm")"
397 log "Установлено: $cur | Доступно: $new"
398 if [[ "$cur" == "$new" ]]; then log "Уже актуальная версия"; rm -rf "$(dirname "$rpm")"; return; fi
399 dnf -y install "$rpm"
400 rm -rf "$(dirname "$rpm")"
401 systemctl daemon-reload
402 systemctl restart "$SERVICE"
403 systemctl is-active --quiet "$SERVICE" && log "Обновлено, сервис работает" \
404 || { journalctl -u "$SERVICE" -n 30 --no-pager; die "Сервис не стартовал после обновления"; }
405 versitygw --version || true
406}
407
408# ---------- self-install ----------
409VGWM_BIN="/usr/local/bin/vgwm"
410
411self_install() {
412 local self; self="$(readlink -f "$0" 2>/dev/null || true)"
413 [[ -f "$self" && "$self" != "$VGWM_BIN" ]] || return 0 # уже установлен или запуск через pipe
414 if [[ -f "$VGWM_BIN" ]] && cmp -s "$self" "$VGWM_BIN"; then return 0; fi
415 local action="Установлен"; [[ -f "$VGWM_BIN" ]] && action="Обновлён"
416 install -m 0755 -o root -g root "$self" "$VGWM_BIN"
417 log "$action vgwm → $VGWM_BIN (дальше можно запускать просто: vgwm <режим>)"
418}
419
420usage() { sed -n '2,14p' "$VGWM_BIN" 2>/dev/null || sed -n '2,14p' "$0"; }
421
422# ---------- main ----------
423DEBUG=0
424
425main() {
426 # --debug допускается в любом месте командной строки
427 local args=() a
428 for a in "$@"; do [[ "$a" == "--debug" ]] && DEBUG=1 || args+=("$a"); done
429 set -- "${args[@]+"${args[@]}"}"
430
431 need_root
432 self_install
433 local mode="${1:-}"; shift || true
434 local template="" rpm=""
435 while [[ $# -gt 0 ]]; do
436 case "$1" in
437 --template) template="$2"; shift 2 ;;
438 --rpm) rpm="$2"; shift 2 ;;
439 *) die "Неизвестный параметр: $1" ;;
440 esac
441 done
442
443 case "$mode" in
444 install)
445 [[ -n "$rpm" ]] || rpm="$(download_latest_rpm)"
446 install_rpm "$rpm"
447 configure_vgw "$template"
448 enable_service
449 configure_firewall
450 ;;
451 firewall|firewall-only|fw) configure_firewall ;;
452 update) update_vgw ;;
453 self-install) log "vgwm установлен в $VGWM_BIN" ;;
454 ""|help|-h|--help) usage ;;
455 *) usage; exit 1 ;;
456 esac
457}
458main "$@"
459