diff --git a/README.md b/README.md index b439217..b717558 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,29 @@ PostgreSQL: хост, порт, база, пользователь, пароль Получаются `bin/lpra-server*` и `dist/agent-linux-{amd64,arm64}`. Версия задаётся в одном месте - `VERSION.txt`, она вшивается в сервер и агент. +## Установка сервера одной командой +На Linux-сервере (amd64 или arm64, systemd): +```sh +wget -qO- https://raw.githubusercontent.com/iRedTea/lpra/main/server-install.sh | sudo sh +``` +Скрипт `server-install.sh`: +- качает бинарник сервера под архитектуру машины из [последнего релиза](https://github.com/iRedTea/lpra/releases/latest) + и папку `dist/` (бинарники агентов) из ветки `main`, кладёт всё в `/opt/lpra`; +- ставит службу systemd `lpra-server` (порт спросит, по умолчанию `8091`); +- по желанию настраивает nginx: спросит домен, создаст сайт на порт 80 (при необходимости поставит nginx), + затем `certbot --nginx` - если certbot с плагином nginx не установлен, этот шаг пропускается; +- если включён ufw - предложит открыть 80, 443 и туннель `8446` tcp/udp; +- запускает службу, перезагружает nginx и проверяет, что сервер отвечает. + +Вопросы задаются в терминале, даже когда скрипт идёт через `| sudo sh`. Без терминала ответы берутся +из переменных: `LPRA_PORT`, `LPRA_NGINX` (y/n), `LPRA_DOMAIN`, `LPRA_EMAIL`, `LPRA_CERTBOT` (y/n), `LPRA_UFW` (y/n): +```sh +wget -qO- https://raw.githubusercontent.com/iRedTea/lpra/main/server-install.sh | sudo LPRA_NGINX=y LPRA_DOMAIN=lpra.example.com sh +``` +Повторный запуск той же командой - **обновление**: бинарник и `dist/` заменяются, база (`/opt/lpra/lpra.db` +и её ключ `lpra.db.key`), настройки службы и nginx остаются. После установки откройте `/setup` - мастер +создаст администратора. + ## Запуск сервера ```sh ./bin/lpra-server -addr 127.0.0.1:8091 -dist ./dist -db ./lpra.db diff --git a/server-install.sh b/server-install.sh new file mode 100755 index 0000000..edd33c6 --- /dev/null +++ b/server-install.sh @@ -0,0 +1,310 @@ +#!/bin/sh +# LPRA server: install or update in one command. +# +# wget -qO- https://raw.githubusercontent.com/iRedTea/lpra/main/server-install.sh | sudo sh +# +# Takes the server binary for this machine from the latest GitHub release and +# the agent binaries (dist/) from the main branch, puts them in /opt/lpra, +# sets up the systemd service and, if asked, nginx with a domain and a +# certificate from certbot. Run again to update: the binary and dist/ are +# replaced, the database, the service's settings and nginx stay. +# +# Questions are asked on the terminal even when the script comes through a +# pipe. Without a terminal the answers come from the environment (or their +# defaults): LPRA_PORT (8091), LPRA_NGINX (y/n), LPRA_DOMAIN, LPRA_EMAIL, +# LPRA_CERTBOT (y/n), LPRA_UFW (y/n). LPRA_REPO and LPRA_DIR change where from +# and where to. +set -eu + +REPO=${LPRA_REPO:-iRedTea/lpra} +DIR=${LPRA_DIR:-/opt/lpra} +SERVICE=lpra-server +UNIT=/etc/systemd/system/$SERVICE.service +TUNNEL_PORT=8446 + +say() { printf '\033[1;34m>>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; } +die() { printf '\033[1;31mxx\033[0m %s\n' "$*" >&2; exit 1; } + +# ---------- checks ---------- +[ "$(id -u)" -eq 0 ] || die "нужен root: ... | sudo sh" +[ "$(uname -s)" = Linux ] || die "сервер ставится только на Linux" +command -v systemctl >/dev/null 2>&1 || die "нужен systemd" +case "$(uname -m)" in + x86_64 | amd64) ARCH=amd64 ;; + aarch64 | arm64) ARCH=arm64 ;; + *) die "архитектура $(uname -m) не поддерживается (есть amd64 и arm64)" ;; +esac + +if command -v curl >/dev/null 2>&1; then + dl() { curl -fsSL --retry 3 -o "$2" "$1"; } + fetch() { curl -fsSL --retry 3 "$1"; } +elif command -v wget >/dev/null 2>&1; then + dl() { wget -q -O "$2" "$1"; } + fetch() { wget -q -O - "$1"; } +else + die "нужен curl или wget" +fi + +# ---------- questions ---------- +TTY= +if (: /dev/null; then TTY=/dev/tty; fi + +# ask VAR "question" default: the answer from the terminal, else the +# environment's LPRA_, else the default. +ask() { + var=$1 q=$2 def=$3 + eval "env=\${LPRA_$var:-}" + [ -n "$env" ] && def=$env + ans= + if [ -n "$TTY" ]; then + if [ -n "$def" ]; then printf '%s [%s]: ' "$q" "$def" >"$TTY"; else printf '%s: ' "$q" >"$TTY"; fi + read -r ans <"$TTY" || ans= + fi + [ -n "$ans" ] || ans=$def + eval "$var=\$ans" +} +# confirm VAR "question" y|n: true for a yes. +confirm() { + ask "$1" "$2 (y/n)" "$3" + eval "v=\$$1" + case "$v" in [yYдД]*) return 0 ;; *) return 1 ;; esac +} + +# ---------- download ---------- +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT INT TERM + +TAG=$(fetch "https://api.github.com/repos/$REPO/releases/latest" 2>/dev/null | + sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n 1) || TAG= +if [ -n "$TAG" ]; then + say "последний релиз: $TAG" + BIN_URL="https://github.com/$REPO/releases/download/$TAG/lpra-server-linux-$ARCH" +else + warn "не узнать номер релиза у GitHub API - беру latest" + BIN_URL="https://github.com/$REPO/releases/latest/download/lpra-server-linux-$ARCH" +fi +say "скачиваю сервер (linux/$ARCH)" +dl "$BIN_URL" "$TMP/lpra-server" || die "не скачался $BIN_URL" +chmod +x "$TMP/lpra-server" + +say "скачиваю dist/ из ветки main" +mkdir -p "$TMP/dist" +URLS=$(fetch "https://api.github.com/repos/$REPO/contents/dist?ref=main" 2>/dev/null | + sed -n 's/.*"download_url": *"\([^"]*\)".*/\1/p') || URLS= +if [ -z "$URLS" ]; then + warn "не получить список dist/ у GitHub API - беру агентов linux amd64 и arm64" + URLS="https://raw.githubusercontent.com/$REPO/main/dist/agent-linux-amd64 +https://raw.githubusercontent.com/$REPO/main/dist/agent-linux-arm64" +fi +for u in $URLS; do + f=${u##*/} + dl "$u" "$TMP/dist/$f" || die "не скачался $u" + case "$f" in agent-*) chmod +x "$TMP/dist/$f" ;; esac + printf ' %s\n' "$f" +done + +# ---------- install ---------- +UPDATE= +[ -f "$UNIT" ] && UPDATE=1 +if [ -n "$UPDATE" ]; then + say "уже установлен - обновляю" + systemctl stop "$SERVICE" 2>/dev/null || true +fi +mkdir -p "$DIR" +mv -f "$TMP/lpra-server" "$DIR/lpra-server" +rm -rf "$DIR/dist.old" +[ -d "$DIR/dist" ] && mv "$DIR/dist" "$DIR/dist.old" +mv "$TMP/dist" "$DIR/dist" +rm -rf "$DIR/dist.old" +say "файлы в $DIR" + +# The port: from the existing service on an update, asked otherwise. +PORT= +if [ -n "$UPDATE" ]; then + PORT=$(sed -n 's/.*-addr [^ ]*:\([0-9][0-9]*\).*/\1/p' "$UNIT" | head -n 1) +fi +if [ -z "$PORT" ]; then + ask PORT "порт сервера" 8091 + case "$PORT" in '' | *[!0-9]*) die "порт - это число" ;; esac +fi + +# ---------- nginx ---------- +NGINX= DOMAIN= TLS= +if [ -n "$UPDATE" ]; then NGDEF=n; else NGDEF=y; fi +if confirm NGINX "настроить nginx с доменом" "$NGDEF"; then + if ! command -v nginx >/dev/null 2>&1; then + say "ставлю nginx" + if command -v apt-get >/dev/null 2>&1; then + { apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq nginx; } >/dev/null 2>&1 + elif command -v dnf >/dev/null 2>&1; then + dnf install -y -q nginx >/dev/null 2>&1 + elif command -v yum >/dev/null 2>&1; then + yum install -y -q nginx >/dev/null 2>&1 + fi + fi + if command -v nginx >/dev/null 2>&1; then + ask DOMAIN "домен (например lpra.example.com)" "" + [ -n "$DOMAIN" ] || die "без домена nginx не настроить" + NGINX=1 + if [ -d /etc/nginx/sites-available ]; then + CONF=/etc/nginx/sites-available/lpra + LINK=/etc/nginx/sites-enabled/lpra + else + CONF=/etc/nginx/conf.d/lpra.conf + LINK= + fi + cat >"$CONF" < 127.0.0.1:$PORT +# Agents and terminals use WebSocket (the Upgrade headers) and long polling +# (no buffering, long timeouts). +map \$http_upgrade \$lpra_connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 80; + server_name $DOMAIN; + + client_max_body_size 120m; + + location / { + proxy_pass http://127.0.0.1:$PORT; + + proxy_http_version 1.1; + proxy_set_header Host \$host; + proxy_set_header X-Real-IP \$remote_addr; + proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto \$scheme; + proxy_set_header Upgrade \$http_upgrade; + proxy_set_header Connection \$lpra_connection_upgrade; + + proxy_buffering off; + proxy_cache off; + proxy_request_buffering off; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + } +} +EOF + [ -n "$LINK" ] && ln -sf "$CONF" "$LINK" + if ! nginx -t >/dev/null 2>&1; then nginx -t; die "nginx -t не прошёл, конфиг: $CONF"; fi + systemctl enable --now nginx >/dev/null 2>&1 || true + systemctl reload nginx + say "nginx: $DOMAIN -> 127.0.0.1:$PORT ($CONF)" + + # A certificate, when certbot with its nginx plugin is there. + if command -v certbot >/dev/null 2>&1 && certbot plugins 2>/dev/null | grep -q nginx; then + if confirm CERTBOT "получить сертификат Let's Encrypt (certbot --nginx)" y; then + ask EMAIL "email для Let's Encrypt (можно пусто)" "" + if [ -n "$EMAIL" ]; then M="-m $EMAIL"; else M="--register-unsafely-without-email"; fi + # shellcheck disable=SC2086 + if certbot --nginx -d "$DOMAIN" --non-interactive --agree-tos --redirect $M; then + TLS=1 + else + warn "certbot не смог - остаётся http. Домен должен указывать на этот сервер, порт 80 открыт" + fi + fi + else + warn "certbot с плагином nginx не найден - сертификат пропускаю (apt install certbot python3-certbot-nginx)" + fi + else + warn "nginx не установлен и не поставился - пропускаю" + fi +fi + +# Not set up now, but by an earlier run: its domain, and https if certbot +# added it. +if [ -z "$NGINX" ]; then + for c in /etc/nginx/sites-available/lpra /etc/nginx/conf.d/lpra.conf; do + [ -f "$c" ] || continue + DOMAIN=$(sed -n 's/^[[:space:]]*server_name[[:space:]]\{1,\}\([^;[:space:]]*\).*/\1/p' "$c" | head -n 1) + grep -q 'listen.*443' "$c" && TLS=1 + break + done +fi + +# ---------- firewall ---------- +if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then + if confirm UFW "ufw включён: открыть 80, 443 и туннель $TUNNEL_PORT tcp/udp" y; then + ufw allow 80/tcp >/dev/null + ufw allow 443/tcp >/dev/null + ufw allow $TUNNEL_PORT/tcp >/dev/null + ufw allow $TUNNEL_PORT/udp >/dev/null + if [ -z "$DOMAIN" ]; then ufw allow "$PORT/tcp" >/dev/null; fi + say "ufw: открыто" + fi +fi + +# ---------- service ---------- +# Behind nginx: local only. Without it the panel listens on every address. +# Cookies are Secure only when the panel is reached by https. +if [ -z "$UPDATE" ] || [ -n "$NGINX" ]; then + if [ -n "$NGINX" ]; then ADDR=127.0.0.1:$PORT; else ADDR=0.0.0.0:$PORT; fi + COOKIES= + [ -z "$TLS" ] && COOKIES='Environment=LPRA_INSECURE_COOKIES=1' + cat >"$UNIT" </dev/null 2>&1 +systemctl restart "$SERVICE" +say "служба $SERVICE запущена" + +# ---------- health check ---------- +ok= +i=0 +while [ $i -lt 20 ]; do + if fetch "http://127.0.0.1:$PORT/login" >/dev/null 2>&1; then ok=1; break; fi + i=$((i + 1)) + sleep 1 +done +[ -n "$ok" ] || die "сервер не отвечает на 127.0.0.1:$PORT - смотрите: journalctl -u $SERVICE -n 50" +say "сервер отвечает" +if [ -n "$NGINX" ]; then + if fetch "http://127.0.0.1/login" >/dev/null 2>&1 || [ -n "$TLS" ]; then say "nginx отвечает"; fi +fi + +# ---------- done ---------- +if [ -n "$TLS" ]; then URL=https://$DOMAIN +elif [ -n "$DOMAIN" ]; then URL=http://$DOMAIN +else + IP=$(hostname -I 2>/dev/null | awk '{print $1}') + URL="http://${IP:-}:$PORT" +fi +echo +say "готово: LPRA ${TAG:-} в $DIR" +if [ -z "$UPDATE" ]; then + echo " откройте $URL/setup - мастер создаст администратора" +else + echo " панель: $URL" +fi +if [ -z "$TLS" ]; then echo " панель без https: куки без Secure. Для работы через интернет лучше домен с сертификатом"; fi +echo " логи: journalctl -u $SERVICE -f"