feat(deploy): установщик, docker-скелет, шаблоны Caddy/LiveKit и CLI
- deploy/install.sh: идемпотентная установка, режимы встроенного и внешнего прокси (§10.13), --dry-run/--skip-tls/--reconfigure, auto-детект профиля (§10.10) - deploy/compose.yaml.tpl: app, livekit, caddy с лимитами, healthcheck, ротацией логов, cap_drop и no-new-privileges (§4.3) - deploy/caddy: Caddyfile.tpl (встроенный) и external-proxy.conf.tpl (наш стенд) - deploy/livekit: конфиг с TURN, dynacast и лимитами комнат (§10.6) - deploy/backup.sh + restore.sh: sqlite .backup, tar, age-шифрование (§10.8) - deploy/update.sh: бэкап → pull/сборка → up → миграции → smoke (§9.3) - deploy/smoke.sh: проверки §10.9 (healthz/readyz/meta, порты, UFW, fail2ban, бэкап) - deploy/security-check.sh: аудит SSH, UFW, fail2ban, прав .env и ключей - deploy/ufw-docker.sh, nftables и fail2ban-конфиги (sshd, glchat-auth, recidive) - deploy/glchat-cli.sh: команды §10.7, systemd-юнит glchat.service - scripts/test-install.sh: прогон установщика в чистом Ubuntu 24.04 (systemd-контейнер) - Dockerfile: multi-stage (node → golang CGO → debian-slim), non-root, healthcheck - Makefile: lint/test/build/dev/generate/security/smoke и фиксация версий инструментов
This commit is contained in:
+54
@@ -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"]
|
||||||
@@ -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)
|
||||||
Executable
+83
@@ -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" <<EOF
|
||||||
|
glchat backup
|
||||||
|
created_at=${STAMP}
|
||||||
|
domain=${DOMAIN:-unknown}
|
||||||
|
version=${APP_VERSION:-unknown}
|
||||||
|
profile=${PROFILE:-unknown}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
log "упаковываем"
|
||||||
|
tar -czf "$ARCHIVE" -C "$WORK_DIR" .
|
||||||
|
|
||||||
|
if [ -n "$AGE_RECIPIENT" ]; then
|
||||||
|
command -v age >/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)"
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: конец блока --------------------------------------------------
|
||||||
@@ -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
|
||||||
@@ -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":"<HOST>".*$
|
||||||
|
|
||||||
|
ignoreregex =
|
||||||
@@ -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]
|
||||||
Executable
+189
@@ -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 <email> сброс пароля пользователя (фаза 1)
|
||||||
|
glchat make-admin <email> выдать права инстанс-админа (фаза 1)
|
||||||
|
glchat remove-admin <email> снять права инстанс-админа (фаза 1)
|
||||||
|
glchat unban <ip> снять бан fail2ban
|
||||||
|
glchat fail2ban-status статус jails и забаненных IP
|
||||||
|
glchat profile [tier] показать/применить профиль производительности
|
||||||
|
glchat security-check аудит: SSH, fail2ban, порты, лимиты
|
||||||
|
glchat reindex перестроить FTS-индекс (фаза 2)
|
||||||
|
glchat change-domain <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 <ip>" >&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 <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 "$@"
|
||||||
Executable
+1033
File diff suppressed because it is too large
Load Diff
@@ -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 (будет установлено)"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+91
@@ -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}"
|
||||||
Executable
+115
@@ -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 ]
|
||||||
Executable
+199
@@ -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 '<div id="root"'; then
|
||||||
|
pass "заглушка клиента отдаётся с /"
|
||||||
|
elif printf '%s' "$index" | grep -qi 'web client bundle is not present'; then
|
||||||
|
fail "в образе нет собранного веб-клиента"
|
||||||
|
else
|
||||||
|
fail "некорректный ответ на /"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '\n[3] Публичный доступ\n'
|
||||||
|
if [ "$PROXY_MODE" = "external" ]; then
|
||||||
|
if curl -fsS --max-time 10 "https://${DOMAIN}/api/v1/meta" >/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/tcp/${public_ip}/${port}" 2>/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
|
||||||
@@ -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
|
||||||
Executable
+43
@@ -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} снаружи)"
|
||||||
Executable
+94
@@ -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}"
|
||||||
Executable
+25
@@ -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)"
|
||||||
Executable
+78
@@ -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 завершён"
|
||||||
Reference in New Issue
Block a user