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:
2026-09-19 18:21:02 +03:00
parent 642d0f720e
commit 4bd1770cb5
22 changed files with 2808 additions and 0 deletions
+54
View File
@@ -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"]
+177
View File
@@ -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)
+83
View File
@@ -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)"
+64
View File
@@ -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
}
}
+67
View File
@@ -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: конец блока --------------------------------------------------
+115
View File
@@ -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
+7
View File
@@ -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 =
+27
View File
@@ -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]
+189
View File
@@ -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 "$@"
+1033
View File
File diff suppressed because it is too large Load Diff
+260
View File
@@ -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 (будет установлено)"
}
+42
View File
@@ -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
+25
View File
@@ -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
}
}
+91
View File
@@ -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}"
+115
View File
@@ -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 ]
+199
View File
@@ -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
+20
View File
@@ -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
+43
View File
@@ -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} снаружи)"
+94
View File
@@ -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}"
View File
+25
View File
@@ -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)"
+78
View File
@@ -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 завершён"