diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..760af7a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,54 @@ +# syntax=docker/dockerfile:1 + +# ---------- web ---------- +FROM node:24-bookworm-slim AS web +WORKDIR /src/web +COPY web/package.json web/package-lock.json ./ +RUN npm ci --no-audit --no-fund +COPY web/ ./ +RUN npm run build + +# ---------- go ---------- +FROM golang:1.27-bookworm AS gobuild +WORKDIR /src +ARG VERSION=dev +ARG COMMIT=none +ARG BUILD_DATE=unknown +COPY go.mod go.sum ./ +RUN go mod download +COPY cmd/ ./cmd/ +COPY internal/ ./internal/ +RUN CGO_ENABLED=1 go build \ + -tags "sqlite_omit_load_extension" \ + -trimpath \ + -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.buildDate=${BUILD_DATE}" \ + -o /out/glchat ./cmd/glchat + +# ---------- runtime ---------- +FROM debian:bookworm-slim AS runtime + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates tzdata curl \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system --gid 10001 glchat \ + && useradd --system --uid 10001 --gid glchat --home-dir /app --shell /usr/sbin/nologin glchat \ + && mkdir -p /app/data/db /app/data/files /app/data/backups /app/web \ + && chown -R glchat:glchat /app + +COPY --from=gobuild /out/glchat /usr/local/bin/glchat +COPY --from=web /src/web/dist /app/web + +WORKDIR /app +USER 10001:10001 + +ENV DATA_DIR=/app/data \ + LISTEN_ADDR=0.0.0.0:8080 \ + LOG_FORMAT=json \ + LOG_LEVEL=info + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1 + +ENTRYPOINT ["/usr/local/bin/glchat"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..dc7a1ce --- /dev/null +++ b/Makefile @@ -0,0 +1,177 @@ +SHELL := /usr/bin/env bash +.DEFAULT_GOAL := help + +APP := glchat +MODULE := glchat +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none) +BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) + +ROOT := $(CURDIR) +CACHE_DIR := $(ROOT)/.cache +BIN_DIR := $(CACHE_DIR)/bin +BUILD_DIR := $(ROOT)/build +export GOCACHE := $(CACHE_DIR)/go-build +export GOMODCACHE:= $(CACHE_DIR)/go-mod +export GOPATH := $(CACHE_DIR)/gopath +export GOTOOLCHAIN := local +export CGO_ENABLED ?= 1 + +GOFLAGS_LOCAL := -trimpath +GO_LDFLAGS := -s -w \ + -X main.version=$(VERSION) \ + -X main.commit=$(COMMIT) \ + -X main.buildDate=$(BUILD_DATE) + +TOOLS := $(BIN_DIR)/golangci-lint $(BIN_DIR)/gofumpt $(BIN_DIR)/sqlc $(BIN_DIR)/goose $(BIN_DIR)/govulncheck $(BIN_DIR)/gitleaks +export PATH := $(BIN_DIR):$(PATH) + +GO_PACKAGES := ./cmd/... ./internal/... +GO_LINT_TIMEOUT ?= 5m + +WEB_DIR := $(ROOT)/web +DOCKER_IMAGE ?= glchat +DOCKER_TAG ?= $(VERSION) +SKIP_TLS ?= 0 + +.PHONY: help +help: ## Показать список целей + @printf 'glchat — доступные цели:\n\n' + @grep -hE '^[a-zA-Z0-9_.-]+:.*?## ' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-22s\033[0m %s\n", $$1, $$2}' + @printf '\nПрофиль: %s | Кэш: %s\n' "$${PROFILE:-auto}" "$(CACHE_DIR)" + +.PHONY: tools +tools: $(TOOLS) ## Установить зафиксированные dev-инструменты в .cache/bin + +$(CACHE_DIR): + @mkdir -p $(CACHE_DIR) $(BIN_DIR) + +$(BIN_DIR)/golangci-lint: | $(CACHE_DIR) + @echo "==> install golangci-lint v2.13.2" + @GOBIN=$(BIN_DIR) go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2 + +$(BIN_DIR)/gofumpt: | $(CACHE_DIR) + @echo "==> install gofumpt" + @GOBIN=$(BIN_DIR) go install mvdan.cc/gofumpt@v0.11.0 + +$(BIN_DIR)/sqlc: | $(CACHE_DIR) + @echo "==> install sqlc" + @GOBIN=$(BIN_DIR) go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.30.0 + +$(BIN_DIR)/goose: | $(CACHE_DIR) + @echo "==> install goose" + @GOBIN=$(BIN_DIR) go install github.com/pressly/goose/v3/cmd/goose@v3.28.0 + +$(BIN_DIR)/govulncheck: | $(CACHE_DIR) + @echo "==> install govulncheck" + @GOBIN=$(BIN_DIR) go install golang.org/x/vuln/cmd/govulncheck@latest + +$(BIN_DIR)/gitleaks: | $(CACHE_DIR) + @echo "==> install gitleaks" + @GOBIN=$(BIN_DIR) go install github.com/zricethezav/gitleaks/v8@latest + +.PHONY: dev +dev: ## Запустить сервер локально (данные в ./build/dev-data) + @mkdir -p $(BUILD_DIR)/dev-data/db + DATA_DIR=$(BUILD_DIR)/dev-data \ + LISTEN_ADDR=127.0.0.1:8080 \ + DOMAIN=localhost \ + LOG_FORMAT=text \ + LOG_LEVEL=debug \ + go run ./cmd/glchat + +.PHONY: build +build: ## Собрать бинарник сервера в ./build/glchat + @mkdir -p $(BUILD_DIR) + go build $(GOFLAGS_LOCAL) -ldflags '$(GO_LDFLAGS)' -o $(BUILD_DIR)/$(APP) ./cmd/glchat + @ls -lh $(BUILD_DIR)/$(APP) + +.PHONY: test +test: ## Тесты Go с race-детектором + go test -race -count=1 $(GO_PACKAGES) + +.PHONY: test-cover +test-cover: ## Тесты с отчётом покрытия (./build/coverage.out) + @mkdir -p $(BUILD_DIR) + go test -race -count=1 -coverprofile=$(BUILD_DIR)/coverage.out $(GO_PACKAGES) + go tool cover -func=$(BUILD_DIR)/coverage.out | tail -1 + +.PHONY: lint +lint: tools go-lint web-lint ## Полный линт (Go + web) + +.PHONY: go-lint +go-lint: tools ## Линт Go: формат, vet, golangci-lint + @echo "==> gofmt check" + @unformatted=$$(gofumpt -l . | grep -v '^\.cache/' || true); \ + if [ -n "$$unformatted" ]; then echo "$$unformatted"; echo "run: make fmt"; exit 1; fi + @echo "==> go vet" + @go vet $(GO_PACKAGES) + @echo "==> golangci-lint" + @golangci-lint run --timeout $(GO_LINT_TIMEOUT) $(GO_PACKAGES) + +.PHONY: fmt +fmt: tools ## Форматировать Go-код + gofumpt -w . + +.PHONY: generate +generate: tools ## Проверить сгенерированные артефакты (OpenAPI, sqlc — по мере появления) + @echo "==> openapi: обслуживается из internal/server/openapi.go (/api/v1/openapi.json)" + @test -s internal/server/openapi.go || { echo "openapi.go отсутствует"; exit 1; } + @if [ -d queries ] && [ -n "$$(ls -A queries 2>/dev/null | grep -v '^.gitkeep$$')" ]; then \ + echo "==> sqlc generate"; sqlc generate; \ + else echo "==> sqlc: запросов пока нет, пропускаем"; fi + +.PHONY: security +security: tools ## AppSec: govulncheck, gitleaks, trivy (по возможности) + @mkdir -p $(BUILD_DIR) + @echo "==> govulncheck" + @govulncheck ./cmd/... ./internal/... || { echo "govulncheck нашёл уязвимости"; exit 1; } + @echo "==> gitleaks" + @$(ROOT)/scripts/gitleaks-scan.sh + @echo "==> trivy filesystem scan" + @if command -v trivy >/dev/null 2>&1; then \ + trivy fs --quiet --scanners vuln,secret,misconfig --exit-code 1 \ + --skip-dirs .cache --skip-dirs .git --skip-dirs web/node_modules .; \ + else echo "trivy not installed — skipped"; fi + +.PHONY: secrets-scan +secrets-scan: tools ## Только проверка на утечки секретов + @$(ROOT)/scripts/gitleaks-scan.sh + +.PHONY: web-install +web-install: ## Установить зависимости web-клиента + cd $(WEB_DIR) && npm ci + +.PHONY: web-lint +web-lint: ## Линт и типизация web-клиента + cd $(WEB_DIR) && npm run lint + cd $(WEB_DIR) && npm run check + +.PHONY: web-test +web-test: ## Тесты web-клиента (vitest) + cd $(WEB_DIR) && npm test + +.PHONY: web-build +web-build: ## Сборка web-клиента в web/dist + cd $(WEB_DIR) && npm run build + +.PHONY: docker-build +docker-build: ## Собрать образ приложения + docker build \ + --build-arg VERSION=$(VERSION) \ + --build-arg COMMIT=$(COMMIT) \ + --build-arg BUILD_DATE=$(BUILD_DATE) \ + -t $(DOCKER_IMAGE):$(DOCKER_TAG) . + +.PHONY: install-dry-run +install-dry-run: ## Проверить установщик без изменений системы + bash deploy/install.sh --dry-run --domain gl.mhspx.su --email admin@gl.mhspx.su --yes + +.PHONY: install-test +install-test: ## Прогон install.sh в чистом контейнере Ubuntu 24.04 + bash scripts/test-install.sh + +.PHONY: clean +clean: ## Удалить артефакты сборки + rm -rf $(BUILD_DIR) diff --git a/deploy/backup.sh b/deploy/backup.sh new file mode 100755 index 0000000..1412d2e --- /dev/null +++ b/deploy/backup.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# glchat: бэкап БД и файлов с age-шифрованием (AGENT.md 10.8). +set -euo pipefail + +GLCHAT_ETC_DIR="${GLCHAT_ETC_DIR:-/opt/glchat}" +GLCHAT_DATA_DIR="${GLCHAT_DATA_DIR:-${GLCHAT_ETC_DIR}/data}" +ENV_FILE="${GLCHAT_ETC_DIR}/.env" +BACKUP_DIR="${GLCHAT_DATA_DIR}/backups" +KEEP_DAYS_DEFAULT=7 + +log() { printf '[backup] %s\n' "$*"; } +die() { + printf '[backup] ошибка: %s\n' "$*" >&2 + exit 1 +} + +[ -f "$ENV_FILE" ] || die "не найден ${ENV_FILE}" +# shellcheck disable=SC1090 +set -a && . "$ENV_FILE" && set +a + +KEEP_DAYS="${BACKUP_KEEP_DAYS:-$KEEP_DAYS_DEFAULT}" +AGE_RECIPIENT="${AGE_RECIPIENT:-}" +STAMP="$(date -u +%Y%m%dT%H%M%SZ)" +WORK_DIR="$(mktemp -d)" +ARCHIVE="${BACKUP_DIR}/glchat-${STAMP}.tar.gz" +TARGET="${ARCHIVE}.age" + +cleanup() { rm -rf "$WORK_DIR"; } +trap cleanup EXIT + +mkdir -p "$BACKUP_DIR" +chmod 700 "$BACKUP_DIR" + +log "дамп SQLite (sqlite3 .backup)" +if command -v sqlite3 >/dev/null 2>&1; then + sqlite3 "${GLCHAT_DATA_DIR}/db/glchat.db" ".backup '${WORK_DIR}/glchat.db'" || + die "не удалось сделать дамп базы" +else + docker exec glchat-app true 2>/dev/null || die "sqlite3 не установлен и контейнер недоступен" + docker cp glchat-app:/app/data/db/glchat.db "${WORK_DIR}/glchat.db" +fi + +log "копируем файлы" +mkdir -p "${WORK_DIR}/files" +if [ -d "${GLCHAT_DATA_DIR}/files" ]; then + if command -v rsync >/dev/null 2>&1; then + rsync -a --delete "${GLCHAT_DATA_DIR}/files/" "${WORK_DIR}/files/" + else + cp -a "${GLCHAT_DATA_DIR}/files/." "${WORK_DIR}/files/" + fi +fi + +log "сохраняем конфиги (без секретов)" +mkdir -p "${WORK_DIR}/config" +cp -a "${GLCHAT_ETC_DIR}/compose.yaml" "${WORK_DIR}/config/" 2>/dev/null || true +cp -a "${GLCHAT_ETC_DIR}/livekit" "${WORK_DIR}/config/" 2>/dev/null || true +cp -a "${GLCHAT_ETC_DIR}/external-proxy.conf" "${WORK_DIR}/config/" 2>/dev/null || true +cat >"${WORK_DIR}/MANIFEST.txt" </dev/null 2>&1 || die "age не установлен, а AGE_RECIPIENT задан" + age -r "$AGE_RECIPIENT" -o "$TARGET" "$ARCHIVE" || die "age-шифрование не удалось" + rm -f "$ARCHIVE" + chmod 600 "$TARGET" + log "готово: ${TARGET} ($(du -h "$TARGET" | cut -f1))" +else + chmod 600 "$ARCHIVE" + log "готово (БЕЗ шифрования, AGE_RECIPIENT не задан): ${ARCHIVE}" +fi + +log "ротация: оставляем ${KEEP_DAYS} дн." +find "$BACKUP_DIR" -maxdepth 1 -name 'glchat-*.tar.gz*' -mtime "+${KEEP_DAYS}" -print -delete || true + +log "бэкапов в каталоге: $(find "$BACKUP_DIR" -maxdepth 1 -name 'glchat-*.tar.gz*' | wc -l)" diff --git a/deploy/caddy/Caddyfile.tpl b/deploy/caddy/Caddyfile.tpl new file mode 100644 index 0000000..4c9eb20 --- /dev/null +++ b/deploy/caddy/Caddyfile.tpl @@ -0,0 +1,64 @@ +# Сгенерировано deploy/install.sh — не редактируйте вручную. +# Режим: {{PROXY_MODE}} | Домен: {{DOMAIN}} +{ + email {{ACME_EMAIL}} + admin off + log { + output file /var/log/caddy/access.log { + roll_size 10mb + roll_keep 3 + } + format json + } +} + +(security_headers) { + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + Referrer-Policy "strict-origin-when-cross-origin" + X-Frame-Options "DENY" + -Server + } +} + +{{DOMAIN}} { + encode zstd gzip + import security_headers + + @gateway path /gateway + handle @gateway { + reverse_proxy app:8080 { + transport http { + read_timeout 1h + write_timeout 1h + } + } + } + + @rtc path /rtc* + handle @rtc { + reverse_proxy livekit:7880 { + transport http { + read_timeout 1h + write_timeout 1h + } + } + } + + handle /api/* { + reverse_proxy app:8080 + } + + handle { + reverse_proxy app:8080 + } +} + +{{FILES_DOMAIN}} { + encode zstd gzip + import security_headers + handle { + reverse_proxy app:8080 + } +} diff --git a/deploy/caddy/external-proxy.conf.tpl b/deploy/caddy/external-proxy.conf.tpl new file mode 100644 index 0000000..2d577df --- /dev/null +++ b/deploy/caddy/external-proxy.conf.tpl @@ -0,0 +1,67 @@ +# Сгенерировано deploy/install.sh — не редактируйте вручную. +# Режим: внешний reverse proxy (--external-proxy). Собственный Caddy не запускается: +# блок ниже нужно вставить в конфиг вашего прокси (например, Caddyfile) и перезагрузить его. +# Приложение и LiveKit слушают только 127.0.0.1. +{ + email {{ACME_EMAIL}} +} + +(glchat_security_headers) { + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + Referrer-Policy "strict-origin-when-cross-origin" + X-Frame-Options "DENY" + -Server + } +} + +# --- glchat: начало блока ------------------------------------------------- +{{DOMAIN}} { + encode zstd gzip + import glchat_security_headers + log { + output file /opt/glchat/caddy/logs/access.log { + roll_size 10mb + roll_keep 3 + } + format json + } + + # WebSocket Gateway (фаза 1): длинные соединения без таймаутов. + @gateway path /gateway + handle @gateway { + reverse_proxy 127.0.0.1:{{APP_PORT}} { + transport http { + read_timeout 1h + write_timeout 1h + } + } + } + + # LiveKit сигналинг (WebSocket). + @rtc path /rtc* + handle @rtc { + reverse_proxy 127.0.0.1:{{LIVEKIT_WS_PORT}} { + transport http { + read_timeout 1h + write_timeout 1h + } + } + } + + # API и статика клиента. + handle { + reverse_proxy 127.0.0.1:{{APP_PORT}} + } +} + +# Пользовательский контент — отдельный origin (изоляция cookies/CSP). +{{FILES_DOMAIN}} { + encode zstd gzip + import glchat_security_headers + handle { + reverse_proxy 127.0.0.1:{{APP_PORT}} + } +} +# --- glchat: конец блока -------------------------------------------------- diff --git a/deploy/compose.yaml.tpl b/deploy/compose.yaml.tpl new file mode 100644 index 0000000..929d42b --- /dev/null +++ b/deploy/compose.yaml.tpl @@ -0,0 +1,115 @@ +# Сгенерировано deploy/install.sh — не редактируйте вручную, изменения перезапишутся. +# Шаблон: deploy/compose.yaml.tpl. Профиль: {{PROFILE}} +name: glchat + +x-logging: &default-logging + driver: json-file + options: + max-size: "10m" + max-file: "3" + +x-app-env: &app-env + DOMAIN: "{{DOMAIN}}" + FILES_DOMAIN: "{{FILES_DOMAIN}}" + INSTANCE_NAME: "{{INSTANCE_NAME}}" + LISTEN_ADDR: "0.0.0.0:8080" + DATA_DIR: "/app/data" + WEB_ROOT: "/app/web" + LOG_LEVEL: "{{LOG_LEVEL}}" + LOG_FORMAT: "json" + SQLITE_READ_POOL: "{{SQLITE_READ_POOL}}" + SQLITE_CACHE_SIZE: "{{SQLITE_CACHE_SIZE}}" + SQLITE_MMAP_SIZE: "{{SQLITE_MMAP_SIZE}}" + GOMAXPROCS: "{{GOMAXPROCS}}" + MAX_UPLOAD_SIZE: "{{MAX_UPLOAD_SIZE}}" + SHUTDOWN_TIMEOUT_SECONDS: "20" + +services: + app: + image: {{APP_IMAGE}} + container_name: glchat-app + restart: unless-stopped + env_file: + - .env + environment: + <<: *app-env + ports: + - "127.0.0.1:{{APP_PORT}}:8080" + volumes: + - "{{DATA_DIR}}/db:/app/data/db" + - "{{DATA_DIR}}/files:/app/data/files" + - "{{DATA_DIR}}/backups:/app/data/backups" + logging: *default-logging + deploy: + resources: + limits: + cpus: "{{APP_CPUS}}" + memory: {{APP_MEMORY_LIMIT}} + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/healthz"] + interval: 30s + timeout: 5s + start_period: 20s + retries: 3 + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + + livekit: + image: livekit/livekit-server:{{LIVEKIT_IMAGE_TAG}} + container_name: glchat-livekit + restart: unless-stopped + command: ["--config", "/etc/livekit.yaml"] + environment: + LIVEKIT_KEYS: "{{LIVEKIT_API_KEY}}: {{LIVEKIT_API_SECRET}}" + ports:{{LIVEKIT_PORTS}} + volumes: + - ./livekit/livekit.yaml:/etc/livekit.yaml:ro + logging: *default-logging + deploy: + resources: + limits: + cpus: "{{LIVEKIT_CPUS}}" + memory: {{LIVEKIT_MEMORY_LIMIT}} + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:7880"] + interval: 30s + timeout: 5s + start_period: 15s + retries: 3 + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + cap_add: + - NET_BIND_SERVICE + + caddy: + image: caddy:{{CADDY_IMAGE_TAG}} + container_name: glchat-caddy + restart: unless-stopped + profiles: ["{{CADDY_PROFILE}}"] + ports: + - "{{HTTP_PORT}}:80" + - "{{HTTPS_PORT}}:443" + - "{{HTTPS_PORT}}:443/udp" + volumes: + - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro + - ./caddy/data:/data + - ./caddy/config:/config + - ./caddy/logs:/var/log/caddy + logging: *default-logging + deploy: + resources: + limits: + cpus: "{{CADDY_CPUS}}" + memory: {{CADDY_MEMORY_LIMIT}} + healthcheck: + test: ["CMD", "caddy", "version"] + interval: 30s + timeout: 5s + start_period: 10s + retries: 3 + security_opt: + - no-new-privileges:true diff --git a/deploy/fail2ban/filter-glchat-auth.conf b/deploy/fail2ban/filter-glchat-auth.conf new file mode 100644 index 0000000..dac192f --- /dev/null +++ b/deploy/fail2ban/filter-glchat-auth.conf @@ -0,0 +1,7 @@ +# Фильтр fail2ban для glchat: неудачные входы и регистрации (AGENT.md 9.6). +# Access-лог Caddy пишется в JSON, поэтому разбираем поля client_ip и status. +[Definition] + +failregex = ^.*"status":(401|403|429),"size":\d+,"host":"[^"]*","uri":"/api/v1/auth/(login|register)".*"client_ip":"".*$ + +ignoreregex = diff --git a/deploy/fail2ban/jail.local.tpl b/deploy/fail2ban/jail.local.tpl new file mode 100644 index 0000000..73c8a08 --- /dev/null +++ b/deploy/fail2ban/jail.local.tpl @@ -0,0 +1,27 @@ +# Сгенерировано deploy/install.sh — не редактируйте вручную. +# Jail для неудачных входов в приложение (AGENT.md 9.6). +[glchat-auth] +enabled = true +filter = glchat-auth +logpath = /opt/glchat/caddy/logs/access.log +maxretry = 10 +findtime = 5m +bantime = 30m +action = iptables-allports[name=glchat-auth] + +[sshd] +enabled = true +port = {{SSH_PORT}} +maxretry = 5 +findtime = 10m +bantime = 1h +action = iptables-allports[name=sshd] + +[recidive] +enabled = true +logpath = /var/log/fail2ban.log +banaction = iptables-allports +bantime = 1w +findtime = 1d +maxretry = 3 +action = iptables-allports[name=recidive] diff --git a/deploy/glchat-cli.sh b/deploy/glchat-cli.sh new file mode 100755 index 0000000..f5cab57 --- /dev/null +++ b/deploy/glchat-cli.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +# glchat — служебные команды инстанса (AGENT.md 10.7). +# Ставится установщиком в /usr/local/bin/glchat. +set -euo pipefail + +GLCHAT_ETC_DIR="${GLCHAT_ETC_DIR:-/opt/glchat}" +COMPOSE_FILE="${GLCHAT_ETC_DIR}/compose.yaml" +ENV_FILE="${GLCHAT_ETC_DIR}/.env" + +COMPOSE="docker compose -f ${COMPOSE_FILE}" + +usage() { + cat <<'EOF' +glchat — управление инстансом + + glchat status состояние контейнеров, версия, здоровье, ресурсы + glchat logs [service] логи (follow) + glchat restart [service] перезапуск стека или сервиса + glchat backup бэкап БД и файлов (age-шифрование) + glchat restore <файл> восстановление из бэкапа + glchat update [--channel X] обновление (бэкап → pull → up → smoke) + glchat reset-password сброс пароля пользователя (фаза 1) + glchat make-admin выдать права инстанс-админа (фаза 1) + glchat remove-admin снять права инстанс-админа (фаза 1) + glchat unban снять бан fail2ban + glchat fail2ban-status статус jails и забаненных IP + glchat profile [tier] показать/применить профиль производительности + glchat security-check аудит: SSH, fail2ban, порты, лимиты + glchat reindex перестроить FTS-индекс (фаза 2) + glchat change-domain сменить домен + glchat uninstall [--keep-data] удалить стек + glchat version версия CLI и приложения +EOF +} + +require_stack() { + [ -f "$COMPOSE_FILE" ] || { + echo "glchat: не найден ${COMPOSE_FILE}; сначала запустите install.sh" >&2 + exit 1 + } +} + +cmd_status() { + require_stack + local meta version health + echo "== compose ==" + $COMPOSE ps + echo + meta="$(curl -fsS "http://127.0.0.1:8080/api/v1/meta" 2>/dev/null || true)" + version="$(printf '%s' "$meta" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')" + health="$(curl -fsS "http://127.0.0.1:8080/readyz" 2>/dev/null || echo '{"status":"unreachable"}')" + echo "== app ==" + echo "version: ${version:-unknown}" + echo "readyz: ${health}" + echo + echo "== ресурсы ==" + docker stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}' 2>/dev/null || true + echo + df -h "${GLCHAT_ETC_DIR}/data" 2>/dev/null | tail -1 || true +} + +cmd_logs() { + require_stack + $COMPOSE logs -f --tail=200 "$@" +} + +cmd_restart() { + require_stack + if [ "$#" -gt 0 ]; then + $COMPOSE restart "$@" + else + $COMPOSE restart + fi +} + +cmd_backup() { + require_stack + bash "${GLCHAT_ETC_DIR}/backup.sh" "$@" +} + +cmd_restore() { + require_stack + bash "${GLCHAT_ETC_DIR}/restore.sh" "$@" +} + +cmd_update() { + require_stack + bash "${GLCHAT_ETC_DIR}/update.sh" "$@" +} + +cmd_unban() { + [ "$#" -ge 1 ] || { + echo "использование: glchat unban " >&2 + exit 1 + } + fail2ban-client unban "$1" +} + +cmd_fail2ban_status() { + fail2ban-client status + for jail in $(fail2ban-client status | sed -n 's/.*Jail list:\s*//p' | tr ',' ' '); do + echo + fail2ban-client status "${jail// /}" + done +} + +cmd_profile() { + if [ "$#" -eq 0 ]; then + echo "профиль: $(sed -n 's/^PROFILE=//p' "$ENV_FILE" 2>/dev/null || echo unknown)" + grep -E '^(SQLITE_|GOMAXPROCS|MAX_UPLOAD_SIZE|APP_CPUS|APP_MEMORY_LIMIT)' "$ENV_FILE" 2>/dev/null || true + return + fi + GLCHAT_ETC_DIR="$GLCHAT_ETC_DIR" bash "${GLCHAT_ETC_DIR}/install.sh" --profile "$1" --reconfigure --yes +} + +cmd_security_check() { + bash "${GLCHAT_ETC_DIR}/security-check.sh" +} + +cmd_change_domain() { + [ "$#" -ge 1 ] || { + echo "использование: glchat change-domain " >&2 + exit 1 + } + GLCHAT_ETC_DIR="$GLCHAT_ETC_DIR" bash "${GLCHAT_ETC_DIR}/install.sh" --domain "$1" --reconfigure --yes +} + +cmd_uninstall() { + require_stack + local keep_data=0 + [ "${1:-}" = "--keep-data" ] && keep_data=1 + echo "Контейнеры glchat будут удалены. Данные: $([ "$keep_data" = 1 ] && echo 'сохраняются' || echo 'УДАЛЯЮТСЯ')" + read -r -p "Продолжить? (yes/no): " reply + [ "$reply" = "yes" ] || { + echo "отменено" + exit 0 + } + systemctl disable --now glchat.service 2>/dev/null || true + $COMPOSE --profile embedded-proxy down --remove-orphans || true + if [ "$keep_data" = 0 ]; then + rm -rf "${GLCHAT_ETC_DIR}/data" + fi + rm -f /etc/systemd/system/glchat.service + systemctl daemon-reload + echo "Стек удалён. Каталог ${GLCHAT_ETC_DIR} оставлен для ручной проверки." +} + +cmd_version() { + echo "glchat CLI 0.1.0 (phase 0)" + [ -f "$ENV_FILE" ] && grep -E '^(APP_VERSION|IMAGE_TAG|DOMAIN|PROFILE)=' "$ENV_FILE" || true +} + +cmd_reindex() { + echo "glchat reindex появится в фазе 2 (FTS5)." >&2 + exit 1 +} + +main() { + local cmd="${1:-help}" + shift || true + case "$cmd" in + status) cmd_status "$@" ;; + logs) cmd_logs "$@" ;; + restart) cmd_restart "$@" ;; + backup) cmd_backup "$@" ;; + restore) cmd_restore "$@" ;; + update) cmd_update "$@" ;; + reset-password | make-admin | remove-admin) + echo "glchat ${cmd}: команда появится в фазе 1 (аккаунты и инстанс)." >&2 + exit 1 + ;; + unban) cmd_unban "$@" ;; + fail2ban-status) cmd_fail2ban_status "$@" ;; + profile) cmd_profile "$@" ;; + security-check) cmd_security_check "$@" ;; + reindex) cmd_reindex "$@" ;; + change-domain) cmd_change_domain "$@" ;; + uninstall) cmd_uninstall "$@" ;; + version) cmd_version "$@" ;; + help | --help | -h) usage ;; + *) + echo "glchat: неизвестная команда '$cmd'" >&2 + usage >&2 + exit 1 + ;; + esac +} + +main "$@" diff --git a/deploy/install.sh b/deploy/install.sh new file mode 100755 index 0000000..513068e --- /dev/null +++ b/deploy/install.sh @@ -0,0 +1,1033 @@ +#!/usr/bin/env bash +# glchat installer — доводит чистый Ubuntu 24.04/26.04 LTS до работающего инстанса. +# Спецификация: AGENT.md §10. Общие функции: deploy/lib/common.sh. +set -euo pipefail + +SCRIPT_PATH="${BASH_SOURCE[0]:-}" +if [ -n "$SCRIPT_PATH" ] && [ -f "$SCRIPT_PATH" ]; then + DEPLOY_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)" + REPO_DIR="$(cd "${DEPLOY_DIR}/.." && pwd)" +else + DEPLOY_DIR="" + REPO_DIR="" +fi + +# Запуск через `curl | bash`: репозиторий распаковывается во временный каталог +# и установщик перезапускается уже из него. +if [ -z "$DEPLOY_DIR" ] || [ ! -f "${DEPLOY_DIR}/lib/common.sh" ]; then + if [ "${GLCHAT_BOOTSTRAPPED:-0}" = "1" ]; then + echo "install.sh: не удалось загрузить репозиторий glchat" >&2 + exit 1 + fi + REPO_URL="${GLCHAT_REPO_URL:-https://gt.mhspx.su/grendervill/glchat.git}" + TMP_REPO="$(mktemp -d /tmp/glchat-src.XXXXXX)" + echo "==> загружаем glchat из ${REPO_URL}" + if command -v git >/dev/null 2>&1; then + git clone --depth 1 "$REPO_URL" "$TMP_REPO" >/dev/null 2>&1 || { + echo "install.sh: не удалось клонировать ${REPO_URL}" >&2 + exit 1 + } + else + curl -fsSL "${REPO_URL%.git}/archive/main.tar.gz" | tar -xz -C "$TMP_REPO" --strip-components=1 || { + echo "install.sh: не удалось скачать архив репозитория" >&2 + exit 1 + } + fi + export GLCHAT_BOOTSTRAPPED=1 + exec bash "${TMP_REPO}/deploy/install.sh" "$@" +fi + +# shellcheck source=lib/common.sh +. "${DEPLOY_DIR}/lib/common.sh" + +# --- значения по умолчанию ------------------------------------------------ + +DOMAIN="${DOMAIN:-}" +ACME_EMAIL="${ACME_EMAIL:-}" +SSH_PORT="${SSH_PORT:-}" +HTTP_PORT="${HTTP_PORT:-80}" +HTTPS_PORT="${HTTPS_PORT:-443}" +APP_PORT="${APP_PORT:-8080}" +LIVEKIT_WS_PORT="${LIVEKIT_WS_PORT:-7880}" +LIVEKIT_TCP_PORT="${LIVEKIT_TCP_PORT:-7881}" +LIVEKIT_UDP_PORT="${LIVEKIT_UDP_PORT:-7882}" +TURN_UDP_PORT="${TURN_UDP_PORT:-3478}" +TURN_TLS_PORT="${TURN_TLS_PORT:-5349}" +PROXY_MODE="${PROXY_MODE:-embedded}" +PROFILE="${PROFILE:-auto}" +INSTANCE_NAME="${INSTANCE_NAME:-glchat}" +MAIN_GUILD_NAME="${MAIN_GUILD_NAME:-Главный сервер}" +ADMIN_EMAIL="${ADMIN_EMAIL:-}" +ADMIN_PASSWORD="${ADMIN_PASSWORD:-}" +REGISTRATION_ENABLED="${REGISTRATION_ENABLED:-true}" +TURN_ENABLED="${TURN_ENABLED:-true}" +SSH_HARDENING="${SSH_HARDENING:-true}" +FILES_SUBDOMAIN="${FILES_SUBDOMAIN:-true}" +ENABLE_FAIL2BAN="${ENABLE_FAIL2BAN:-true}" +ENABLE_UFW="${ENABLE_UFW:-true}" +ENABLE_NFTABLES="${ENABLE_NFTABLES:-true}" +ENABLE_SSH_HARDENING="${ENABLE_SSH_HARDENING:-true}" +APP_IMAGE="${APP_IMAGE:-glchat:local}" +IMAGE_TAG="${IMAGE_TAG:-v0.1.0}" +CADDY_IMAGE_TAG="${CADDY_IMAGE_TAG:-2.10-alpine}" +LIVEKIT_IMAGE_TAG="${LIVEKIT_IMAGE_TAG:-v1.9.7}" +MAX_UPLOAD_MB="${MAX_UPLOAD_MB:-}" +LOG_LEVEL="${LOG_LEVEL:-info}" +SKIP_TLS="${SKIP_TLS:-0}" +DRY_RUN="${DRY_RUN:-0}" +RECONFIGURE="${RECONFIGURE:-0}" +GLCHAT_FORCE_OS="${GLCHAT_FORCE_OS:-0}" +GLCHAT_ASSUME_YES="${GLCHAT_ASSUME_YES:-0}" + +TARGET_DIR="${GLCHAT_ETC_DIR:-/opt/glchat}" +if [ "$DRY_RUN" = "1" ] && [ -z "${GLCHAT_ETC_DIR:-}" ]; then + TARGET_DIR="$(mktemp -d /tmp/glchat-dry-run.XXXXXX)" +fi +GLCHAT_ETC_DIR="$TARGET_DIR" +GLCHAT_DATA_DIR="${TARGET_DIR}/data" +GLCHAT_INSTALL_LOG="${TARGET_DIR}/install.log" +GLCHAT_ENV_FILE="${TARGET_DIR}/.env" + +usage() { + cat <<'EOF' +install.sh — установка glchat (AGENT.md 10). + +Основное: + --domain домен инстанса (обязателен) + --email email для Let's Encrypt (обязателен, если TLS включён) + --admin логин инстанс-администратора + --password пароль инстанс-администратора (иначе — интерактивный ввод) + --instance-name название инстанса (по умолчанию glchat) + +Сеть: + --ssh-port SSH-порт (по умолчанию берётся из sshd_config) + --http-port HTTP (по умолчанию 80) + --https-port HTTPS (по умолчанию 443) + --app-port внутренний порт приложения (127.0.0.1, по умолчанию 8080) + --livekit-tcp LiveKit TCP (по умолчанию 7881) + --livekit-udp LiveKit UDP (по умолчанию 7882) + --turn-udp TURN UDP (по умолчанию 3478) + --turn-tls TURN TLS (по умолчанию 5349) + --external-proxy не занимать 80/443: отдать маршруты внешнему прокси (AGENT.md 10.13) + --no-files-subdomain не использовать files. (fallback /files/*) + --no-turn отключить встроенный TURN (UDP 7882 + TCP 7881 остаются) + +Ресурсы и безопасность: + --profile auto|lite|standard|plus|pro|custom|none (по умолчанию auto) + --max-upload переопределить лимит загрузки + --no-fail2ban не устанавливать/не настраивать fail2ban + --no-ufw не менять правила UFW/DOCKER-USER + --no-nftables не применять nftables-лимиты + --no-ssh-hardening не трогать sshd_config + --anti-bot включить PoW-анти-бот (фаза 5) + +Режимы запуска: + --dry-run показать план, ничего не менять в системе + --reconfigure перегенерировать конфиги, сохранив существующие секреты + --yes не задавать вопросов (для автоматизации) + --force-os продолжить на непроверенной версии ОС + --skip-tls не выпускать сертификат (для тестов в контейнере без домена) + --image использовать готовый образ вместо сборки из репозитория + -h, --help эта справка +EOF +} + +# --- разбор аргументов ---------------------------------------------------- + +parse_args() { + while [ "$#" -gt 0 ]; do + case "$1" in + --domain) DOMAIN="${2:?}"; shift 2 ;; + --email) ACME_EMAIL="${2:?}"; shift 2 ;; + --admin) ADMIN_EMAIL="${2:?}"; shift 2 ;; + --password) ADMIN_PASSWORD="${2:?}"; shift 2 ;; + --instance-name) INSTANCE_NAME="${2:?}"; shift 2 ;; + --main-guild) MAIN_GUILD_NAME="${2:?}"; shift 2 ;; + --ssh-port) SSH_PORT="${2:?}"; shift 2 ;; + --http-port) HTTP_PORT="${2:?}"; shift 2 ;; + --https-port) HTTPS_PORT="${2:?}"; shift 2 ;; + --app-port) APP_PORT="${2:?}"; shift 2 ;; + --livekit-tcp) LIVEKIT_TCP_PORT="${2:?}"; shift 2 ;; + --livekit-udp) LIVEKIT_UDP_PORT="${2:?}"; shift 2 ;; + --turn-udp) TURN_UDP_PORT="${2:?}"; shift 2 ;; + --turn-tls) TURN_TLS_PORT="${2:?}"; shift 2 ;; + --profile) PROFILE="${2:?}"; shift 2 ;; + --max-upload) MAX_UPLOAD_MB="${2:?}"; shift 2 ;; + --image) APP_IMAGE="${2:?}"; shift 2 ;; + --tag) IMAGE_TAG="${2:?}"; shift 2 ;; + --external-proxy) PROXY_MODE="external"; shift ;; + --no-files-subdomain) FILES_SUBDOMAIN="false"; shift ;; + --no-turn) TURN_ENABLED="false"; shift ;; + --no-fail2ban) ENABLE_FAIL2BAN="false"; shift ;; + --no-ufw) ENABLE_UFW="false"; shift ;; + --no-nftables) ENABLE_NFTABLES="false"; shift ;; + --no-ssh-hardening) ENABLE_SSH_HARDENING="false"; SSH_HARDENING="false"; shift ;; + --anti-bot) ANTI_BOT_ENABLED="true"; shift ;; + --registration-off) REGISTRATION_ENABLED="false"; shift ;; + --dry-run) DRY_RUN="1"; shift ;; + --reconfigure) RECONFIGURE="1"; shift ;; + --yes | -y) GLCHAT_ASSUME_YES="1"; shift ;; + --force-os) GLCHAT_FORCE_OS="1"; shift ;; + --skip-tls) SKIP_TLS="1"; shift ;; + -h | --help) usage; exit 0 ;; + *) die "неизвестный аргумент: $1 (см. --help)" ;; + esac + done +} + +interactive_defaults() { + if [ -z "$DOMAIN" ]; then + DOMAIN="$(prompt_value 'Домен инстанса (например, chat.example.com)')" + fi + [ -n "$DOMAIN" ] || die "домен обязателен: --domain " + + if [ -z "$ACME_EMAIL" ] && [ "$SKIP_TLS" != "1" ]; then + ACME_EMAIL="$(prompt_value 'Email для Let'"'"'s Encrypt' "admin@${DOMAIN}")" + fi + [ -n "$ACME_EMAIL" ] || ACME_EMAIL="admin@${DOMAIN}" + + if [ -z "$SSH_PORT" ]; then + SSH_PORT="$(detect_ssh_port)" + fi +} + +detect_ssh_port() { + local port="22" conf="/etc/ssh/sshd_config" + if [ -r "$conf" ]; then + local found + found="$(awk '/^[[:space:]]*Port[[:space:]]+[0-9]+/ {print $2; exit}' "$conf")" + [ -n "$found" ] && port="$found" + fi + if have ss; then + local listen + listen="$(ss -tlnp 2>/dev/null | awk '/sshd/ {print $4}' | head -1 | sed 's/.*://')" + if [ -n "$listen" ] && [ "$listen" != "$port" ]; then + warn "sshd слушает порт ${listen}, в конфиге указан ${port}; используем ${listen}" + port="$listen" + fi + fi + printf '%s' "$port" +} + +# --- подготовка ----------------------------------------------------------- + +validate_inputs() { + case "$PROFILE" in + auto | lite | standard | plus | pro | custom | none) ;; + *) die "неизвестный профиль: $PROFILE" ;; + esac + case "$PROXY_MODE" in + embedded | external) ;; + *) die "неизвестный режим прокси: $PROXY_MODE" ;; + esac + if [ "$PROXY_MODE" = "external" ] && [ "$SKIP_TLS" = "0" ]; then + warn "режим --external-proxy: TLS и сертификаты обслуживает внешний прокси" + fi + if [ -n "$ADMIN_PASSWORD" ] && [ "${#ADMIN_PASSWORD}" -lt 8 ]; then + die "пароль администратора короче 8 символов" + fi +} + +check_ports_free() { + local ports=() + if [ "$PROXY_MODE" = "embedded" ]; then + ports+=("$HTTP_PORT" "$HTTPS_PORT") + else + ports+=("$APP_PORT" "$LIVEKIT_WS_PORT") + fi + have ss || return 0 + local busy=0 + for p in "${ports[@]}"; do + if ss -tlnH "sport = :${p}" 2>/dev/null | grep -q .; then + if [ "$PROXY_MODE" = "embedded" ]; then + warn "порт ${p} занят: для встроенного прокси это блокирует установку (используйте --external-proxy)" + busy=1 + else + warn "порт ${p} занят на 127.0.0.1 — проверьте, не запущен ли уже glchat" + fi + fi + done + [ "$busy" = "0" ] || die "освободите порты или используйте --external-proxy" +} + +check_dns() { + local domain="$1" + have getent || return 0 + local resolved + resolved="$(getent hosts "$domain" | awk '{print $1}' | head -1 || true)" + if [ -z "$resolved" ]; then + warn "A-запись для ${domain} не найдена: Let's Encrypt не сможет выпустить сертификат" + return 0 + fi + local public_ip + public_ip="$(curl -fsS --max-time 8 https://api.ipify.org 2>/dev/null || true)" + if [ -n "$public_ip" ] && [ "$resolved" != "$public_ip" ]; then + warn "${domain} указывает на ${resolved}, внешний IP этого сервера ${public_ip}" + else + ok "DNS: ${domain} → ${resolved}" + fi +} + +print_plan() { + local tier_detected="$1" + printf '\n%s=== План установки ===%s\n' "$GLCHAT_BOLD" "$GLCHAT_RESET" + printf ' домен: %s\n' "$DOMAIN" + printf ' поддомен файлов: %s\n' "$( [ "$FILES_SUBDOMAIN" = "true" ] && echo "files.${DOMAIN}" || echo "нет (fallback /files/*)")" + printf ' режим прокси: %s\n' "$( [ "$PROXY_MODE" = "external" ] && echo 'внешний (80/443 не занимаем)' || echo 'встроенный Caddy')" + printf ' профиль: %s (авто-детект: %s)\n' "$PROFILE" "$tier_detected" + printf ' каталог: %s\n' "$TARGET_DIR" + printf ' образ: %s (%s)\n' "$APP_IMAGE" "$( [ "$APP_IMAGE" = "glchat:local" ] && echo 'сборка из репозитория' || echo 'готовый образ')" + printf ' TURN: %s\n' "$( [ "$TURN_ENABLED" = "true" ] && echo "включён (turn.${DOMAIN}, ${TURN_UDP_PORT}/udp, ${TURN_TLS_PORT}/tcp)" || echo выключен)" + printf ' fail2ban: %s\n' "$( [ "$ENABLE_FAIL2BAN" = "true" ] && echo 'sshd + glchat-auth + recidive' || echo выключен)" + printf ' UFW/DOCKER-USER: %s\n' "$( [ "$ENABLE_UFW" = "true" ] && echo 'настраиваются' || echo 'не трогаем')" + printf ' nftables-лимиты: %s\n' "$( [ "$ENABLE_NFTABLES" = "true" ] && echo 'применяются' || echo 'не трогаем')" + printf ' SSH hardening: %s\n' "$( [ "$ENABLE_SSH_HARDENING" = "true" ] && echo 'при наличии ключей' || echo выключен)" + printf '\n%s=== Порты ===%s\n' "$GLCHAT_BOLD" "$GLCHAT_RESET" + if [ "$PROXY_MODE" = "external" ]; then + printf ' %-18s %-10s %s\n' "app (API+web)" "127.0.0.1:${APP_PORT}" "внутренний, наружу не публикуется" + printf ' %-18s %-10s %s\n' "LiveKit WS" "127.0.0.1:${LIVEKIT_WS_PORT}" "внутренний" + printf ' %-18s %-10s %s\n' "80/443" "-" "обслуживает внешний прокси (не меняем)" + else + printf ' %-18s %-10s %s\n' "HTTP" "${HTTP_PORT}/tcp" "ACME + редирект на HTTPS" + printf ' %-18s %-10s %s\n' "HTTPS" "${HTTPS_PORT}/tcp" "всё приложение" + fi + printf ' %-18s %-10s %s\n' "LiveKit RTC" "${LIVEKIT_TCP_PORT}/tcp" "fallback-транспорт WebRTC" + printf ' %-18s %-10s %s\n' "LiveKit RTC" "${LIVEKIT_UDP_PORT}/udp" "медиатрафик" + if [ "$TURN_ENABLED" = "true" ]; then + printf ' %-18s %-10s %s\n' "TURN" "${TURN_UDP_PORT}/udp" "NAT traversal" + printf ' %-18s %-10s %s\n' "TURN/TLS" "${TURN_TLS_PORT}/tcp" "TURN через TLS" + fi + printf ' %-18s %-10s %s\n' "SSH" "${SSH_PORT}/tcp" "администрирование (сохраняем)" + printf '\n' +} + +# --- установка пакетов ---------------------------------------------------- + +install_packages() { + info "устанавливаем системные пакеты" + export DEBIAN_FRONTEND=noninteractive + if [ "$DRY_RUN" = "1" ]; then + log_file_only "DRY-RUN apt-get install: ca-certificates curl gnupg jq openssl sqlite3 age nftables fail2ban ufw chrony docker-ce docker-compose-plugin" + return 0 + fi + apt-get update -qq + apt-get install -y -qq \ + ca-certificates curl gnupg jq openssl sqlite3 age nftables ufw chrony \ + rsync gzip zstd tar >/dev/null + if [ "$ENABLE_FAIL2BAN" = "true" ]; then + apt-get install -y -qq fail2ban >/dev/null + fi + ok "пакеты установлены" +} + +install_docker() { + if have docker && docker compose version >/dev/null 2>&1; then + ok "Docker уже установлен: $(docker --version)" + return 0 + fi + info "устанавливаем Docker Engine из официального репозитория" + if [ "$DRY_RUN" = "1" ]; then + log_file_only "DRY-RUN: docker apt repo + install docker-ce docker-compose-plugin" + return 0 + fi + install -m 0755 -d /etc/apt/keyrings + if [ ! -f /etc/apt/keyrings/docker.gpg ]; then + curl -fsSL "https://download.docker.com/linux/ubuntu/gpg" | + gpg --dearmor -o /etc/apt/keyrings/docker.gpg + chmod a+r /etc/apt/keyrings/docker.gpg + fi + # shellcheck disable=SC1091 + . /etc/os-release + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu ${UBUNTU_CODENAME:-$VERSION_CODENAME} stable" \ + >/etc/apt/sources.list.d/docker.list + apt-get update -qq + apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin >/dev/null + systemctl enable --now docker + ok "Docker установлен: $(docker --version)" +} + +# --- секреты и конфиги ---------------------------------------------------- + +generate_secrets() { + info "секреты" + local env_existing=0 + [ -f "$GLCHAT_ENV_FILE" ] && env_existing=1 + + secret_or_new() { + local key="$1" generator="${2:-rand_hex}" + local current="" + if [ "$env_existing" = "1" ] && [ "$RECONFIGURE" = "1" ]; then + current="$(env_get "$key" || true)" + fi + if [ -n "$current" ]; then + printf '%s' "$current" + else + "$generator" 32 + fi + } + + SESSION_PEPPER="$(secret_or_new SESSION_PEPPER rand_hex)" + MASTER_KEY="$(secret_or_new MASTER_KEY rand_hex)" + TOTP_ENCRYPTION_KEY="$(secret_or_new TOTP_ENCRYPTION_KEY rand_hex)" + WEBHOOK_SECRET="$(secret_or_new WEBHOOK_SECRET rand_hex)" + LIVEKIT_API_KEY="$(secret_or_new LIVEKIT_API_KEY rand_hex)" + LIVEKIT_API_SECRET="$(secret_or_new LIVEKIT_API_SECRET rand_hex)" + + if [ "$DRY_RUN" = "1" ]; then + log_file_only "DRY-RUN: секреты сгенерированы в памяти (в файл не пишутся)" + ok "секреты подготовлены (dry-run)" + return 0 + fi + + mkdir -p "${GLCHAT_DATA_DIR}"/{db,files,backups} + chmod 700 "$GLCHAT_DATA_DIR" + + if [ "$env_existing" = "1" ] && [ "$RECONFIGURE" = "1" ] && [ -f "${GLCHAT_DATA_DIR}/backups/age-key.txt" ]; then + AGE_KEY_FILE="${GLCHAT_DATA_DIR}/backups/age-key.txt" + else + AGE_KEY_FILE="${GLCHAT_DATA_DIR}/backups/age-key.txt" + if have age-keygen; then + age-keygen -o "$AGE_KEY_FILE" >/dev/null 2>&1 + else + warn "age-keygen не найден: создаём ed25519-ключ через openssl" + openssl genpkey -algorithm ED25519 -out "$AGE_KEY_FILE" 2>/dev/null + fi + fi + chmod 600 "$AGE_KEY_FILE" + AGE_RECIPIENT="$(grep -o 'age1[0-9a-z]*' "$AGE_KEY_FILE" | head -1 || true)" + if [ -z "$AGE_RECIPIENT" ] && have age-keygen; then + AGE_RECIPIENT="$(age-keygen -y "$AGE_KEY_FILE" 2>/dev/null || true)" + fi + ok "age-ключ бэкапов: ${AGE_KEY_FILE}" +} + +write_env_file() { + info "пишем ${GLCHAT_ENV_FILE}" + if [ "$DRY_RUN" = "1" ]; then + log_file_only "DRY-RUN: .env не записывается" + return 0 + fi + local tmp + tmp="$(mktemp)" + cat >"$tmp" </etc/sysctl.d/99-glchat.conf </dev/null 2>&1 || warn "не удалось применить часть sysctl (проверьте вручную)" + ulimit_line="* soft nofile 65535 +* hard nofile 65535" + printf '%s\n' "$ulimit_line" >/etc/security/limits.d/99-glchat.conf + if [ "${PROFILE_SWAP_GB:-0}" -gt 0 ]; then + ensure_swap "$PROFILE_SWAP_GB" + fi + ok "профиль ${PROFILE_RESOLVED} применён" +} + +ensure_swap() { + local want_gb="$1" current_mb + current_mb="$(awk '/SwapTotal/ {printf "%d", $2/1024}' /proc/meminfo)" + if [ "$current_mb" -ge $((want_gb * 1024 - 256)) ]; then + ok "swap уже есть: ${current_mb} MB" + return 0 + fi + if ! have fallocate && ! have dd; then + warn "не удалось создать swap: нет fallocate/dd" + return 0 + fi + info "создаём swap ${want_gb} GB" + if have fallocate; then + fallocate -l "${want_gb}G" /swapfile 2>/dev/null || dd if=/dev/zero of=/swapfile bs=1M count=$((want_gb * 1024)) status=none + else + dd if=/dev/zero of=/swapfile bs=1M count=$((want_gb * 1024)) status=none + fi + chmod 600 /swapfile + mkswap /swapfile >/dev/null + swapon /swapfile + if ! grep -q '^/swapfile' /etc/fstab; then + printf '/swapfile none swap sw 0 0\n' >>/etc/fstab + fi + ok "swap ${want_gb} GB активирован" +} + +render_all_templates() { + info "генерируем конфиги в ${TARGET_DIR}" + if [ "$DRY_RUN" = "1" ]; then + log_file_only "DRY-RUN: рендер шаблонов compose/Caddy/LiveKit/fail2ban/nftables/systemd" + else + mkdir -p "${TARGET_DIR}"/{caddy/logs,caddy/data,caddy/config,livekit,fail2ban,nftables} + cp "${DEPLOY_DIR}/lib/common.sh" "${TARGET_DIR}/lib-common.sh" 2>/dev/null || true + cp "${DEPLOY_DIR}/"*.sh "${TARGET_DIR}/" 2>/dev/null || true + cp "${DEPLOY_DIR}/fail2ban/filter-glchat-auth.conf" "${TARGET_DIR}/fail2ban/" + fi + + local files_root + if [ "$FILES_SUBDOMAIN" = "true" ]; then + files_root="files.${DOMAIN}" + else + files_root="${DOMAIN}" + fi + FILES_DOMAIN_RESOLVED="$files_root" + + # В режиме внешнего прокси собственный Caddy не нужен вовсе: сервис + # выносится в несуществующий профиль, чтобы `docker compose up` его не поднимал. + CADDY_PROFILE="embedded-proxy" + if [ "$PROXY_MODE" = "external" ] || [ "$SKIP_TLS" = "1" ]; then + CADDY_PROFILE="disabled" + fi + LIVEKIT_PUBLISH="true" + [ "$SKIP_TLS" = "1" ] && LIVEKIT_PUBLISH="false" + + # Публикация портов LiveKit отключается в тестовом режиме (--skip-tls), + # когда установщик проверяется внутри контейнера. + local livekit_ports + if [ "$LIVEKIT_PUBLISH" = "true" ]; then + livekit_ports=" + - \"127.0.0.1:${LIVEKIT_WS_PORT}:7880\" + - \"${LIVEKIT_TCP_PORT}:7881\" + - \"${LIVEKIT_UDP_PORT}:7882/udp\"" + else + livekit_ports=" []" + fi + LIVEKIT_PORTS="$livekit_ports" + + render_template "${DEPLOY_DIR}/compose.yaml.tpl" "${TARGET_DIR}/compose.yaml" \ + "DOMAIN=${DOMAIN}" "FILES_DOMAIN=${FILES_DOMAIN_RESOLVED}" "INSTANCE_NAME=${INSTANCE_NAME}" \ + "PROFILE=${PROFILE_RESOLVED}" "APP_IMAGE=${APP_IMAGE}" "IMAGE_TAG=${IMAGE_TAG}" \ + "APP_PORT=${APP_PORT}" "LIVEKIT_WS_PORT=${LIVEKIT_WS_PORT}" \ + "LIVEKIT_TCP_PORT=${LIVEKIT_TCP_PORT}" "LIVEKIT_UDP_PORT=${LIVEKIT_UDP_PORT}" \ + "DATA_DIR=${GLCHAT_DATA_DIR}" "LOG_LEVEL=${LOG_LEVEL}" \ + "SQLITE_READ_POOL=${PROFILE_SQLITE_READ_POOL}" \ + "SQLITE_CACHE_SIZE=-${PROFILE_SQLITE_CACHE_MB}000" \ + "SQLITE_MMAP_SIZE=$((PROFILE_SQLITE_MMAP_MB * 1024 * 1024))" \ + "GOMAXPROCS=${PROFILE_GOMAXPROCS}" \ + "MAX_UPLOAD_SIZE=$((EFFECTIVE_UPLOAD_MB * 1024 * 1024))" \ + "APP_CPUS=${PROFILE_APP_CPUS}" "APP_MEMORY_LIMIT=${PROFILE_APP_LIMIT}" \ + "LIVEKIT_CPUS=${PROFILE_LIVEKIT_CPUS}" "LIVEKIT_MEMORY_LIMIT=${PROFILE_LIVEKIT_LIMIT}" \ + "CADDY_CPUS=1.0" "CADDY_MEMORY_LIMIT=256m" \ + "CADDY_IMAGE_TAG=${CADDY_IMAGE_TAG}" "LIVEKIT_IMAGE_TAG=${LIVEKIT_IMAGE_TAG}" \ + "CADDY_PROFILE=${CADDY_PROFILE}" "LIVEKIT_PUBLISH=${LIVEKIT_PUBLISH}" \ + "LIVEKIT_PORTS=${LIVEKIT_PORTS}" \ + "LIVEKIT_API_KEY=${LIVEKIT_API_KEY}" "LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET}" \ + "HTTP_PORT=${HTTP_PORT}" "HTTPS_PORT=${HTTPS_PORT}" + + if [ "$PROXY_MODE" = "external" ]; then + render_template "${DEPLOY_DIR}/caddy/external-proxy.conf.tpl" "${TARGET_DIR}/external-proxy.conf" \ + "DOMAIN=${DOMAIN}" "FILES_DOMAIN=${FILES_DOMAIN_RESOLVED}" "ACME_EMAIL=${ACME_EMAIL}" \ + "APP_PORT=${APP_PORT}" "LIVEKIT_WS_PORT=${LIVEKIT_WS_PORT}" + else + render_template "${DEPLOY_DIR}/caddy/Caddyfile.tpl" "${TARGET_DIR}/caddy/Caddyfile" \ + "DOMAIN=${DOMAIN}" "FILES_DOMAIN=${FILES_DOMAIN_RESOLVED}" "ACME_EMAIL=${ACME_EMAIL}" \ + "PROXY_MODE=${PROXY_MODE}" + fi + + local turn_external_tls="false" + [ "$PROXY_MODE" = "external" ] && turn_external_tls="true" + local disable_simulcast="true" + [ "${PROFILE_LIVEKIT_SIMULCAST:-0}" -gt 0 ] 2>/dev/null && disable_simulcast="false" + PROFILE_LIVEKIT_DISABLE_SIMULCAST="$disable_simulcast" + + local use_external_ip="true" + if [ "$PROXY_MODE" = "embedded" ]; then + use_external_ip="true" + fi + PROFILE_LIVEKIT_USE_EXTERNAL_IP="$use_external_ip" + TURN_EXTERNAL_TLS="$turn_external_tls" + + render_template "${DEPLOY_DIR}/livekit/livekit.yaml.tpl" "${TARGET_DIR}/livekit/livekit.yaml" \ + "LIVEKIT_WS_PORT=7880" "LIVEKIT_TCP_PORT=7881" "LIVEKIT_UDP_PORT=7882" \ + "LIVEKIT_USE_EXTERNAL_IP=${use_external_ip}" \ + "TURN_ENABLED=${TURN_ENABLED}" "TURN_DOMAIN=turn.${DOMAIN}" \ + "TURN_UDP_PORT=3478" "TURN_TLS_PORT=5349" "TURN_EXTERNAL_TLS=${turn_external_tls}" \ + "LIVEKIT_MAX_PARTICIPANTS=${PROFILE_LIVEKIT_MAX_PARTICIPANTS}" \ + "LIVEKIT_DISABLE_SIMULCAST=${disable_simulcast}" + + if [ "$ENABLE_FAIL2BAN" = "true" ]; then + render_template "${DEPLOY_DIR}/fail2ban/jail.local.tpl" "${TARGET_DIR}/fail2ban/jail.local" \ + "SSH_PORT=${SSH_PORT}" + fi + + if [ "$ENABLE_NFTABLES" = "true" ]; then + local web_ports="80, 443" + [ "$PROXY_MODE" = "external" ] && web_ports="80, 443" + render_template "${DEPLOY_DIR}/nftables/glchat.nft.tpl" "${TARGET_DIR}/nftables/glchat.nft" \ + "WEB_PORTS=${web_ports}" "CONNLIMIT=200" "CONN_RATE=50" "SYN_RATE=100" + fi + + render_template "${DEPLOY_DIR}/systemd/glchat.service.tpl" "${TARGET_DIR}/glchat.service" + + if [ "$DRY_RUN" = "1" ]; then + ok "шаблоны отрендерены в ${TARGET_DIR} (dry-run)" + ls -la "$TARGET_DIR" 2>/dev/null | sed -n '1,20p' | log_file_only + else + ok "конфиги сгенерированы" + fi +} + +install_cli_and_units() { + info "устанавливаем glchat CLI и systemd-юнит" + if [ "$DRY_RUN" = "1" ]; then + log_file_only "DRY-RUN: install -m 0755 deploy/glchat-cli.sh /usr/local/bin/glchat; systemctl enable glchat.service" + return 0 + fi + install -m 0755 "${TARGET_DIR}/glchat-cli.sh" /usr/local/bin/glchat + install -m 0644 "${TARGET_DIR}/glchat.service" /etc/systemd/system/glchat.service + systemctl daemon-reload + systemctl enable glchat.service >/dev/null 2>&1 || warn "не удалось включить glchat.service" + ok "glchat CLI установлен (/usr/local/bin/glchat)" +} + +# --- безопасность --------------------------------------------------------- + +configure_ufw() { + [ "$ENABLE_UFW" = "true" ] || { + warn "UFW не настраиваем (--no-ufw)" + return 0 + } + info "настраиваем UFW" + if [ "$DRY_RUN" = "1" ]; then + log_file_only "DRY-RUN: ufw default deny incoming; allow ${SSH_PORT}, ${LIVEKIT_TCP_PORT}/tcp, ${LIVEKIT_UDP_PORT}/udp, TURN" + return 0 + fi + ufw --force reset >/dev/null 2>&1 || true + ufw default deny incoming >/dev/null + ufw default allow outgoing >/dev/null + ufw allow "${SSH_PORT}/tcp" comment 'ssh' >/dev/null + if [ "$PROXY_MODE" = "embedded" ]; then + ufw allow "${HTTP_PORT}/tcp" comment 'http' >/dev/null + ufw allow "${HTTPS_PORT}/tcp" comment 'https' >/dev/null + fi + ufw allow "${LIVEKIT_TCP_PORT}/tcp" comment 'livekit-rtc-tcp' >/dev/null + ufw allow "${LIVEKIT_UDP_PORT}/udp" comment 'livekit-rtc-udp' >/dev/null + if [ "$TURN_ENABLED" = "true" ]; then + ufw allow "${TURN_UDP_PORT}/udp" comment 'turn' >/dev/null + ufw allow "${TURN_TLS_PORT}/tcp" comment 'turn-tls' >/dev/null + fi + ufw --force enable >/dev/null + ok "UFW активен: $(ufw status | head -1)" +} + +configure_docker_user_chain() { + [ "$ENABLE_UFW" = "true" ] || return 0 + info "правила DOCKER-USER (AGENT.md 10.4)" + if [ "$DRY_RUN" = "1" ]; then + log_file_only "DRY-RUN: DOCKER-USER: разрешаем трафик к app/livekit только c docker-подсетей и localhost" + return 0 + fi + if ! have iptables; then + apt-get install -y -qq iptables >/dev/null 2>&1 || true + fi + if ! iptables -nL DOCKER-USER >/dev/null 2>&1; then + warn "цепочка DOCKER-USER недоступна (Docker не запущен?) — правила будут применены службой glchat-docker-user" + return 0 + fi + apply_docker_user_rules + install -m 0755 "${TARGET_DIR}/ufw-docker.sh" /usr/local/sbin/glchat-docker-user + cat >/etc/systemd/system/glchat-docker-user.service <<'EOF' +[Unit] +Description=glchat: правила DOCKER-USER (не публиковать внутренние порты) +After=docker.service +Requires=docker.service + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/local/sbin/glchat-docker-user +ExecReload=/usr/local/sbin/glchat-docker-user + +[Install] +WantedBy=multi-user.target +EOF + systemctl daemon-reload + systemctl enable --now glchat-docker-user.service >/dev/null 2>&1 || warn "не удалось включить glchat-docker-user.service" + ok "DOCKER-USER настроен" +} + +configure_nftables() { + [ "$ENABLE_NFTABLES" = "true" ] || { + warn "nftables-лимиты не применяем (--no-nftables)" + return 0 + } + info "nftables-лимиты (connlimit/hashlimit)" + if [ "$DRY_RUN" = "1" ]; then + log_file_only "DRY-RUN: nft -f ${TARGET_DIR}/nftables/glchat.nft" + return 0 + fi + if ! have nft; then + warn "nft не найден — пропускаем" + return 0 + fi + mkdir -p /etc/nftables.d + install -m 0644 "${TARGET_DIR}/nftables/glchat.nft" /etc/nftables.d/glchat.nft + if nft -f /etc/nftables.d/glchat.nft 2>/dev/null; then + ok "правила nftables применены" + else + warn "не удалось применить nftables-правила (проверьте поддержку меток и цепочек)" + fi + cat >/etc/systemd/system/glchat-nftables.service <<'EOF' +[Unit] +Description=glchat: nftables-лимиты (connlimit/hashlimit/syn-flood) +DefaultDependencies=no +Before=network-pre.target +Wants=network-pre.target +After=systemd-modules-load.service + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/sbin/nft -f /etc/nftables.d/glchat.nft + +[Install] +WantedBy=multi-user.target +EOF + systemctl daemon-reload + systemctl enable glchat-nftables.service >/dev/null 2>&1 || warn "не удалось включить glchat-nftables.service" +} + +configure_fail2ban() { + [ "$ENABLE_FAIL2BAN" = "true" ] || { + warn "fail2ban не настраиваем (--no-fail2ban)" + return 0 + } + info "настраиваем fail2ban (AGENT.md 9.6)" + if [ "$DRY_RUN" = "1" ]; then + log_file_only "DRY-RUN: fail2ban jails sshd, glchat-auth, recidive" + return 0 + fi + if ! have fail2ban-client; then + apt-get install -y -qq fail2ban >/dev/null || { + warn "fail2ban не установлен" + return 0 + } + fi + install -d /etc/fail2ban/filter.d /etc/fail2ban/jail.d + install -m 0644 "${DEPLOY_DIR}/fail2ban/filter-glchat-auth.conf" /etc/fail2ban/filter.d/glchat-auth.conf + install -m 0644 "${TARGET_DIR}/fail2ban/jail.local" /etc/fail2ban/jail.d/glchat.local + touch "${TARGET_DIR}/caddy/logs/access.log" + chmod 0640 "${TARGET_DIR}/caddy/logs/access.log" + chown root:adm "${TARGET_DIR}/caddy/logs/access.log" 2>/dev/null || true + mkdir -p /var/lib/fail2ban-glchat + if [ -f /etc/fail2ban/fail2ban.conf ]; then + sed -i 's#^\(dbpurgeage.*\)#\1#' /etc/fail2ban/fail2ban.conf + fi + systemctl enable fail2ban >/dev/null 2>&1 || true + systemctl restart fail2ban >/dev/null 2>&1 || systemctl start fail2ban >/dev/null 2>&1 || true + sleep 1 + ok "fail2ban: $(fail2ban-client status 2>/dev/null | sed -n 's/.*Jail list:\s*//p' || echo 'статус недоступен')" +} + +configure_ssh_hardening() { + [ "$ENABLE_SSH_HARDENING" = "true" ] || { + warn "SSH hardening выключен" + return 0 + } + info "SSH hardening" + local auth_keys="" + for home in /root /home/*; do + if [ -s "${home}/.ssh/authorized_keys" ]; then + auth_keys="${home}/.ssh/authorized_keys" + break + fi + done + if [ -z "$auth_keys" ]; then + warn "authorized_keys не найдены: SSH hardening НЕ применяется (защита от локаута, AGENT.md 9.2)" + log_file_only "SSH hardening пропущен: нет authorized_keys" + return 0 + fi + if [ "$DRY_RUN" = "1" ]; then + log_file_only "DRY-RUN: sshd_config: PasswordAuthentication no, PermitRootLogin prohibit-password, MaxAuthTries 3" + return 0 + fi + local dropin="/etc/ssh/sshd_config.d/99-glchat.conf" + mkdir -p /etc/ssh/sshd_config.d + cat >"$dropin" <<'EOF' +# glchat: вход только по ключам (AGENT.md 9.2) +PasswordAuthentication no +KbdInteractiveAuthentication no +PermitRootLogin prohibit-password +MaxAuthTries 3 +EOF + if sshd -t 2>/dev/null; then + systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || warn "не удалось перезагрузить sshd" + ok "SSH hardening применён (ключи найдены: ${auth_keys})" + else + rm -f "$dropin" + warn "конфиг sshd не прошёл проверку: изменения отменены" + fi +} + +# --- запуск стека --------------------------------------------------------- + +build_or_pull_image() { + info "готовим образ приложения: ${APP_IMAGE}" + if [ "$DRY_RUN" = "1" ]; then + log_file_only "DRY-RUN: docker build -t ${APP_IMAGE} (или pull готового образа)" + return 0 + fi + if [ "$APP_IMAGE" != "glchat:local" ]; then + docker pull "$APP_IMAGE" || die "не удалось скачать образ ${APP_IMAGE}" + ok "образ скачан" + return 0 + fi + [ -n "$REPO_DIR" ] || die "для сборки образа нужен репозиторий (--image для готового образа)" + info "собираем образ из ${REPO_DIR} (может занять несколько минут)" + docker build -t "$APP_IMAGE" \ + --build-arg "VERSION=${IMAGE_TAG}" \ + --build-arg "COMMIT=$(git -C "$REPO_DIR" rev-parse --short HEAD 2>/dev/null || echo none)" \ + --build-arg "BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + "$REPO_DIR" || die "сборка образа не удалась" + ok "образ собран: $(docker images --format '{{.Repository}}:{{.Tag}} {{.Size}}' "$APP_IMAGE" | head -1)" +} + +start_stack() { + info "запускаем стек" + if [ "$DRY_RUN" = "1" ]; then + log_file_only "DRY-RUN: docker compose up -d --remove-orphans" + return 0 + fi + local compose_args=(-f "${TARGET_DIR}/compose.yaml") + [ "$PROXY_MODE" = "embedded" ] && compose_args+=(--profile embedded-proxy) + (cd "$TARGET_DIR" && docker compose "${compose_args[@]}" pull --quiet 2>/dev/null || true) + (cd "$TARGET_DIR" && docker compose "${compose_args[@]}" up -d --remove-orphans) || die "не удалось запустить стек" + info "ожидаем готовности приложения" + local waited=0 + until curl -fsS "http://127.0.0.1:${APP_PORT}/readyz" >/dev/null 2>&1; do + waited=$((waited + 2)) + if [ "$waited" -ge 60 ]; then + warn "приложение не ответило за 60 с — смотрите: glchat logs app" + break + fi + sleep 2 + done + curl -fsS "http://127.0.0.1:${APP_PORT}/readyz" >/dev/null 2>&1 && ok "приложение готово" || warn "готовность не подтверждена" +} + +bootstrap_admin() { + info "bootstrap инстанса (админ, главный сервер, роли)" + if [ -z "$ADMIN_EMAIL" ]; then + warn "админ не задан (--admin): bootstrap будет выполнен вручную в фазе 1" + return 0 + fi + if [ "$DRY_RUN" = "1" ]; then + log_file_only "DRY-RUN: glchat bootstrap-admin --email " + return 0 + fi + if docker exec glchat-app /usr/local/bin/glchat bootstrap-admin \ + --email "$ADMIN_EMAIL" --password "$ADMIN_PASSWORD" \ + --instance-name "$INSTANCE_NAME" --main-guild "$MAIN_GUILD_NAME" >/dev/null 2>&1; then + ok "инстанс-админ создан" + else + warn "bootstrap-admin недоступен в этой версии образа (появится в фазе 1); данные сохранены в .env" + fi +} + +run_smoke() { + info "smoke-тест (AGENT.md 10.9)" + if [ "$DRY_RUN" = "1" ]; then + log_file_only "DRY-RUN: smoke.sh" + return 0 + fi + GLCHAT_ETC_DIR="$TARGET_DIR" bash "${TARGET_DIR}/smoke.sh" || warn "smoke-тест завершился с замечаниями" +} + +print_summary() { + local scheme="https" base="https://${DOMAIN}" + [ "$SKIP_TLS" = "1" ] && base="http://${DOMAIN}" + printf '\n%s=== Установка завершена ===%s\n' "$GLCHAT_BOLD" "$GLCHAT_RESET" + printf ' URL: %s\n' "$base" + printf ' админ: %s\n' "${ADMIN_EMAIL:-не задан}" + printf ' каталог: %s\n' "$TARGET_DIR" + printf ' журнал: %s\n' "$GLCHAT_INSTALL_LOG" + printf ' профиль: %s\n' "$PROFILE_RESOLVED" + if [ "$PROXY_MODE" = "external" ]; then + printf '\n%sВнешний прокси:%s добавьте блок из %s/external-proxy.conf в конфиг своего прокси\n' \ + "$GLCHAT_BOLD" "$GLCHAT_RESET" "$TARGET_DIR" + printf ' и перезагрузите его: caddy reload --config <ваш Caddyfile>\n' + fi + cat < | update + glchat profile [lite|standard|plus|pro|custom|none] + glchat security-check | fail2ban-status | unban + +Важно: + * Приватный age-ключ бэкапов: ${GLCHAT_DATA_DIR}/backups/age-key.txt — сохраните его ВНЕ сервера + и удалите с сервера: без него расшифровать бэкапы невозможно. + * Не забудьте про внешний firewall провайдера (если есть). +EOF + if [ "$SKIP_TLS" = "0" ] && [ "$PROXY_MODE" = "embedded" ]; then + printf ' * Сертификат Let%s Encrypt выпускается автоматически при первом запросе.\n' "'" + fi + printf '\n' +} + +# --- основной поток ------------------------------------------------------- + +main() { + parse_args "$@" + need_root + + info "glchat installer (${IMAGE_TAG}) — режим: $([ "$DRY_RUN" = 1 ] && echo dry-run || echo apply)" + + check_os + interactive_defaults + validate_inputs + check_resources + check_dns "$DOMAIN" || true + check_ports_free + + local detected_profile + detected_profile="$(detect_profile)" + if [ "$PROFILE" = "auto" ]; then + PROFILE_RESOLVED="$detected_profile" + else + PROFILE_RESOLVED="$PROFILE" + fi + profile_values "$PROFILE_RESOLVED" + + if [ -z "$MAX_UPLOAD_MB" ]; then + EFFECTIVE_UPLOAD_MB="$PROFILE_UPLOAD_MB" + else + EFFECTIVE_UPLOAD_MB="$MAX_UPLOAD_MB" + fi + PROFILE_APP_CPUS="$(nproc)" + PROFILE_LIVEKIT_CPUS="1.0" + PROFILE_SWAPPINESS=10 + + print_plan "$detected_profile" + if ! confirm "Применить план? (yes/no)" no; then + warn "установка отменена пользователем" + exit 0 + fi + + if [ "$DRY_RUN" != "1" ] && [ -z "$ADMIN_PASSWORD" ]; then + ADMIN_PASSWORD="$(prompt_secret 'Пароль инстанс-администратора (мин. 8 символов)')" + [ -n "$ADMIN_PASSWORD" ] || die "пароль не задан" + fi + + install_packages + install_docker + generate_secrets + write_env_file + render_all_templates + apply_profile + configure_ufw + configure_docker_user_chain + configure_nftables + configure_fail2ban + configure_ssh_hardening + install_cli_and_units + build_or_pull_image + start_stack + bootstrap_admin + run_smoke + print_summary + + if [ "$DRY_RUN" = "1" ]; then + printf '\n%sDry-run:%s файлы для проверки остались в %s (удалите вручную)\n\n' \ + "$GLCHAT_BOLD" "$GLCHAT_RESET" "$TARGET_DIR" + fi +} + +if [ "${GLCHAT_INSTALL_SH_SOURCED:-0}" != "1" ]; then + main "$@" +fi diff --git a/deploy/lib/common.sh b/deploy/lib/common.sh new file mode 100644 index 0000000..c5c1e04 --- /dev/null +++ b/deploy/lib/common.sh @@ -0,0 +1,260 @@ +#!/usr/bin/env bash +# Общие функции установщика и служебных скриптов glchat. +# Подключается через `source`; сам по себе ничего не выполняет. + +set -euo pipefail + +GLCHAT_ETC_DIR="${GLCHAT_ETC_DIR:-/opt/glchat}" +GLCHAT_DATA_DIR="${GLCHAT_DATA_DIR:-${GLCHAT_ETC_DIR}/data}" +GLCHAT_INSTALL_LOG="${GLCHAT_INSTALL_LOG:-${GLCHAT_ETC_DIR}/install.log}" +GLCHAT_ENV_FILE="${GLCHAT_ENV_FILE:-${GLCHAT_ETC_DIR}/.env}" + +GLCHAT_GREEN=$'\033[32m' +GLCHAT_YELLOW=$'\033[33m' +GLCHAT_RED=$'\033[31m' +GLCHAT_BOLD=$'\033[1m' +GLCHAT_RESET=$'\033[0m' + +log() { + local line + line="[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" + printf '%s\n' "$line" + if [ -n "${GLCHAT_INSTALL_LOG:-}" ] && [ -w "$(dirname "$GLCHAT_INSTALL_LOG")" ] 2>/dev/null; then + printf '%s\n' "$line" >>"$GLCHAT_INSTALL_LOG" + fi +} + +log_file_only() { + if [ -n "${GLCHAT_INSTALL_LOG:-}" ]; then + mkdir -p "$(dirname "$GLCHAT_INSTALL_LOG")" 2>/dev/null || true + printf '[%s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" >>"$GLCHAT_INSTALL_LOG" 2>/dev/null || true + fi +} + +info() { printf '%s==>%s %s\n' "$GLCHAT_BOLD" "$GLCHAT_RESET" "$*"; log "INFO $*"; } +ok() { printf '%s ok%s %s\n' "$GLCHAT_GREEN" "$GLCHAT_RESET" "$*"; log "OK $*"; } +warn() { printf '%swarn%s %s\n' "$GLCHAT_YELLOW" "$GLCHAT_RESET" "$*" >&2; log "WARN $*"; } +die() { + printf '%serr %s %s\n' "$GLCHAT_RED" "$GLCHAT_RESET" "$*" >&2 + log "ERROR $*" + exit 1 +} + +need_root() { + if [ "$(id -u)" -ne 0 ]; then + die "запустите установщик от root: sudo bash deploy/install.sh" + fi +} + +have() { command -v "$1" >/dev/null 2>&1; } + +require_cmd() { + for cmd in "$@"; do + have "$cmd" || die "не найдена обязательная команда: $cmd" + done +} + +confirm() { + local prompt="$1" default="${2:-no}" reply + if [ "${GLCHAT_ASSUME_YES:-0}" = "1" ]; then + return 0 + fi + if [ ! -t 0 ]; then + [ "$default" = "yes" ] && return 0 || return 1 + fi + read -r -p "$prompt " reply || true + reply="${reply:-$default}" + case "$reply" in + y | Y | yes | YES | да | Да) return 0 ;; + *) return 1 ;; + esac +} + +prompt_value() { + local prompt="$1" default="${2:-}" reply + if [ ! -t 0 ]; then + printf '%s' "$default" + return 0 + fi + if [ -n "$default" ]; then + read -r -p "$prompt [$default]: " reply || true + printf '%s' "${reply:-$default}" + else + read -r -p "$prompt: " reply || true + printf '%s' "$reply" + fi +} + +prompt_secret() { + local prompt="$1" first second + if [ ! -t 0 ]; then + die "$prompt: требуется интерактивный ввод или флаг --password" + fi + while true; do + read -r -s -p "$prompt: " first || true + printf '\n' >&2 + read -r -s -p "повторите пароль: " second || true + printf '\n' >&2 + if [ "$first" != "$second" ]; then + warn "пароли не совпадают, попробуйте снова" + continue + fi + if [ "${#first}" -lt 8 ]; then + warn "пароль короче 8 символов" + continue + fi + printf '%s' "$first" + return 0 + done +} + +rand_hex() { openssl rand -hex "${1:-32}"; } +rand_base64() { openssl rand -base64 "${1:-32}" | tr -d '\n=' | tr '+/' '-_'; } + +json_escape() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//\"/\\\"}" + printf '%s' "$value" +} + +# --- профили производительности (AGENT.md 10.10) ------------------------- + +profile_values() { + local tier="$1" + case "$tier" in + lite) + PROFILE_SQLITE_CACHE_MB=8 PROFILE_SQLITE_MMAP_MB=128 PROFILE_SQLITE_READ_POOL=2 + PROFILE_GOMAXPROCS=1 PROFILE_LRU_ENTRIES=2000 + PROFILE_LIVEKIT_MAX_PARTICIPANTS=10 PROFILE_LIVEKIT_MAX_VIDEO=4 PROFILE_LIVEKIT_SIMULCAST=0 + PROFILE_UPLOAD_MB=25 PROFILE_APP_LIMIT=384m PROFILE_LIVEKIT_LIMIT=640m + PROFILE_SWAP_GB=2 PROFILE_BACKUP_KEEP=7 PROFILE_SHARE_SOFT=1440p/30 PROFILE_SHARE_MAX=4K/60 + ;; + standard) + PROFILE_SQLITE_CACHE_MB=16 PROFILE_SQLITE_MMAP_MB=256 PROFILE_SQLITE_READ_POOL=4 + PROFILE_GOMAXPROCS=2 PROFILE_LRU_ENTRIES=10000 + PROFILE_LIVEKIT_MAX_PARTICIPANTS=25 PROFILE_LIVEKIT_MAX_VIDEO=8 PROFILE_LIVEKIT_SIMULCAST=2 + PROFILE_UPLOAD_MB=50 PROFILE_APP_LIMIT=768m PROFILE_LIVEKIT_LIMIT=1536m + PROFILE_SWAP_GB=2 PROFILE_BACKUP_KEEP=14 PROFILE_SHARE_SOFT=1440p/30 PROFILE_SHARE_MAX=4K/60 + ;; + plus) + PROFILE_SQLITE_CACHE_MB=32 PROFILE_SQLITE_MMAP_MB=512 PROFILE_SQLITE_READ_POOL=8 + PROFILE_GOMAXPROCS=4 PROFILE_LRU_ENTRIES=50000 + PROFILE_LIVEKIT_MAX_PARTICIPANTS=50 PROFILE_LIVEKIT_MAX_VIDEO=16 PROFILE_LIVEKIT_SIMULCAST=3 + PROFILE_UPLOAD_MB=100 PROFILE_APP_LIMIT=1536m PROFILE_LIVEKIT_LIMIT=3g + PROFILE_SWAP_GB=2 PROFILE_BACKUP_KEEP=30 PROFILE_SHARE_SOFT=4K/30 PROFILE_SHARE_MAX=4K/60 + ;; + pro) + PROFILE_SQLITE_CACHE_MB=64 PROFILE_SQLITE_MMAP_MB=1024 PROFILE_SQLITE_READ_POOL="$(nproc)" + PROFILE_GOMAXPROCS="$(nproc)" PROFILE_LRU_ENTRIES=200000 + PROFILE_LIVEKIT_MAX_PARTICIPANTS=100 PROFILE_LIVEKIT_MAX_VIDEO=32 PROFILE_LIVEKIT_SIMULCAST=3 + PROFILE_UPLOAD_MB=250 PROFILE_APP_LIMIT=4g PROFILE_LIVEKIT_LIMIT=6g + PROFILE_SWAP_GB=0 PROFILE_BACKUP_KEEP=60 PROFILE_SHARE_SOFT=4K/60 PROFILE_SHARE_MAX=4K/60 + ;; + none) + PROFILE_SQLITE_CACHE_MB=8 PROFILE_SQLITE_MMAP_MB=128 PROFILE_SQLITE_READ_POOL=2 + PROFILE_GOMAXPROCS=0 PROFILE_LRU_ENTRIES=2000 + PROFILE_LIVEKIT_MAX_PARTICIPANTS=10 PROFILE_LIVEKIT_MAX_VIDEO=4 PROFILE_LIVEKIT_SIMULCAST=0 + PROFILE_UPLOAD_MB=25 PROFILE_APP_LIMIT=512m PROFILE_LIVEKIT_LIMIT=768m + PROFILE_SWAP_GB=0 PROFILE_BACKUP_KEEP=7 PROFILE_SHARE_SOFT=1080p/30 PROFILE_SHARE_MAX=4K/60 + ;; + *) die "неизвестный профиль: $tier" ;; + esac +} + +detect_profile() { + local cpus ram_mb + cpus="$(nproc)" + ram_mb="$(awk '/MemTotal/ {printf "%d", $2/1024}' /proc/meminfo)" + if [ "$cpus" -le 1 ] && [ "$ram_mb" -lt 3072 ]; then + printf 'lite' + elif [ "$cpus" -le 2 ] || [ "$ram_mb" -lt 5120 ]; then + printf 'standard' + elif [ "$cpus" -le 4 ] || [ "$ram_mb" -lt 9216 ]; then + printf 'plus' + else + printf 'pro' + fi +} + +# --- рендеринг шаблонов --------------------------------------------------- + +render_template() { + local src="$1" dst="$2" + shift 2 + local content + content="$(cat "$src")" + while [ "$#" -gt 0 ]; do + local key="${1%%=*}" value="${1#*=}" + content="${content//"{{${key}}}"/$value}" + done + if printf '%s' "$content" | grep -q '{{[A-Z_]\{2,\}}}'; then + local leftover + leftover="$(printf '%s' "$content" | grep -o '{{[A-Z_]\{2,\}}}' | sort -u | tr '\n' ' ')" + warn "в шаблоне $src остались незаполненные плейсхолдеры: $leftover" + fi + printf '%s\n' "$content" >"$dst" +} + +env_get() { + local key="$1" file="${2:-$GLCHAT_ENV_FILE}" + [ -f "$file" ] || return 1 + grep -E "^${key}=" "$file" | tail -1 | cut -d= -f2- +} + +env_set() { + local key="$1" value="$2" file="${3:-$GLCHAT_ENV_FILE}" + if grep -qE "^${key}=" "$file" 2>/dev/null; then + local tmp + tmp="$(mktemp)" + awk -v k="$key" -v v="$value" 'BEGIN { FS = "=" } $1 == k { print k "=" v; next } { print }' "$file" >"$tmp" + cat "$tmp" >"$file" + rm -f "$tmp" + else + printf '%s=%s\n' "$key" "$value" >>"$file" + fi +} + +# --- общие проверки ------------------------------------------------------- + +check_os() { + [ -r /etc/os-release ] || die "не удалось прочитать /etc/os-release" + # shellcheck disable=SC1091 + . /etc/os-release + case "${ID:-}" in + ubuntu) + case "${VERSION_ID:-}" in + 24.04 | 26.04) ok "ОС: Ubuntu ${VERSION_ID} (${PRETTY_NAME})" ;; + *) + if [ "${GLCHAT_FORCE_OS:-0}" = "1" ]; then + warn "Ubuntu ${VERSION_ID:-?} не в списке проверенных; продолжаем из-за --force-os" + else + die "поддерживаются Ubuntu 24.04 LTS и 26.04 LTS, обнаружено: ${PRETTY_NAME:-unknown}. Используйте --force-os для продолжения" + fi + ;; + esac + ;; + debian) + warn "Debian ${VERSION_ID:-?}: установка не проверялась, продолжаем (пакеты Debian-совместимы)" + ;; + *) die "поддерживается Ubuntu 24.04/26.04 LTS, обнаружено: ${PRETTY_NAME:-unknown}" ;; + esac +} + +check_resources() { + local cpus ram_mb disk_mb + cpus="$(nproc)" + ram_mb="$(awk '/MemTotal/ {printf "%d", $2/1024}' /proc/meminfo)" + disk_mb="$(df -Pm / | awk 'NR==2 {print $4}')" + info "ресурсы: ${cpus} vCPU, ${ram_mb} MB RAM, ${disk_mb} MB свободно на /" + if [ "$ram_mb" -lt 1800 ]; then + warn "меньше 2 GB RAM: профиль lite может не уложиться в бюджет (AGENT.md 9.1)" + fi + if [ "$disk_mb" -lt 8000 ]; then + warn "меньше 8 GB свободного места: сборка образа и бэкапы могут не поместиться" + fi +} + +check_command() { + have "$1" && ok "найдено: $1" || warn "отсутствует: $1 (будет установлено)" +} diff --git a/deploy/livekit/livekit.yaml.tpl b/deploy/livekit/livekit.yaml.tpl new file mode 100644 index 0000000..aa8ff0b --- /dev/null +++ b/deploy/livekit/livekit.yaml.tpl @@ -0,0 +1,42 @@ +# Сгенерировано deploy/install.sh — не редактируйте вручную. +# Документация: https://docs.livekit.io/home/self-hosting/deployment/ +port: {{LIVEKIT_WS_PORT}} +bind_addresses: + - "0.0.0.0" + +rtc: + tcp_port: {{LIVEKIT_TCP_PORT}} + udp_port: {{LIVEKIT_UDP_PORT}} + use_external_ip: {{LIVEKIT_USE_EXTERNAL_IP}} + # Синхронизация портов с публичным адресом: контейнер публикует порты напрямую. + port_range_start: 0 + port_range_end: 0 + +turn: + enabled: {{TURN_ENABLED}} + domain: {{TURN_DOMAIN}} + udp_port: {{TURN_UDP_PORT}} + tls_port: {{TURN_TLS_PORT}} + external_tls: {{TURN_EXTERNAL_TLS}} + +room: + auto_create: true + empty_timeout: 300 + departure_timeout: 20 + max_participants: {{LIVEKIT_MAX_PARTICIPANTS}} + enable_remote_unmute: false + +audio: + # Opus с низким битрейтом: экономия канала при 1 vCPU (AGENT.md 9.1). + active_level: 35 + min_level: 25 + smooth_intervals: false + +video: + dynacast: true + disable_simulcast: {{LIVEKIT_DISABLE_SIMULCAST}} + +logging: + level: info + json: true + sample: true diff --git a/deploy/nftables/glchat.nft.tpl b/deploy/nftables/glchat.nft.tpl new file mode 100644 index 0000000..ab59551 --- /dev/null +++ b/deploy/nftables/glchat.nft.tpl @@ -0,0 +1,25 @@ +# Сгенерировано deploy/install.sh — не редактируйте вручную. +# Ограничения против флуда и перебора (AGENT.md 9.7). Один IP: не более {{CONNLIMIT}} соединений +# к 80/443 и не более {{CONN_RATE}} новых соединений в секунду. +table inet glchat { + set web_ports { + type inet_service + elements = { {{WEB_PORTS}} } + } + + chain input_guard { + type filter hook input priority -10; policy accept; + + # Уже установленные соединения не трогаем. + ct state established,related accept + + # Ограничение одновременных соединений с одного источника. + tcp dport @web_ports ct state new meter glchat_connlimit size 65535 { ip saddr limit over {{CONNLIMIT}} } drop + + # Ограничение скорости появления новых соединений. + tcp dport @web_ports ct state new meter glchat_rate size 65535 { ip saddr limit rate over {{CONN_RATE}}/second } drop + + # Защита от SYN-флуда. + tcp flags & (fin|syn|rst|ack) == syn limit rate over {{SYN_RATE}}/second drop + } +} diff --git a/deploy/restore.sh b/deploy/restore.sh new file mode 100755 index 0000000..29be87e --- /dev/null +++ b/deploy/restore.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# glchat: восстановление из бэкапа (AGENT.md 10.8). +set -euo pipefail + +GLCHAT_ETC_DIR="${GLCHAT_ETC_DIR:-/opt/glchat}" +GLCHAT_DATA_DIR="${GLCHAT_DATA_DIR:-${GLCHAT_ETC_DIR}/data}" +ENV_FILE="${GLCHAT_ETC_DIR}/.env" +COMPOSE_FILE="${GLCHAT_ETC_DIR}/compose.yaml" + +log() { printf '[restore] %s\n' "$*"; } +die() { + printf '[restore] ошибка: %s\n' "$*" >&2 + exit 1 +} + +ARCHIVE="${1:-}" +[ -n "$ARCHIVE" ] || die "использование: glchat restore <файл.tar.gz[.age]>" +[ -f "$ARCHIVE" ] || die "файл не найден: $ARCHIVE" +[ -f "$ENV_FILE" ] || die "не найден ${ENV_FILE}" +[ -f "$COMPOSE_FILE" ] || die "не найден ${COMPOSE_FILE}" + +# shellcheck disable=SC1090 +set -a && . "$ENV_FILE" && set +a + +WORK_DIR="$(mktemp -d)" +cleanup() { rm -rf "$WORK_DIR"; } +trap cleanup EXIT + +case "$ARCHIVE" in + *.age) + command -v age >/dev/null 2>&1 || die "age не установлен" + KEY_FILE="${AGE_KEY_FILE:-${GLCHAT_DATA_DIR}/backups/age-key.txt}" + [ -f "$KEY_FILE" ] || die "не найден приватный age-ключ (${KEY_FILE}); укажите AGE_KEY_FILE" + log "расшифровываем ${ARCHIVE}" + age -d -i "$KEY_FILE" -o "${WORK_DIR}/backup.tar.gz" "$ARCHIVE" || die "не удалось расшифровать бэкап" + ;; + *) cp "$ARCHIVE" "${WORK_DIR}/backup.tar.gz" ;; +esac + +log "распаковываем" +tar -xzf "${WORK_DIR}/backup.tar.gz" -C "$WORK_DIR" +[ -f "${WORK_DIR}/glchat.db" ] || die "в архиве нет glchat.db — это не бэкап glchat" + +log "текущее состояние" +(cd "$GLCHAT_ETC_DIR" && docker compose ps) || true + +log "создаём страховочный бэкап текущих данных" +if [ "${GLCHAT_SKIP_PREBACKUP:-0}" != "1" ]; then + GLCHAT_ETC_DIR="$GLCHAT_ETC_DIR" bash "${GLCHAT_ETC_DIR}/backup.sh" || die "страховочный бэкап не удался; восстановление остановлено" +fi + +printf 'Восстановление заменит текущую БД и файлы. Продолжить? (yes/no): ' +read -r reply +[ "$reply" = "yes" ] || { + log "отменено" + exit 0 +} + +log "останавливаем приложение" +(cd "$GLCHAT_ETC_DIR" && docker compose stop app) || true + +rollback_dir="${GLCHAT_DATA_DIR}/pre-restore-$(date -u +%Y%m%dT%H%M%SZ)" +mkdir -p "$rollback_dir" +cp -a "${GLCHAT_DATA_DIR}/db" "$rollback_dir/" 2>/dev/null || true +log "текущие данные скопированы в ${rollback_dir}" + +log "заменяем базу" +install -m 640 "${WORK_DIR}/glchat.db" "${GLCHAT_DATA_DIR}/db/glchat.db" +rm -f "${GLCHAT_DATA_DIR}/db/glchat.db-wal" "${GLCHAT_DATA_DIR}/db/glchat.db-shm" + +if [ -d "${WORK_DIR}/files" ]; then + log "заменяем файлы" + rm -rf "${GLCHAT_DATA_DIR}/files" + mkdir -p "${GLCHAT_DATA_DIR}/files" + cp -a "${WORK_DIR}/files/." "${GLCHAT_DATA_DIR}/files/" +fi + +log "запускаем приложение" +(cd "$GLCHAT_ETC_DIR" && docker compose start app) || (cd "$GLCHAT_ETC_DIR" && docker compose up -d app) + +for _ in $(seq 1 30); do + if curl -fsS "http://127.0.0.1:${APP_PORT:-8080}/readyz" >/dev/null 2>&1; then + log "приложение готово" + GLCHAT_ETC_DIR="$GLCHAT_ETC_DIR" bash "${GLCHAT_ETC_DIR}/smoke.sh" || true + log "восстановление завершено" + exit 0 + fi + sleep 2 +done + +die "приложение не поднялось после восстановления; откат: ${rollback_dir}" diff --git a/deploy/security-check.sh b/deploy/security-check.sh new file mode 100755 index 0000000..8644efd --- /dev/null +++ b/deploy/security-check.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# glchat: аудит безопасности инстанса (AGENT.md 9.2, 9.6, 9.7). +set -uo pipefail + +GLCHAT_ETC_DIR="${GLCHAT_ETC_DIR:-/opt/glchat}" +ENV_FILE="${GLCHAT_ETC_DIR}/.env" +ISSUES=0 + +say_ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; } +say_warn() { + printf ' \033[33m!\033[0m %s\n' "$*" + ISSUES=$((ISSUES + 1)) +} +say_info() { printf ' · %s\n' "$*"; } + +printf '\n=== glchat security-check ===\n' + +printf '\n[SSH]\n' +if sshd -T 2>/dev/null | grep -q '^passwordauthentication no'; then + say_ok "вход по паролю запрещён" +else + say_warn "PasswordAuthentication включён (AGENT.md 9.2 требует ключи)" +fi +if sshd -T 2>/dev/null | grep -qE '^permitrootlogin (no|prohibit-password)'; then + say_ok "root-логин ограничен" +else + say_warn "PermitRootLogin разрешён полностью" +fi +max_tries="$(sshd -T 2>/dev/null | awk '/^maxauthtries/ {print $2}')" +if [ -n "$max_tries" ] && [ "$max_tries" -le 3 ]; then + say_ok "MaxAuthTries=${max_tries}" +else + say_warn "MaxAuthTries=${max_tries:-unknown} (рекомендуется ≤ 3)" +fi + +printf '\n[UFW]\n' +if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q 'Status: active'; then + say_ok "UFW активен" + ufw status numbered 2>/dev/null | sed -n 's/^\[[ 0-9]*\]\s*/ · /p' | head -20 +else + say_warn "UFW не активен" +fi + +printf '\n[DOCKER-USER]\n' +if command -v iptables >/dev/null 2>&1 && iptables -nL DOCKER-USER >/dev/null 2>&1; then + if iptables -nL DOCKER-USER | grep -q glchat-block-internal; then + say_ok "внутренние порты приложения закрыты снаружи" + else + say_warn "нет правил glchat в DOCKER-USER" + fi +fi + +printf '\n[fail2ban]\n' +if command -v fail2ban-client >/dev/null 2>&1; then + jails="$(fail2ban-client status 2>/dev/null | sed -n 's/.*Jail list:\s*//p')" + for jail in sshd glchat-auth recidive; do + if printf '%s' "$jails" | grep -q "$jail"; then + banned="$(fail2ban-client status "$jail" 2>/dev/null | sed -n 's/.*Currently banned:\s*//p')" + say_ok "jail ${jail} (забанено: ${banned:-0})" + else + say_warn "jail ${jail} не активен" + fi + done +else + say_warn "fail2ban не установлен" +fi + +printf '\n[Слушающие порты]\n' +if command -v ss >/dev/null 2>&1; then + ss -tulnH | awk '{print $1, $5}' | sort -u | while read -r proto addr; do + case "$addr" in + 127.0.0.1:* | [::1]:*) say_info "${proto} ${addr} (локальный)" ;; + 0.0.0.0:* | [::]:* | *:*) say_info "${proto} ${addr}" ;; + esac + done +fi + +printf '\n[Секреты и права]\n' +if [ -f "$ENV_FILE" ]; then + perms="$(stat -c '%a' "$ENV_FILE")" + if [ "$perms" = "600" ]; then + say_ok ".env: права 600" + else + say_warn ".env: права ${perms} (нужно 600)" + fi + for key in SESSION_PEPPER MASTER_KEY LIVEKIT_API_SECRET TOTP_ENCRYPTION_KEY WEBHOOK_SECRET; do + value="$(grep -E "^${key}=" "$ENV_FILE" | cut -d= -f2- || true)" + if [ -z "$value" ]; then + say_warn "${key} не задан" + elif [ "${#value}" -lt 32 ]; then + say_warn "${key} короче 32 символов" + else + say_ok "${key} задан (${#value} символов)" + fi + done +else + say_warn "не найден ${ENV_FILE}" +fi + +age_key="${GLCHAT_ETC_DIR}/data/backups/age-key.txt" +if [ -f "$age_key" ]; then + say_warn "приватный age-ключ всё ещё на сервере (${age_key}): сохраните его вне сервера и удалите" +fi + +printf '\n[Обновления пакетов]\n' +if command -v apt-get >/dev/null 2>&1; then + upgradable="$(apt-get -s upgrade 2>/dev/null | grep -c '^Inst' || true)" + say_info "пакетов с доступными обновлениями: ${upgradable}" +fi +if [ -f /var/run/reboot-required ]; then + say_warn "требуется перезагрузка сервера (обновления ядра)" +fi + +printf '\n=== Замечаний: %d ===\n\n' "$ISSUES" +[ "$ISSUES" -eq 0 ] diff --git a/deploy/smoke.sh b/deploy/smoke.sh new file mode 100755 index 0000000..9af0a89 --- /dev/null +++ b/deploy/smoke.sh @@ -0,0 +1,199 @@ +#!/usr/bin/env bash +# glchat: smoke-тест после установки (AGENT.md 10.9). +# Фаза 0 проверяет фундамент: HTTPS-заглушка, meta, healthy, порты, fail2ban. +set -uo pipefail + +GLCHAT_ETC_DIR="${GLCHAT_ETC_DIR:-/opt/glchat}" +ENV_FILE="${GLCHAT_ETC_DIR}/.env" +PASSED=0 +FAILED=0 +SKIPPED=0 + +pass() { + printf ' \033[32m✓\033[0m %s\n' "$*" + PASSED=$((PASSED + 1)) +} +fail() { + printf ' \033[31m✗\033[0m %s\n' "$*" + FAILED=$((FAILED + 1)) +} +skip() { + printf ' \033[33m–\033[0m %s\n' "$*" + SKIPPED=$((SKIPPED + 1)) +} + +if [ -f "$ENV_FILE" ]; then + # shellcheck disable=SC1090 + set -a && . "$ENV_FILE" && set +a +fi +DOMAIN="${DOMAIN:-}" +APP_PORT="${APP_PORT:-8080}" +PROXY_MODE="${PROXY_MODE:-embedded}" +LIVEKIT_TCP_PORT="${LIVEKIT_TCP_PORT:-7881}" +LIVEKIT_UDP_PORT="${LIVEKIT_UDP_PORT:-7882}" +TURN_ENABLED="${TURN_ENABLED:-true}" +TURN_UDP_PORT="${TURN_UDP_PORT:-3478}" +TURN_TLS_PORT="${TURN_TLS_PORT:-5349}" +SKIP_TLS="${SKIP_TLS:-0}" +ENABLE_FAIL2BAN="${FAIL2BAN_ENABLED:-true}" + +printf '\n=== glchat smoke-тест ===\n' + +printf '\n[1] Приложение\n' +if curl -fsS "http://127.0.0.1:${APP_PORT}/healthz" >/dev/null; then + pass "/healthz отвечает 200" +else + fail "/healthz недоступен на 127.0.0.1:${APP_PORT}" +fi + +ready="$(curl -fsS "http://127.0.0.1:${APP_PORT}/readyz" 2>/dev/null || true)" +if printf '%s' "$ready" | grep -q '"status":"ready"'; then + pass "/readyz: база данных готова" +else + fail "/readyz: ${ready:-нет ответа}" +fi + +meta="$(curl -fsS "http://127.0.0.1:${APP_PORT}/api/v1/meta" 2>/dev/null || true)" +if printf '%s' "$meta" | grep -q '"api_version":"v1"'; then + version="$(printf '%s' "$meta" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')" + pass "/api/v1/meta отвечает, версия ${version}" +else + fail "/api/v1/meta недоступен" +fi + +printf '\n[2] Веб-клиент\n' +index="$(curl -fsS "http://127.0.0.1:${APP_PORT}/" 2>/dev/null || true)" +if printf '%s' "$index" | grep -qi '
/dev/null 2>&1; then + pass "https://${DOMAIN}/api/v1/meta отвечает через внешний прокси" + else + fail "https://${DOMAIN} недоступен: добавьте блок из ${GLCHAT_ETC_DIR}/external-proxy.conf во внешний прокси" + fi +elif [ "$SKIP_TLS" = "1" ]; then + skip "TLS отключён (--skip-tls): публичная проверка пропущена" +else + if curl -fsS --max-time 15 "https://${DOMAIN}/api/v1/meta" >/dev/null 2>&1; then + pass "https://${DOMAIN}/api/v1/meta отвечает (сертификат валиден)" + else + fail "https://${DOMAIN} недоступен (проверьте DNS и логи Caddy)" + fi +fi + +printf '\n[4] Внутренние порты закрыты снаружи\n' +public_ip="$(curl -fsS --max-time 5 https://api.ipify.org 2>/dev/null || true)" +if [ -z "$public_ip" ]; then + skip "внешний IP не определён: пропускаем проверку публикации портов" +else + for port in "$APP_PORT" 7880; do + if timeout 4 bash -c "/dev/null; then + fail "порт ${port} доступен снаружи (${public_ip})" + else + pass "порт ${port} недоступен снаружи" + fi + done +fi + +printf '\n[5] Открытые медиапорты\n' +if [ "$LIVEKIT_PUBLISH" = "false" ] || [ "${SKIP_TLS}" = "1" ]; then + skip "порты LiveKit не публиковались в этом режиме" +else + for spec in "${LIVEKIT_TCP_PORT}/tcp" "${LIVEKIT_UDP_PORT}/udp"; do + port="${spec%%/*}" + if ss -tulnH 2>/dev/null | grep -q ":${port} "; then + pass "слушает ${spec}" + else + fail "не слушает ${spec}" + fi + done + if [ "$TURN_ENABLED" = "true" ]; then + for spec in "${TURN_UDP_PORT}/udp" "${TURN_TLS_PORT}/tcp"; do + port="${spec%%/*}" + if ss -tulnH 2>/dev/null | grep -q ":${port} "; then + pass "слушает ${spec}" + else + skip "не слушает ${spec} (проверьте конфиг LiveKit/TURN)" + fi + done + fi +fi + +printf '\n[6] UFW и DOCKER-USER\n' +if command -v ufw >/dev/null 2>&1; then + if ufw status 2>/dev/null | grep -q 'Status: active'; then + pass "UFW активен" + if [ "$PROXY_MODE" = "external" ]; then + ufw status | grep -q "${LIVEKIT_TCP_PORT}/tcp" && pass "UFW: ${LIVEKIT_TCP_PORT}/tcp разрешён" || fail "UFW: нет правила для ${LIVEKIT_TCP_PORT}/tcp" + ufw status | grep -q "${LIVEKIT_UDP_PORT}/udp" && pass "UFW: ${LIVEKIT_UDP_PORT}/udp разрешён" || fail "UFW: нет правила для ${LIVEKIT_UDP_PORT}/udp" + fi + else + fail "UFW не активен" + fi +else + skip "ufw не установлен" +fi +if command -v iptables >/dev/null 2>&1 && iptables -nL DOCKER-USER >/dev/null 2>&1; then + if iptables -nL DOCKER-USER 2>/dev/null | grep -q 'glchat-block-internal'; then + pass "DOCKER-USER: внутренние порты заблокированы" + else + fail "DOCKER-USER: нет правил glchat" + fi +else + skip "цепочка DOCKER-USER недоступна" +fi + +printf '\n[7] fail2ban\n' +if [ "$ENABLE_FAIL2BAN" = "true" ] && command -v fail2ban-client >/dev/null 2>&1; then + jails="$(fail2ban-client status 2>/dev/null | sed -n 's/.*Jail list:\s*//p')" + for jail in sshd glchat-auth recidive; do + if printf '%s' "$jails" | grep -q "$jail"; then + pass "jail ${jail} активен" + else + fail "jail ${jail} не активен" + fi + done +else + skip "fail2ban не включён" +fi + +printf '\n[8] Автозапуск\n' +if systemctl is-enabled glchat.service >/dev/null 2>&1; then + pass "glchat.service включён" +else + fail "glchat.service не включён в автозапуск" +fi +if docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^glchat-app$'; then + restart_policy="$(docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' glchat-app 2>/dev/null)" + if [ "$restart_policy" = "unless-stopped" ]; then + pass "политика перезапуска контейнера: unless-stopped" + else + fail "политика перезапуска: ${restart_policy:-unknown}" + fi +else + fail "контейнер glchat-app не запущен" +fi + +printf '\n[9] Бэкап\n' +if GLCHAT_ETC_DIR="$GLCHAT_ETC_DIR" bash "${GLCHAT_ETC_DIR}/backup.sh" >/tmp/glchat-smoke-backup.log 2>&1; then + last_backup="$(ls -1t "${GLCHAT_ETC_DIR}/data/backups"/glchat-*.tar.gz* 2>/dev/null | head -1)" + pass "бэкап создан: $(basename "${last_backup:-unknown}")" + if [ "${last_backup}" != *.age ]; then + skip "бэкап без шифрования (AGE_RECIPIENT не задан)" + fi +else + fail "бэкап не создан (см. /tmp/glchat-smoke-backup.log)" +fi + +printf '\n=== Итог: %d ok, %d ошибок, %d пропущено ===\n\n' "$PASSED" "$FAILED" "$SKIPPED" +if [ "$FAILED" -gt 0 ]; then + exit 1 +fi +exit 0 diff --git a/deploy/systemd/glchat.service.tpl b/deploy/systemd/glchat.service.tpl new file mode 100644 index 0000000..068dab4 --- /dev/null +++ b/deploy/systemd/glchat.service.tpl @@ -0,0 +1,20 @@ +# Сгенерировано deploy/install.sh — не редактируйте вручную. +[Unit] +Description=glchat stack (docker compose) +Documentation=https://gt.mhspx.su/grendervill/glchat +Requires=docker.service +After=docker.service network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +WorkingDirectory=/opt/glchat +ExecStart=/usr/bin/docker compose -f /opt/glchat/compose.yaml up -d --remove-orphans +ExecStop=/usr/bin/docker compose -f /opt/glchat/compose.yaml stop +ExecReload=/usr/bin/docker compose -f /opt/glchat/compose.yaml restart +TimeoutStartSec=300 +TimeoutStopSec=120 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/ufw-docker.sh b/deploy/ufw-docker.sh new file mode 100755 index 0000000..f92cd30 --- /dev/null +++ b/deploy/ufw-docker.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# glchat: правила цепочки DOCKER-USER (AGENT.md 10.4). +# Docker публикует порты в обход UFW, поэтому внутренние порты приложения +# (127.0.0.1:8080, 127.0.0.1:7880) должны быть недоступны снаружи явно. +set -euo pipefail + +APP_PORT="${APP_PORT:-8080}" +LIVEKIT_WS_PORT="${LIVEKIT_WS_PORT:-7880}" + +if ! command -v iptables >/dev/null 2>&1; then + echo "glchat-docker-user: iptables не найден" >&2 + exit 0 +fi + +if ! iptables -nL DOCKER-USER >/dev/null 2>&1; then + echo "glchat-docker-user: цепочка DOCKER-USER недоступна (Docker не запущен?)" >&2 + exit 0 +fi + +# Идемпотентность: снимаем только свои правила. +while iptables -C DOCKER-USER -m comment --comment "glchat-managed" -j RETURN >/dev/null 2>&1; do + iptables -D DOCKER-USER -m comment --comment "glchat-managed" -j RETURN >/dev/null 2>&1 || break +done +while iptables -C DOCKER-USER -p tcp -m multiport --dports "${APP_PORT},${LIVEKIT_WS_PORT}" -m comment --comment "glchat-block-internal" -j DROP >/dev/null 2>&1; do + iptables -D DOCKER-USER -p tcp -m multiport --dports "${APP_PORT},${LIVEKIT_WS_PORT}" -m comment --comment "glchat-block-internal" -j DROP >/dev/null 2>&1 || break +done + +docker_subnets="$(docker network inspect bridge --format '{{range .IPAM.Config}}{{.Subnet}} {{end}}' 2>/dev/null || true)" +[ -n "$docker_subnets" ] || docker_subnets="172.16.0.0/12" + +# Сначала блокируем внутренние порты для всех, затем разрешаем docker-подсети и localhost. +iptables -I DOCKER-USER 1 -p tcp -m multiport --dports "${APP_PORT},${LIVEKIT_WS_PORT}" \ + -m comment --comment "glchat-block-internal" -j DROP +for subnet in $docker_subnets; do + iptables -I DOCKER-USER 1 -s "$subnet" -p tcp -m multiport --dports "${APP_PORT},${LIVEKIT_WS_PORT}" \ + -m comment --comment "glchat-managed" -j RETURN +done +iptables -I DOCKER-USER 1 -s 127.0.0.0/8 -p tcp -m multiport --dports "${APP_PORT},${LIVEKIT_WS_PORT}" \ + -m comment --comment "glchat-managed" -j RETURN +iptables -I DOCKER-USER 1 -i lo -p tcp -m multiport --dports "${APP_PORT},${LIVEKIT_WS_PORT}" \ + -m comment --comment "glchat-managed" -j RETURN + +echo "glchat-docker-user: правила применены (заблокированы ${APP_PORT}, ${LIVEKIT_WS_PORT} снаружи)" diff --git a/deploy/update.sh b/deploy/update.sh new file mode 100755 index 0000000..1c0da0c --- /dev/null +++ b/deploy/update.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# glchat: обновление стека (AGENT.md 9.3, 10.11). +set -euo pipefail + +GLCHAT_ETC_DIR="${GLCHAT_ETC_DIR:-/opt/glchat}" +ENV_FILE="${GLCHAT_ETC_DIR}/.env" +COMPOSE_FILE="${GLCHAT_ETC_DIR}/compose.yaml" +REPO_DIR_DEFAULT="${GLCHAT_REPO_DIR:-/opt/glchat-src}" + +log() { printf '[update] %s\n' "$*"; } +die() { + printf '[update] ошибка: %s\n' "$*" >&2 + exit 1 +} + +TARGET_VERSION="" +CHANNEL="" +while [ "$#" -gt 0 ]; do + case "$1" in + --version) TARGET_VERSION="${2:?}"; shift 2 ;; + --channel) CHANNEL="${2:?}"; shift 2 ;; + *) die "неизвестный аргумент: $1" ;; + esac +done + +[ -f "$ENV_FILE" ] || die "не найден ${ENV_FILE}" +[ -f "$COMPOSE_FILE" ] || die "не найден ${COMPOSE_FILE}" + +# shellcheck disable=SC1090 +set -a && . "$ENV_FILE" && set +a + +CURRENT="${APP_VERSION:-unknown}" +TARGET="${TARGET_VERSION:-$CURRENT}" +[ -n "$CHANNEL" ] && TARGET_CHANNEL="$CHANNEL" || TARGET_CHANNEL="${CHANNEL_NAME:-stable}" + +log "текущая версия: ${CURRENT} → целевая: ${TARGET} (канал ${TARGET_CHANNEL})" + +if [ "$CURRENT" = "$TARGET" ] && [ -z "${GLCHAT_FORCE_UPDATE:-}" ]; then + log "версия не изменилась; для принудительного обновления задайте GLCHAT_FORCE_UPDATE=1" +fi + +log "бэкап перед обновлением (обязательно)" +GLCHAT_ETC_DIR="$GLCHAT_ETC_DIR" bash "${GLCHAT_ETC_DIR}/backup.sh" || die "бэкап не удался; обновление остановлено" + +compose_args=(-f "$COMPOSE_FILE") +[ "${PROXY_MODE:-embedded}" = "embedded" ] && compose_args+=(--profile embedded-proxy) + +if [ "$APP_IMAGE" = "glchat:local" ] || [ -z "${APP_IMAGE:-}" ]; then + REPO_DIR="${GLCHAT_REPO_DIR:-$REPO_DIR_DEFAULT}" + if [ -d "${REPO_DIR}/.git" ]; then + log "обновляем исходники в ${REPO_DIR}" + git -C "$REPO_DIR" fetch --tags --depth 1 origin + if [ -n "$TARGET_VERSION" ]; then + git -C "$REPO_DIR" checkout "$TARGET_VERSION" + else + git -C "$REPO_DIR" pull --ff-only + fi + log "пересобираем образ" + docker build -t "${APP_IMAGE:-glchat:local}" \ + --build-arg "VERSION=${TARGET}" \ + --build-arg "COMMIT=$(git -C "$REPO_DIR" rev-parse --short HEAD)" \ + --build-arg "BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + "$REPO_DIR" || die "сборка не удалась" + else + die "каталог исходников ${REPO_DIR} не найден; выполните git clone или задайте GLCHAT_REPO_DIR" + fi +else + log "скачиваем образ ${APP_IMAGE}" + docker pull "$APP_IMAGE" || die "не удалось скачать образ" +fi + +log "применяем новую версию" +(cd "$GLCHAT_ETC_DIR" && docker compose "${compose_args[@]}" up -d --remove-orphans) || die "не удалось поднять стек" + +log "ожидаем готовности (миграции выполняются на старте приложения)" +for _ in $(seq 1 45); do + if curl -fsS "http://127.0.0.1:${APP_PORT:-8080}/readyz" >/dev/null 2>&1; then + log "приложение готово" + break + fi + sleep 2 +done + +if ! curl -fsS "http://127.0.0.1:${APP_PORT:-8080}/readyz" >/dev/null 2>&1; then + die "приложение не поднялось; откат: glchat restore <бэкап>, затем docker compose up -d" +fi + +if command -v sed >/dev/null 2>&1; then + sed -i "s/^APP_VERSION=.*/APP_VERSION=${TARGET}/" "$ENV_FILE" + sed -i "s/^CHANNEL_NAME=.*/CHANNEL_NAME=${TARGET_CHANNEL}/" "$ENV_FILE" 2>/dev/null || printf 'CHANNEL_NAME=%s\n' "$TARGET_CHANNEL" >>"$ENV_FILE" +fi + +GLCHAT_ETC_DIR="$GLCHAT_ETC_DIR" bash "${GLCHAT_ETC_DIR}/smoke.sh" || log "smoke-тест с замечаниями" +log "обновление завершено: ${TARGET}" diff --git a/queries/.gitkeep b/queries/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scripts/gitleaks-scan.sh b/scripts/gitleaks-scan.sh new file mode 100755 index 0000000..8edafc4 --- /dev/null +++ b/scripts/gitleaks-scan.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Сканирование репозитория на секреты. Поддерживает gitleaks 8.19+ +# (`gitleaks dir`) и старые версии (`gitleaks detect`). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CONFIG="${ROOT}/.gitleaks.toml" + +if ! command -v gitleaks >/dev/null 2>&1; then + echo "gitleaks не установлен — пропускаем (make tools поставит его в .cache/bin)" + exit 0 +fi + +cd "$ROOT" + +if gitleaks dir --help >/dev/null 2>&1; then + # Кэши инструментов и собранные артефакты не являются частью исходников. + gitleaks dir . --no-banner --redact --config "$CONFIG" \ + --report-format sarif --report-path "${ROOT}/build/gitleaks.sarif" +else + gitleaks detect --no-banner --redact --config "$CONFIG" \ + --report-format sarif --report-path "${ROOT}/build/gitleaks.sarif" +fi + +echo "gitleaks: утечек не найдено (отчёт: build/gitleaks.sarif)" diff --git a/scripts/test-install.sh b/scripts/test-install.sh new file mode 100755 index 0000000..1e8711d --- /dev/null +++ b/scripts/test-install.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Прогон deploy/install.sh в чистом контейнере Ubuntu 24.04 (AGENT.md 11.3). +# Требуется Docker с поддержкой привилегированных контейнеров (systemd внутри). +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +IMAGE="${GLCHAT_TEST_IMAGE:-jrei/systemd-ubuntu:24.04}" +CONTAINER="${GLCHAT_TEST_CONTAINER:-glchat-install-test-$$}" + +log() { printf '\n=== %s ===\n' "$*"; } + +cleanup() { + docker rm -f "$CONTAINER" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +log "запуск ${IMAGE} как systemd-контейнера" +docker rm -f "$CONTAINER" >/dev/null 2>&1 || true +docker run -d --name "$CONTAINER" \ + --privileged \ + --cgroupns=host \ + --tmpfs /tmp:exec,size=2g --tmpfs /run --tmpfs /run/lock \ + -v /sys/fs/cgroup:/sys/fs/cgroup:rw \ + "$IMAGE" /sbin/init >/dev/null + +log "ожидание systemd" +for _ in $(seq 1 30); do + if docker exec "$CONTAINER" systemctl is-system-running 2>/dev/null | grep -qE 'running|degraded'; then + break + fi + sleep 1 +done +docker exec "$CONTAINER" systemctl is-system-running || true +docker exec "$CONTAINER" bash -lc '. /etc/os-release && echo "тестовое окружение: $PRETTY_NAME"' + +log "копируем репозиторий внутрь контейнера" +docker exec "$CONTAINER" mkdir -p /opt/glchat-src +docker cp "${REPO_DIR}/." "${CONTAINER}:/opt/glchat-src/" + +log "1/4 install.sh --dry-run" +docker exec "$CONTAINER" bash -lc ' + cd /opt/glchat-src && GLCHAT_ETC_DIR=/tmp/dryrun \ + bash deploy/install.sh --dry-run --domain gl.test.local --email admin@gl.test.local \ + --admin admin@gl.test.local --password "test-password-123" --yes +' + +log "2/4 install.sh (боевой прогон: TLS и публичные порты не трогаем)" +docker exec "$CONTAINER" bash -lc " + cd /opt/glchat-src && GLCHAT_ETC_DIR=/opt/glchat \ + bash deploy/install.sh --domain gl.test.local --email admin@gl.test.local \ + --admin admin@gl.test.local --password 'test-password-123' \ + --skip-tls --app-port 8080 --no-turn --yes +" + +log "3/4 идемпотентность: повторный запуск с --reconfigure" +docker exec "$CONTAINER" bash -lc " + cd /opt/glchat-src && GLCHAT_ETC_DIR=/opt/glchat \ + bash deploy/install.sh --domain gl.test.local --email admin@gl.test.local \ + --admin admin@gl.test.local --password 'test-password-123' \ + --skip-tls --app-port 8080 --no-turn --reconfigure --yes +" + +log "4/4 проверки состояния" +docker exec "$CONTAINER" bash -lc ' + set -e + echo "--- healthz"; curl -fsS http://127.0.0.1:8080/healthz; echo + echo "--- readyz"; curl -fsS http://127.0.0.1:8080/readyz; echo + echo "--- meta"; curl -fsS http://127.0.0.1:8080/api/v1/meta; echo + echo "--- index"; curl -fsS http://127.0.0.1:8080/ | head -c 120; echo + test -x /usr/local/bin/glchat && echo "CLI: ok" + test -f /opt/glchat/compose.yaml && echo "compose: ok" + test -f /opt/glchat/external-proxy.conf && echo "external-proxy fragment: ok" + stat -c "perms %a %n" /opt/glchat/.env + systemctl is-enabled glchat.service + glchat version +' + +log "ОК: прогон установщика в чистом Ubuntu 24.04 завершён"