vgwm
· 19 KiB · Text
Raw
#!/usr/bin/env bash
# vgwm — VersityGW Manager (AlmaLinux 8/9)
#
# vgwm install [--template FILE] [--rpm FILE] полная установка
# vgwm firewall только firewall на nftables (без VersityGW): сброс всех
# правил и создание заново в интерактиве
# vgwm update обновление VersityGW из GitHub Releases
# vgwm self-install только установить vgwm в /usr/local/bin
#
# --template FILE — файл-образец конфига: берутся только активные (не закомментированные)
# строки, серверо-зависимые параметры запрашиваются интерактивно.
# --rpm FILE — локальный RPM вместо загрузки последнего релиза.
# --debug — подробный вывод (например, загруженные правила nftables).
set -Eeuo pipefail
INSTANCE="mnogoweb"
CONF="/etc/versitygw.d/${INSTANCE}.conf"
SERVICE="versitygw@${INSTANCE}"
STATE_DIR="/etc/vgwm"
CLIENTS_FILE="${STATE_DIR}/clients.list"
GH_API="https://api.github.com/repos/versity/versitygw/releases/latest"
ADMIN_IPS=(94.250.251.190 46.161.40.8) # SSH + 80/443
MON_IPS=(212.237.218.213 37.228.116.152) # Zabbix + Beszel
SSH_PORT=9999
ZABBIX_PORT=10050
BESZEL_PORT=45876
# ---------- helpers ----------
log() { echo -e "\e[32m[+]\e[0m $*"; }
warn() { echo -e "\e[33m[!]\e[0m $*" >&2; }
die() { echo -e "\e[31m[x]\e[0m $*" >&2; exit 1; }
ask() { # ask "Вопрос" "default" -> stdout
local prompt="$1" def="${2:-}" ans
if [[ -n "$def" ]]; then read -rp "$prompt [$def]: " ans; echo "${ans:-$def}"
else read -rp "$prompt: " ans; echo "$ans"; fi
}
ask_secret() {
local prompt="$1" def="${2:-}" ans
read -rsp "$prompt${def:+ [оставить текущий]}: " ans; echo >&2
echo "${ans:-$def}"
}
yes_no() { local a; read -rp "$1 [y/N]: " a; [[ "$a" =~ ^[YyДд]$ ]]; }
valid_ip() { # IPv4/IPv6 с опциональным /маска
python3 - "$1" <<'PY' 2>/dev/null
import ipaddress, sys
ipaddress.ip_network(sys.argv[1], strict=False)
PY
}
ip_family() { [[ "$1" == *:* ]] && echo ipv6 || echo ipv4; }
need_root() { [[ $EUID -eq 0 ]] || die "Запускать от root"; }
# ---------- RPM ----------
rpm_arch_regex() {
case "$(uname -m)" in
x86_64) echo 'x86_64|amd64' ;;
aarch64) echo 'aarch64|arm64' ;;
*) die "Неподдерживаемая архитектура: $(uname -m)" ;;
esac
}
download_latest_rpm() { # -> путь к rpm в stdout
local json url tag tmp
command -v curl >/dev/null || dnf -y install curl >&2
json="$(curl -fsSL "$GH_API")" || die "Не удалось получить данные релиза с GitHub"
tag="$(grep -oP '"tag_name":\s*"\K[^"]+' <<<"$json" | head -1)"
url="$(grep -oP '"browser_download_url":\s*"\K[^"]+\.rpm' <<<"$json" \
| grep -Ei "$(rpm_arch_regex)" | head -1)"
[[ -n "$url" ]] || die "В релизе $tag не найден RPM для $(uname -m)"
tmp="$(mktemp -d)"
log "Скачиваю $tag: $url" >&2
curl -fL --retry 3 -o "$tmp/$(basename "$url")" "$url" >&2
echo "$tmp/$(basename "$url")"
}
install_rpm() {
local rpm="$1"
[[ -f "$rpm" ]] || die "RPM не найден: $rpm"
dnf -y install "$rpm"
}
# ---------- config ----------
set_kv() { # set_kv KEY VALUE FILE — заменить активный ключ или дописать
local k="$1" v="$2" f="$3"
if grep -qE "^[[:space:]]*${k}=" "$f"; then
sed -i -E "s|^[[:space:]]*${k}=.*|${k}=${v//|/\\|}|" "$f"
else
echo "${k}=${v}" >>"$f"
fi
}
get_kv() { grep -E "^[[:space:]]*$1=" "$2" 2>/dev/null | tail -1 | cut -d= -f2- | tr -d '"' || true; }
# Активные параметры эталонного шаблона mnogoweb.
# __REGION__, ключи и пути подставляются при установке.
default_template() {
cat <<'EOF'
VGW_BACKEND=posix
VGW_BACKEND_ARG=/data/s3
ROOT_ACCESS_KEY_ID=
ROOT_SECRET_ACCESS_KEY=
VGW_PORT=:443
VGW_REGION=__REGION__
VGW_CERT=/etc/versitygw.d/ssl.cert
VGW_KEY=/etc/versitygw.d/ssl.key
VGW_VIRTUAL_DOMAIN=s3.bs-__REGION__.mnogoweb.com
VGW_DISABLE_STRICT_BUCKET_NAMES=true
VGW_MP_MAX_PARTS=100000
VGW_COPY_OBJECT_THRESHOLD=64424509440
VGW_WEBUI_S3_PREFIX=/ui
VGW_WEBUI_CERT=/etc/versitygw.d/ssl.cert
VGW_WEBUI_KEY=/etc/versitygw.d/ssl.key
VGW_CORS_ALLOW_ORIGIN=https://bs-__REGION__.mnogoweb.com
VGW_WEBUI_GATEWAYS=https://s3.bs-__REGION__.mnogoweb.com
VGW_DEBUG=true
EOF
}
gen_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"; }
place_tls() { # place_tls DEST "описание" — копирует файл в DEST, если его ещё нет
local dest="$1" what="$2" src
if [[ -f "$dest" ]]; then
src="$(ask "$what уже есть ($dest). Путь к новому файлу (пусто — оставить)" "")"
else
src="$(ask "Путь к файлу: $what (будет скопирован в $dest)" "")"
fi
if [[ -n "$src" ]]; then
[[ -f "$src" ]] || die "Файл не найден: $src"
install -m 0640 -o root -g versitygw "$src" "$dest"
fi
[[ -f "$dest" ]] || die "$what отсутствует: $dest — без него сервис не запустится"
}
configure_vgw() {
local template="${1:-}" tmp
tmp="$(mktemp)"
if [[ -n "$template" ]]; then
[[ -f "$template" ]] || die "Шаблон не найден: $template"
# только активные строки: без комментариев и пустых
grep -Ev '^[[:space:]]*(#|$)' "$template" | sed -E 's/[[:space:]]+$//' >"$tmp"
log "Из шаблона взято активных параметров: $(wc -l <"$tmp")"
else
default_template >"$tmp"
fi
# значения уже существующего конфига — как дефолты при повторном запуске
local old="$CONF"; [[ -f "$old" ]] || old=/dev/null
echo "--- Серверо-зависимые параметры ---"
local region domain backend_arg access secret def_reg cert key iam
def_reg="$(get_kv VGW_REGION "$old")"; def_reg="${def_reg:-$(get_kv VGW_REGION "$tmp")}"
[[ "$def_reg" == "__REGION__" ]] && def_reg=""
region="$(ask "Регион (VGW_REGION), напр. us1" "$def_reg")"
[[ "$region" =~ ^[a-z0-9-]+$ ]] || die "Некорректный регион: $region"
domain="$(ask "Базовый домен" "bs-${region}.mnogoweb.com")"
backend_arg="$(get_kv VGW_BACKEND_ARG "$old")"; backend_arg="${backend_arg:-$(get_kv VGW_BACKEND_ARG "$tmp")}"
backend_arg="$(ask "Каталог данных (VGW_BACKEND_ARG)" "$backend_arg")"
access="$(get_kv ROOT_ACCESS_KEY_ID "$old")"
secret="$(get_kv ROOT_SECRET_ACCESS_KEY "$old")"
if [[ -z "$access" || -z "$secret" ]] || yes_no "Сгенерировать новые root-ключи?"; then
if yes_no "Ввести root-ключи вручную (иначе — сгенерировать)?"; then
access="$(ask "Root access key" "")"
secret="$(ask_secret "Root secret key" "")"
else
access="$(gen_key 16)"; secret="$(gen_key 32)"
fi
fi
[[ -n "$access" && -n "$secret" ]] || die "Access/secret обязательны"
sed -i "s/__REGION__/${region}/g" "$tmp"
set_kv VGW_REGION "$region" "$tmp"
set_kv VGW_VIRTUAL_DOMAIN "s3.${domain}" "$tmp"
set_kv VGW_CORS_ALLOW_ORIGIN "https://${domain}" "$tmp"
set_kv VGW_WEBUI_GATEWAYS "https://s3.${domain}" "$tmp"
set_kv VGW_BACKEND_ARG "$backend_arg" "$tmp"
set_kv ROOT_ACCESS_KEY_ID "$access" "$tmp"
set_kv ROOT_SECRET_ACCESS_KEY "$secret" "$tmp"
mkdir -p /etc/versitygw.d
[[ -f "$CONF" ]] && cp -a "$CONF" "${CONF}.bak.$(date +%F_%H%M%S)"
install -m 0640 -o root -g versitygw "$tmp" "$CONF"
rm -f "$tmp"
# TLS: сертификат для s3.${domain} и *.s3.${domain} (virtual-host бакеты)
cert="$(get_kv VGW_CERT "$CONF")"; key="$(get_kv VGW_KEY "$CONF")"
[[ -n "$cert" ]] && place_tls "$cert" "TLS-сертификат (fullchain)"
[[ -n "$key" ]] && place_tls "$key" "TLS-ключ"
# каталоги и права
mkdir -p "$backend_arg"; chown versitygw:versitygw "$backend_arg"
iam="$(get_kv VGW_IAM_DIR "$CONF")"
[[ -n "$iam" ]] && { mkdir -p "$iam"; chown versitygw:versitygw "$iam"; }
log "Конфиг записан: $CONF"
echo " Регион: $region"
echo " S3: https://s3.${domain} (WebUI: https://s3.${domain}/ui)"
echo " Access key: $access"
echo " Secret key: $secret <- сохраните"
}
enable_service() {
# разрешаем непривилегированному пользователю слушать 80/443
mkdir -p "/etc/systemd/system/versitygw@.service.d"
cat >/etc/systemd/system/versitygw@.service.d/override.conf <<'EOF'
[Service]
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
EOF
systemctl daemon-reload
systemctl enable "$SERVICE"
systemctl restart "$SERVICE"
sleep 2
systemctl is-active --quiet "$SERVICE" \
&& log "Сервис $SERVICE запущен" \
|| { journalctl -u "$SERVICE" -n 30 --no-pager; die "Сервис не стартовал"; }
}
# ---------- firewall (nftables) ----------
read_ip_list() { # интерактивный ввод списка IP -> CLIENTS[]
local def="" input
[[ -f "$CLIENTS_FILE" ]] && def="$(tr '\n' ' ' <"$CLIENTS_FILE" | sed 's/ $//')"
echo "Список IP/подсетей клиентов для 80/443 и FTP (через пробел или запятую)."
input="$(ask "IP клиентов" "$def")"
CLIENTS=()
for ip in ${input//,/ }; do
valid_ip "$ip" && CLIENTS+=("$ip") || warn "Пропущен некорректный адрес: $ip"
done
mkdir -p "$STATE_DIR"
printf '%s\n' "${CLIENTS[@]}" >"$CLIENTS_FILE"
}
NFT_RULES="/etc/nftables/vgwm.nft"
NFT_MAIN="/etc/sysconfig/nftables.conf"
ROLLBACK_SEC=60
# элементы set'а для одного семейства: nft_elems ipv4|ipv6 IP...
nft_elems() {
local fam="$1"; shift
local out=() ip
for ip in "$@"; do [[ "$(ip_family "$ip")" == "$fam" ]] && out+=("$ip"); done
(( ${#out[@]} )) && { local IFS=,; echo "elements = { ${out[*]} };"; }
}
gen_ruleset() { # gen_ruleset PASV -> stdout
local pasv="$1"
cat <<EOF
#!/usr/sbin/nft -f
# Сгенерировано vgwm $(date '+%F %T'). Ручные правки будут перезаписаны.
flush ruleset
table inet vgwm {
set admin4 { type ipv4_addr; flags interval; $(nft_elems ipv4 "${ADMIN_IPS[@]}") }
set admin6 { type ipv6_addr; flags interval; $(nft_elems ipv6 "${ADMIN_IPS[@]}") }
set clients4 { type ipv4_addr; flags interval; $(nft_elems ipv4 "${CLIENTS[@]}") }
set clients6 { type ipv6_addr; flags interval; $(nft_elems ipv6 "${CLIENTS[@]}") }
set mon4 { type ipv4_addr; flags interval; $(nft_elems ipv4 "${MON_IPS[@]}") }
set mon6 { type ipv6_addr; flags interval; $(nft_elems ipv6 "${MON_IPS[@]}") }
chain input {
type filter hook input priority filter; policy drop;
iif lo accept
ct state established,related accept
ct state invalid drop
# IPv6 не работает без Neighbor Discovery
icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert, nd-router-advert, nd-router-solicit } ip6 hoplimit 255 accept
# SSH
tcp dport $SSH_PORT ip saddr @admin4 accept
tcp dport $SSH_PORT ip6 saddr @admin6 accept
# HTTP/HTTPS: админы + клиенты
tcp dport { 80, 443 } ip saddr @admin4 accept
tcp dport { 80, 443 } ip6 saddr @admin6 accept
tcp dport { 80, 443 } ip saddr @clients4 accept
tcp dport { 80, 443 } ip6 saddr @clients6 accept
# FTP (control + passive)
tcp dport { 21, $pasv } ip saddr @clients4 accept
tcp dport { 21, $pasv } ip6 saddr @clients6 accept
# Zabbix-agent, Beszel-agent
tcp dport { $ZABBIX_PORT, $BESZEL_PORT } ip saddr @mon4 accept
tcp dport { $ZABBIX_PORT, $BESZEL_PORT } ip6 saddr @mon6 accept
}
chain forward {
type filter hook forward priority filter; policy drop;
}
chain output {
type filter hook output priority filter; policy accept;
}
}
EOF
}
ensure_nftables() {
rpm -q nftables >/dev/null 2>&1 || dnf -y install nftables || die "Не удалось установить nftables"
# всё, что тоже управляет netfilter, должно быть выключено
local svc
for svc in firewalld iptables ip6tables ufw; do
if systemctl is-active --quiet "$svc" 2>/dev/null || systemctl is-enabled --quiet "$svc" 2>/dev/null; then
warn "Найден конкурирующий сервис: $svc"
yes_no "Остановить, отключить и замаскировать $svc?" || die "Конфликт с $svc — прервано"
systemctl disable --now "$svc" >/dev/null 2>&1 || true
systemctl mask "$svc" >/dev/null 2>&1 || true
fi
done
[[ "$(systemctl is-enabled nftables 2>/dev/null)" == "masked" ]] && systemctl unmask nftables
command -v nft >/dev/null || die "nft не найден"
log "nftables $(nft --version | awk '{print $2}')"
}
apply_with_rollback() { # apply_with_rollback FILE
local new="$1" backup
backup="$(mktemp)"
nft list ruleset >"$backup"
nft -c -f "$new" || { rm -f "$backup"; die "Ошибка синтаксиса правил: $new"; }
nft -f "$new" || { rm -f "$backup"; die "Не удалось применить правила"; }
if [[ -t 0 && -n "${SSH_CONNECTION:-}" ]]; then
warn "Правила применены. Откройте НОВУЮ SSH-сессию и проверьте доступ."
local ans=""
read -r -t "$ROLLBACK_SEC" -p "Введите 'ok' в течение ${ROLLBACK_SEC} c, иначе откат: " ans || true
echo
if [[ "$ans" != "ok" ]]; then
{ echo "flush ruleset"; cat "$backup"; } | nft -f -
rm -f "$backup"
die "Подтверждения нет — правила откатаны, изменения не сохранены"
fi
fi
rm -f "$backup"
}
persist_ruleset() {
install -d -m 0755 "$(dirname "$NFT_RULES")"
install -m 0600 "$1" "$NFT_RULES"
[[ -f "$NFT_MAIN" ]] && ! grep -qx "include \"$NFT_RULES\"" "$NFT_MAIN" \
&& cp -a "$NFT_MAIN" "${NFT_MAIN}.bak.$(date +%F_%H%M%S)"
cat >"$NFT_MAIN" <<EOF
# Управляется vgwm. Все правила — в $NFT_RULES
include "$NFT_RULES"
EOF
systemctl enable nftables >/dev/null 2>&1
systemctl restart nftables || { journalctl -u nftables -n 20 --no-pager; die "nftables.service не стартовал"; }
}
configure_firewall() {
ensure_nftables
read_ip_list
local pasv; pasv="$(ask "Диапазон пассивных портов FTP" "$(cat "$STATE_DIR/ftp_pasv" 2>/dev/null || echo 30000-31000)")"
[[ "$pasv" =~ ^[0-9]+-[0-9]+$ ]] || die "Неверный диапазон: $pasv"
echo "$pasv" >"$STATE_DIR/ftp_pasv"
# защита от потери доступа
local me="${SSH_CLIENT%% *}"
if [[ -n "$me" ]] && ! printf '%s\n' "${ADMIN_IPS[@]}" | grep -qx "$me"; then
warn "Ваш текущий IP $me НЕ входит в список SSH ($SSH_PORT): ${ADMIN_IPS[*]}"
yes_no "Продолжить?" || die "Отменено"
fi
ss -ltnH "sport = :$SSH_PORT" 2>/dev/null | grep -q . \
|| warn "Никто не слушает порт $SSH_PORT — после применения SSH будет недоступен"
local tmp; tmp="$(mktemp)"
gen_ruleset "$pasv" >"$tmp"
log "Сброс всех правил и применение нового набора"
apply_with_rollback "$tmp"
persist_ruleset "$tmp"
rm -f "$tmp"
log "Firewall применён и сохранён в $NFT_RULES"
if (( DEBUG )); then
echo "--- [debug] загруженные правила ---"
nft list table inet vgwm
fi
}
# ---------- update ----------
update_vgw() {
local cur rpm new
cur="$(rpm -q versitygw 2>/dev/null || echo 'не установлен')"
rpm="$(download_latest_rpm)"
new="$(rpm -qp --qf '%{NAME}-%{VERSION}-%{RELEASE}.%{ARCH}' "$rpm")"
log "Установлено: $cur | Доступно: $new"
if [[ "$cur" == "$new" ]]; then log "Уже актуальная версия"; rm -rf "$(dirname "$rpm")"; return; fi
dnf -y install "$rpm"
rm -rf "$(dirname "$rpm")"
systemctl daemon-reload
systemctl restart "$SERVICE"
systemctl is-active --quiet "$SERVICE" && log "Обновлено, сервис работает" \
|| { journalctl -u "$SERVICE" -n 30 --no-pager; die "Сервис не стартовал после обновления"; }
versitygw --version || true
}
# ---------- self-install ----------
VGWM_BIN="/usr/local/bin/vgwm"
self_install() {
local self; self="$(readlink -f "$0" 2>/dev/null || true)"
[[ -f "$self" && "$self" != "$VGWM_BIN" ]] || return 0 # уже установлен или запуск через pipe
if [[ -f "$VGWM_BIN" ]] && cmp -s "$self" "$VGWM_BIN"; then return 0; fi
local action="Установлен"; [[ -f "$VGWM_BIN" ]] && action="Обновлён"
install -m 0755 -o root -g root "$self" "$VGWM_BIN"
log "$action vgwm → $VGWM_BIN (дальше можно запускать просто: vgwm <режим>)"
}
usage() { sed -n '2,14p' "$VGWM_BIN" 2>/dev/null || sed -n '2,14p' "$0"; }
# ---------- main ----------
DEBUG=0
main() {
# --debug допускается в любом месте командной строки
local args=() a
for a in "$@"; do [[ "$a" == "--debug" ]] && DEBUG=1 || args+=("$a"); done
set -- "${args[@]+"${args[@]}"}"
need_root
self_install
local mode="${1:-}"; shift || true
local template="" rpm=""
while [[ $# -gt 0 ]]; do
case "$1" in
--template) template="$2"; shift 2 ;;
--rpm) rpm="$2"; shift 2 ;;
*) die "Неизвестный параметр: $1" ;;
esac
done
case "$mode" in
install)
[[ -n "$rpm" ]] || rpm="$(download_latest_rpm)"
install_rpm "$rpm"
configure_vgw "$template"
enable_service
configure_firewall
;;
firewall|firewall-only|fw) configure_firewall ;;
update) update_vgw ;;
self-install) log "vgwm установлен в $VGWM_BIN" ;;
""|help|-h|--help) usage ;;
*) usage; exit 1 ;;
esac
}
main "$@"
| 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 | |
| 15 | set -Eeuo pipefail |
| 16 | |
| 17 | INSTANCE="mnogoweb" |
| 18 | CONF="/etc/versitygw.d/${INSTANCE}.conf" |
| 19 | SERVICE="versitygw@${INSTANCE}" |
| 20 | STATE_DIR="/etc/vgwm" |
| 21 | CLIENTS_FILE="${STATE_DIR}/clients.list" |
| 22 | GH_API="https://api.github.com/repos/versity/versitygw/releases/latest" |
| 23 | |
| 24 | ADMIN_IPS=(94.250.251.190 46.161.40.8) # SSH + 80/443 |
| 25 | MON_IPS=(212.237.218.213 37.228.116.152) # Zabbix + Beszel |
| 26 | SSH_PORT=9999 |
| 27 | ZABBIX_PORT=10050 |
| 28 | BESZEL_PORT=45876 |
| 29 | |
| 30 | # ---------- helpers ---------- |
| 31 | log() { echo -e "\e[32m[+]\e[0m $*"; } |
| 32 | warn() { echo -e "\e[33m[!]\e[0m $*" >&2; } |
| 33 | die() { echo -e "\e[31m[x]\e[0m $*" >&2; exit 1; } |
| 34 | |
| 35 | ask() { # 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 | } |
| 40 | ask_secret() { |
| 41 | local prompt="$1" def="${2:-}" ans |
| 42 | read -rsp "$prompt${def:+ [оставить текущий]}: " ans; echo >&2 |
| 43 | echo "${ans:-$def}" |
| 44 | } |
| 45 | yes_no() { local a; read -rp "$1 [y/N]: " a; [[ "$a" =~ ^[YyДд]$ ]]; } |
| 46 | |
| 47 | valid_ip() { # IPv4/IPv6 с опциональным /маска |
| 48 | python3 - "$1" <<'PY' 2>/dev/null |
| 49 | import ipaddress, sys |
| 50 | ipaddress.ip_network(sys.argv[1], strict=False) |
| 51 | PY |
| 52 | } |
| 53 | ip_family() { [[ "$1" == *:* ]] && echo ipv6 || echo ipv4; } |
| 54 | |
| 55 | need_root() { [[ $EUID -eq 0 ]] || die "Запускать от root"; } |
| 56 | |
| 57 | # ---------- RPM ---------- |
| 58 | rpm_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 | |
| 66 | download_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 | |
| 80 | install_rpm() { |
| 81 | local rpm="$1" |
| 82 | [[ -f "$rpm" ]] || die "RPM не найден: $rpm" |
| 83 | dnf -y install "$rpm" |
| 84 | } |
| 85 | |
| 86 | # ---------- config ---------- |
| 87 | set_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 | } |
| 95 | get_kv() { grep -E "^[[:space:]]*$1=" "$2" 2>/dev/null | tail -1 | cut -d= -f2- | tr -d '"' || true; } |
| 96 | |
| 97 | # Активные параметры эталонного шаблона mnogoweb. |
| 98 | # __REGION__, ключи и пути подставляются при установке. |
| 99 | default_template() { |
| 100 | cat <<'EOF' |
| 101 | VGW_BACKEND=posix |
| 102 | VGW_BACKEND_ARG=/data/s3 |
| 103 | ROOT_ACCESS_KEY_ID= |
| 104 | ROOT_SECRET_ACCESS_KEY= |
| 105 | VGW_PORT=:443 |
| 106 | VGW_REGION=__REGION__ |
| 107 | VGW_CERT=/etc/versitygw.d/ssl.cert |
| 108 | VGW_KEY=/etc/versitygw.d/ssl.key |
| 109 | VGW_VIRTUAL_DOMAIN=s3.bs-__REGION__.mnogoweb.com |
| 110 | VGW_DISABLE_STRICT_BUCKET_NAMES=true |
| 111 | VGW_MP_MAX_PARTS=100000 |
| 112 | VGW_COPY_OBJECT_THRESHOLD=64424509440 |
| 113 | VGW_WEBUI_S3_PREFIX=/ui |
| 114 | VGW_WEBUI_CERT=/etc/versitygw.d/ssl.cert |
| 115 | VGW_WEBUI_KEY=/etc/versitygw.d/ssl.key |
| 116 | VGW_CORS_ALLOW_ORIGIN=https://bs-__REGION__.mnogoweb.com |
| 117 | VGW_WEBUI_GATEWAYS=https://s3.bs-__REGION__.mnogoweb.com |
| 118 | VGW_DEBUG=true |
| 119 | EOF |
| 120 | } |
| 121 | |
| 122 | gen_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 | |
| 124 | place_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 | |
| 138 | configure_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 | |
| 207 | enable_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] |
| 212 | AmbientCapabilities=CAP_NET_BIND_SERVICE |
| 213 | CapabilityBoundingSet=CAP_NET_BIND_SERVICE |
| 214 | EOF |
| 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) ---------- |
| 225 | read_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 | |
| 238 | NFT_RULES="/etc/nftables/vgwm.nft" |
| 239 | NFT_MAIN="/etc/sysconfig/nftables.conf" |
| 240 | ROLLBACK_SEC=60 |
| 241 | |
| 242 | # элементы set'а для одного семейства: nft_elems ipv4|ipv6 IP... |
| 243 | nft_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 | |
| 250 | gen_ruleset() { # gen_ruleset PASV -> stdout |
| 251 | local pasv="$1" |
| 252 | cat <<EOF |
| 253 | #!/usr/sbin/nft -f |
| 254 | # Сгенерировано vgwm $(date '+%F %T'). Ручные правки будут перезаписаны. |
| 255 | flush ruleset |
| 256 | |
| 257 | table 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 | } |
| 302 | EOF |
| 303 | } |
| 304 | |
| 305 | ensure_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 | |
| 324 | apply_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 | |
| 346 | persist_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 |
| 353 | include "$NFT_RULES" |
| 354 | EOF |
| 355 | systemctl enable nftables >/dev/null 2>&1 |
| 356 | systemctl restart nftables || { journalctl -u nftables -n 20 --no-pager; die "nftables.service не стартовал"; } |
| 357 | } |
| 358 | |
| 359 | configure_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 ---------- |
| 392 | update_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 ---------- |
| 409 | VGWM_BIN="/usr/local/bin/vgwm" |
| 410 | |
| 411 | self_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 | |
| 420 | usage() { sed -n '2,14p' "$VGWM_BIN" 2>/dev/null || sed -n '2,14p' "$0"; } |
| 421 | |
| 422 | # ---------- main ---------- |
| 423 | DEBUG=0 |
| 424 | |
| 425 | main() { |
| 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 | } |
| 458 | main "$@" |
| 459 |