Last active 23 hours ago

VersityGW install script

mnogoweb revised this gist 23 hours ago. Go to revision

No changes

mnogoweb revised this gist 23 hours ago. Go to revision

1 file changed, 458 insertions

vgwm(file created)

@@ -0,0 +1,458 @@
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 "$@"
Newer Older