diff --git a/Dockerfile b/Dockerfile index 760af7a..f08ded8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,10 @@ WORKDIR /src ARG VERSION=dev ARG COMMIT=none ARG BUILD_DATE=unknown +# Сети без доступа к storage.googleapis.com не могут использовать proxy.golang.org. +ARG GOPROXY=https://proxy.golang.org,direct +ARG GOSUMDB=sum.golang.org +ENV GOPROXY=${GOPROXY} GOSUMDB=${GOSUMDB} GOTOOLCHAIN=local COPY go.mod go.sum ./ RUN go mod download COPY cmd/ ./cmd/ diff --git a/Makefile b/Makefile index 29d41b2..cfa896f 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,10 @@ export GOMODCACHE:= $(CACHE_DIR)/go-mod export GOPATH := $(CACHE_DIR)/gopath export GOTOOLCHAIN := local export GOLANGCI_LINT_CACHE := $(CACHE_DIR)/golangci-lint +# Сети без доступа к storage.googleapis.com (например, домашний сервер) не могут +# скачать модули через proxy.golang.org; goproxy.io работает как fallback. +export GOPROXY ?= https://proxy.golang.org,direct +export GOSUMDB ?= sum.golang.org export CGO_ENABLED ?= 1 GOFLAGS_LOCAL := -trimpath @@ -24,7 +28,10 @@ GO_LDFLAGS := -s -w \ -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 +# Инструмент берётся из .cache/bin; если он уже установлен в системе +# (PATH), повторная установка не нужна — это важно для CI и серверов, +# где сборка инструментов из исходников недоступна. +SYSTEM_TOOLS := golangci-lint gofumpt sqlc goose govulncheck gitleaks export PATH := $(BIN_DIR):$(PATH) GO_PACKAGES := ./cmd/... ./internal/... @@ -43,10 +50,15 @@ help: ## Показать список целей @printf '\nПрофиль: %s | Кэш: %s\n' "$${PROFILE:-auto}" "$(CACHE_DIR)" .PHONY: tools -tools: $(TOOLS) ## Установить зафиксированные dev-инструменты в .cache/bin - -$(CACHE_DIR): +tools: ## Установить dev-инструменты в .cache/bin (уже установленные в системе пропускаются) @mkdir -p $(CACHE_DIR) $(BIN_DIR) + @for tool in $(SYSTEM_TOOLS); do \ + if command -v $$tool >/dev/null 2>&1; then \ + printf ' ok %-16s %s\n' "$$tool" "$$(command -v $$tool)"; \ + else \ + $(MAKE) --no-print-directory $(BIN_DIR)/$$tool || exit 1; \ + fi; \ + done $(BIN_DIR)/golangci-lint: | $(CACHE_DIR) @echo "==> install golangci-lint v2.13.2" @@ -54,11 +66,11 @@ $(BIN_DIR)/golangci-lint: | $(CACHE_DIR) $(BIN_DIR)/gofumpt: | $(CACHE_DIR) @echo "==> install gofumpt" - @GOBIN=$(BIN_DIR) go install mvdan.cc/gofumpt@v0.11.0 + @GOBIN=$(BIN_DIR) go install mvdan.cc/gofumpt@v0.12.0 $(BIN_DIR)/sqlc: | $(CACHE_DIR) @echo "==> install sqlc" - @GOBIN=$(BIN_DIR) go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.30.0 + @GOBIN=$(BIN_DIR) go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1 $(BIN_DIR)/goose: | $(CACHE_DIR) @echo "==> install goose" @@ -66,11 +78,11 @@ $(BIN_DIR)/goose: | $(CACHE_DIR) $(BIN_DIR)/govulncheck: | $(CACHE_DIR) @echo "==> install govulncheck" - @GOBIN=$(BIN_DIR) go install golang.org/x/vuln/cmd/govulncheck@latest + @GOBIN=$(BIN_DIR) go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 $(BIN_DIR)/gitleaks: | $(CACHE_DIR) @echo "==> install gitleaks" - @GOBIN=$(BIN_DIR) go install github.com/zricethezav/gitleaks/v8@latest + @GOBIN=$(BIN_DIR) go install github.com/zricethezav/gitleaks/v8@v8.30.0 .PHONY: dev dev: ## Запустить сервер локально (данные в ./build/dev-data) @@ -166,6 +178,8 @@ docker-build: ## Собрать образ приложения --build-arg VERSION=$(VERSION) \ --build-arg COMMIT=$(COMMIT) \ --build-arg BUILD_DATE=$(BUILD_DATE) \ + --build-arg GOPROXY=$(GOPROXY) \ + --build-arg GOSUMDB=$(GOSUMDB) \ -t $(DOCKER_IMAGE):$(DOCKER_TAG) . .PHONY: install-dry-run diff --git a/deploy/caddy/Caddyfile.tpl b/deploy/caddy/Caddyfile.tpl index 4c9eb20..760d460 100644 --- a/deploy/caddy/Caddyfile.tpl +++ b/deploy/caddy/Caddyfile.tpl @@ -1,7 +1,7 @@ # Сгенерировано deploy/install.sh — не редактируйте вручную. -# Режим: {{PROXY_MODE}} | Домен: {{DOMAIN}} +# Режим: ${PROXY_MODE} | Домен: ${DOMAIN} { - email {{ACME_EMAIL}} + email ${ACME_EMAIL} admin off log { output file /var/log/caddy/access.log { @@ -22,7 +22,7 @@ } } -{{DOMAIN}} { +${DOMAIN} { encode zstd gzip import security_headers @@ -55,7 +55,7 @@ } } -{{FILES_DOMAIN}} { +${FILES_DOMAIN} { encode zstd gzip import security_headers handle { diff --git a/deploy/caddy/external-proxy.conf.tpl b/deploy/caddy/external-proxy.conf.tpl index 2d577df..2e25d84 100644 --- a/deploy/caddy/external-proxy.conf.tpl +++ b/deploy/caddy/external-proxy.conf.tpl @@ -3,7 +3,7 @@ # блок ниже нужно вставить в конфиг вашего прокси (например, Caddyfile) и перезагрузить его. # Приложение и LiveKit слушают только 127.0.0.1. { - email {{ACME_EMAIL}} + email ${ACME_EMAIL} } (glchat_security_headers) { @@ -17,7 +17,7 @@ } # --- glchat: начало блока ------------------------------------------------- -{{DOMAIN}} { +${DOMAIN} { encode zstd gzip import glchat_security_headers log { @@ -31,7 +31,7 @@ # WebSocket Gateway (фаза 1): длинные соединения без таймаутов. @gateway path /gateway handle @gateway { - reverse_proxy 127.0.0.1:{{APP_PORT}} { + reverse_proxy 127.0.0.1:${APP_PORT} { transport http { read_timeout 1h write_timeout 1h @@ -42,7 +42,7 @@ # LiveKit сигналинг (WebSocket). @rtc path /rtc* handle @rtc { - reverse_proxy 127.0.0.1:{{LIVEKIT_WS_PORT}} { + reverse_proxy 127.0.0.1:${LIVEKIT_WS_PORT} { transport http { read_timeout 1h write_timeout 1h @@ -52,16 +52,16 @@ # API и статика клиента. handle { - reverse_proxy 127.0.0.1:{{APP_PORT}} + reverse_proxy 127.0.0.1:${APP_PORT} } } # Пользовательский контент — отдельный origin (изоляция cookies/CSP). -{{FILES_DOMAIN}} { +${FILES_DOMAIN} { encode zstd gzip import glchat_security_headers handle { - reverse_proxy 127.0.0.1:{{APP_PORT}} + reverse_proxy 127.0.0.1:${APP_PORT} } } # --- glchat: конец блока -------------------------------------------------- diff --git a/deploy/compose.test.yaml.tpl b/deploy/compose.test.yaml.tpl new file mode 100644 index 0000000..dc134d0 --- /dev/null +++ b/deploy/compose.test.yaml.tpl @@ -0,0 +1,116 @@ +# Сгенерировано 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}" + # Порты LiveKit не публикуются: тестовый режим --skip-tls (проверка установщика в контейнере). + ports: [] + volumes: + - ./livekit/livekit.yaml:/etc/livekit.yaml:ro + logging: *default-logging + deploy: + resources: + limits: + cpus: "${LIVEKIT_CPUS}" + memory: ${LIVEKIT_MEMORY_LIMIT} + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:7880"] + interval: 30s + timeout: 5s + start_period: 15s + retries: 3 + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + cap_add: + - NET_BIND_SERVICE + + caddy: + image: caddy:${CADDY_IMAGE_TAG} + container_name: glchat-caddy + restart: unless-stopped + profiles: ["${CADDY_PROFILE}"] + ports: + - "${HTTP_PORT}:80" + - "${HTTPS_PORT}:443" + - "${HTTPS_PORT}:443/udp" + volumes: + - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro + - ./caddy/data:/data + - ./caddy/config:/config + - ./caddy/logs:/var/log/caddy + logging: *default-logging + deploy: + resources: + limits: + cpus: "${CADDY_CPUS}" + memory: ${CADDY_MEMORY_LIMIT} + healthcheck: + test: ["CMD", "caddy", "version"] + interval: 30s + timeout: 5s + start_period: 10s + retries: 3 + security_opt: + - no-new-privileges:true diff --git a/deploy/compose.yaml.tpl b/deploy/compose.yaml.tpl index 929d42b..e8fc8a2 100644 --- a/deploy/compose.yaml.tpl +++ b/deploy/compose.yaml.tpl @@ -1,5 +1,5 @@ # Сгенерировано deploy/install.sh — не редактируйте вручную, изменения перезапишутся. -# Шаблон: deploy/compose.yaml.tpl. Профиль: {{PROFILE}} +# Шаблон: deploy/compose.yaml.tpl. Профиль: ${PROFILE} name: glchat x-logging: &default-logging @@ -9,24 +9,24 @@ x-logging: &default-logging max-file: "3" x-app-env: &app-env - DOMAIN: "{{DOMAIN}}" - FILES_DOMAIN: "{{FILES_DOMAIN}}" - INSTANCE_NAME: "{{INSTANCE_NAME}}" + 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_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}}" + 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}} + image: ${APP_IMAGE} container_name: glchat-app restart: unless-stopped env_file: @@ -34,17 +34,17 @@ services: environment: <<: *app-env ports: - - "127.0.0.1:{{APP_PORT}}:8080" + - "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" + - "${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}} + cpus: "${APP_CPUS}" + memory: ${APP_MEMORY_LIMIT} healthcheck: test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/healthz"] interval: 30s @@ -57,21 +57,24 @@ services: - ALL livekit: - image: livekit/livekit-server:{{LIVEKIT_IMAGE_TAG}} + 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}} + LIVEKIT_KEYS: "${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}" + ports: + - "127.0.0.1:${LIVEKIT_WS_PORT}:7880" + - "${LIVEKIT_TCP_PORT}:7881" + - "${LIVEKIT_UDP_PORT}:7882/udp" volumes: - ./livekit/livekit.yaml:/etc/livekit.yaml:ro logging: *default-logging deploy: resources: limits: - cpus: "{{LIVEKIT_CPUS}}" - memory: {{LIVEKIT_MEMORY_LIMIT}} + cpus: "${LIVEKIT_CPUS}" + memory: ${LIVEKIT_MEMORY_LIMIT} healthcheck: test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:7880"] interval: 30s @@ -86,14 +89,14 @@ services: - NET_BIND_SERVICE caddy: - image: caddy:{{CADDY_IMAGE_TAG}} + image: caddy:${CADDY_IMAGE_TAG} container_name: glchat-caddy restart: unless-stopped - profiles: ["{{CADDY_PROFILE}}"] + profiles: ["${CADDY_PROFILE}"] ports: - - "{{HTTP_PORT}}:80" - - "{{HTTPS_PORT}}:443" - - "{{HTTPS_PORT}}:443/udp" + - "${HTTP_PORT}:80" + - "${HTTPS_PORT}:443" + - "${HTTPS_PORT}:443/udp" volumes: - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro - ./caddy/data:/data @@ -103,8 +106,8 @@ services: deploy: resources: limits: - cpus: "{{CADDY_CPUS}}" - memory: {{CADDY_MEMORY_LIMIT}} + cpus: "${CADDY_CPUS}" + memory: ${CADDY_MEMORY_LIMIT} healthcheck: test: ["CMD", "caddy", "version"] interval: 30s diff --git a/deploy/fail2ban/jail.local.tpl b/deploy/fail2ban/jail.local.tpl index 73c8a08..a209439 100644 --- a/deploy/fail2ban/jail.local.tpl +++ b/deploy/fail2ban/jail.local.tpl @@ -11,7 +11,7 @@ action = iptables-allports[name=glchat-auth] [sshd] enabled = true -port = {{SSH_PORT}} +port = ${SSH_PORT} maxretry = 5 findtime = 10m bantime = 1h diff --git a/deploy/install.sh b/deploy/install.sh index 513068e..73aea5b 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -193,6 +193,10 @@ interactive_defaults() { detect_ssh_port() { local port="22" conf="/etc/ssh/sshd_config" + if [ ! -r "$conf" ] && [ ! -d /etc/ssh ]; then + printf '%s' "$port" + return 0 + fi if [ -r "$conf" ]; then local found found="$(awk '/^[[:space:]]*Port[[:space:]]+[0-9]+/ {print $2; exit}' "$conf")" @@ -547,12 +551,14 @@ ensure_swap() { ok "swap ${want_gb} GB активирован" } + + render_all_templates() { info "генерируем конфиги в ${TARGET_DIR}" + mkdir -p "${TARGET_DIR}"/{caddy/logs,caddy/data,caddy/config,livekit,fail2ban,nftables} if [ "$DRY_RUN" = "1" ]; then log_file_only "DRY-RUN: рендер шаблонов compose/Caddy/LiveKit/fail2ban/nftables/systemd" else - mkdir -p "${TARGET_DIR}"/{caddy/logs,caddy/data,caddy/config,livekit,fail2ban,nftables} cp "${DEPLOY_DIR}/lib/common.sh" "${TARGET_DIR}/lib-common.sh" 2>/dev/null || true cp "${DEPLOY_DIR}/"*.sh "${TARGET_DIR}/" 2>/dev/null || true cp "${DEPLOY_DIR}/fail2ban/filter-glchat-auth.conf" "${TARGET_DIR}/fail2ban/" @@ -575,20 +581,14 @@ render_all_templates() { LIVEKIT_PUBLISH="true" [ "$SKIP_TLS" = "1" ] && LIVEKIT_PUBLISH="false" - # Публикация портов LiveKit отключается в тестовом режиме (--skip-tls), - # когда установщик проверяется внутри контейнера. - local livekit_ports - if [ "$LIVEKIT_PUBLISH" = "true" ]; then - livekit_ports=" - - \"127.0.0.1:${LIVEKIT_WS_PORT}:7880\" - - \"${LIVEKIT_TCP_PORT}:7881\" - - \"${LIVEKIT_UDP_PORT}:7882/udp\"" - else - livekit_ports=" []" + # Вариант compose выбирается по режиму: в тестовом прогоне (--skip-tls) + # порты LiveKit не публикуются, чтобы не занимать порты хост-машины. + local compose_template="${DEPLOY_DIR}/compose.yaml.tpl" + if [ "$LIVEKIT_PUBLISH" != "true" ]; then + compose_template="${DEPLOY_DIR}/compose.test.yaml.tpl" fi - LIVEKIT_PORTS="$livekit_ports" - render_template "${DEPLOY_DIR}/compose.yaml.tpl" "${TARGET_DIR}/compose.yaml" \ + render_template "${compose_template}" "${TARGET_DIR}/compose.yaml" \ "DOMAIN=${DOMAIN}" "FILES_DOMAIN=${FILES_DOMAIN_RESOLVED}" "INSTANCE_NAME=${INSTANCE_NAME}" \ "PROFILE=${PROFILE_RESOLVED}" "APP_IMAGE=${APP_IMAGE}" "IMAGE_TAG=${IMAGE_TAG}" \ "APP_PORT=${APP_PORT}" "LIVEKIT_WS_PORT=${LIVEKIT_WS_PORT}" \ @@ -604,7 +604,6 @@ render_all_templates() { "CADDY_CPUS=1.0" "CADDY_MEMORY_LIMIT=256m" \ "CADDY_IMAGE_TAG=${CADDY_IMAGE_TAG}" "LIVEKIT_IMAGE_TAG=${LIVEKIT_IMAGE_TAG}" \ "CADDY_PROFILE=${CADDY_PROFILE}" "LIVEKIT_PUBLISH=${LIVEKIT_PUBLISH}" \ - "LIVEKIT_PORTS=${LIVEKIT_PORTS}" \ "LIVEKIT_API_KEY=${LIVEKIT_API_KEY}" "LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET}" \ "HTTP_PORT=${HTTP_PORT}" "HTTPS_PORT=${HTTPS_PORT}" @@ -632,10 +631,15 @@ render_all_templates() { TURN_EXTERNAL_TLS="$turn_external_tls" render_template "${DEPLOY_DIR}/livekit/livekit.yaml.tpl" "${TARGET_DIR}/livekit/livekit.yaml" \ - "LIVEKIT_WS_PORT=7880" "LIVEKIT_TCP_PORT=7881" "LIVEKIT_UDP_PORT=7882" \ + "LIVEKIT_WS_PORT=${LIVEKIT_WS_PORT}" \ + "LIVEKIT_TCP_PORT=${LIVEKIT_TCP_PORT}" \ + "LIVEKIT_UDP_PORT=${LIVEKIT_UDP_PORT}" \ "LIVEKIT_USE_EXTERNAL_IP=${use_external_ip}" \ - "TURN_ENABLED=${TURN_ENABLED}" "TURN_DOMAIN=turn.${DOMAIN}" \ - "TURN_UDP_PORT=3478" "TURN_TLS_PORT=5349" "TURN_EXTERNAL_TLS=${turn_external_tls}" \ + "TURN_ENABLED=${TURN_ENABLED}" \ + "TURN_DOMAIN=turn.${DOMAIN}" \ + "TURN_UDP_PORT=${TURN_UDP_PORT}" \ + "TURN_TLS_PORT=${TURN_TLS_PORT}" \ + "TURN_EXTERNAL_TLS=${turn_external_tls}" \ "LIVEKIT_MAX_PARTICIPANTS=${PROFILE_LIVEKIT_MAX_PARTICIPANTS}" \ "LIVEKIT_DISABLE_SIMULCAST=${disable_simulcast}" @@ -872,6 +876,8 @@ build_or_pull_image() { --build-arg "VERSION=${IMAGE_TAG}" \ --build-arg "COMMIT=$(git -C "$REPO_DIR" rev-parse --short HEAD 2>/dev/null || echo none)" \ --build-arg "BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --build-arg "GOPROXY=${GOPROXY:-https://proxy.golang.org,direct}" \ + --build-arg "GOSUMDB=${GOSUMDB:-sum.golang.org}" \ "$REPO_DIR" || die "сборка образа не удалась" ok "образ собран: $(docker images --format '{{.Repository}}:{{.Tag}} {{.Size}}' "$APP_IMAGE" | head -1)" } @@ -955,7 +961,7 @@ print_summary() { * Не забудьте про внешний firewall провайдера (если есть). EOF if [ "$SKIP_TLS" = "0" ] && [ "$PROXY_MODE" = "embedded" ]; then - printf ' * Сертификат Let%s Encrypt выпускается автоматически при первом запросе.\n' "'" + printf " * Сертификат Let's Encrypt выпускается автоматически при первом запросе.\n" fi printf '\n' } @@ -989,7 +995,7 @@ main() { else EFFECTIVE_UPLOAD_MB="$MAX_UPLOAD_MB" fi - PROFILE_APP_CPUS="$(nproc)" + PROFILE_APP_CPUS="$(detect_cpus)" PROFILE_LIVEKIT_CPUS="1.0" PROFILE_SWAPPINESS=10 diff --git a/deploy/lib/common.sh b/deploy/lib/common.sh index c5c1e04..3bf7538 100644 --- a/deploy/lib/common.sh +++ b/deploy/lib/common.sh @@ -41,6 +41,11 @@ die() { } need_root() { + # В режиме --dry-run изменения не применяются, поэтому root не требуется: + # это позволяет проверять установщик на машине разработчика. + if [ "${DRY_RUN:-0}" = "1" ]; then + return 0 + fi if [ "$(id -u)" -ne 0 ]; then die "запустите установщик от root: sudo bash deploy/install.sh" fi @@ -145,8 +150,8 @@ profile_values() { 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_SQLITE_CACHE_MB=64 PROFILE_SQLITE_MMAP_MB=1024 PROFILE_SQLITE_READ_POOL="$(detect_cpus)" + PROFILE_GOMAXPROCS="$(detect_cpus)" 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 @@ -164,8 +169,12 @@ profile_values() { detect_profile() { local cpus ram_mb - cpus="$(nproc)" - ram_mb="$(awk '/MemTotal/ {printf "%d", $2/1024}' /proc/meminfo)" + cpus="$(detect_cpus)" + if [ -r /proc/meminfo ]; then + ram_mb="$(awk '/MemTotal/ {printf "%d", $2/1024}' /proc/meminfo)" + else + ram_mb=4096 + fi if [ "$cpus" -le 1 ] && [ "$ram_mb" -lt 3072 ]; then printf 'lite' elif [ "$cpus" -le 2 ] || [ "$ram_mb" -lt 5120 ]; then @@ -179,19 +188,45 @@ detect_profile() { # --- рендеринг шаблонов --------------------------------------------------- +# render_template src dst KEY=value... +# +# Значения передаются через окружение, а не подставляются в аргументы: это +# исключает разбор значений с пробелами и переносами строк и делает рендер +# предсказуемым (bash ${var//pat/repl} на многострочных значениях и variadic +# аргументах ведёт себя неочевидно). Если gettext-base (envsubst) недоступен, +# используется эквивалентная замена средствами bash. 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}" + # Нечётное число аргументов означает, что вызов неполный: сообщаем об этом, + # но продолжаем — значения, которые всё же переданы, будут применены, + # а незаполненные плейсхолдеры видны в предупреждении ниже. + if [ $(( $# % 2 )) -ne 0 ]; then + warn "render_template($(basename "$src")): нечётное число аргументов: $#" + fi + + # export "$pair" разбирает KEY=value сам; export "${pair}" трактует всю + # пару как имя переменной — из-за этого значения не попадали в окружение. + local pair + for pair in "$@"; do + export "$pair" done + + local content + if command -v envsubst >/dev/null 2>&1; then + content="$(envsubst <"$src")" + else + content="$(cat "$src")" + for pair in "$@"; do + local key="${pair%%=*}" value="${pair#*=}" + content="${content//"{{${key}}}"/$value}" + done + fi + 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" + warn "в шаблоне $(basename "$src") остались незаполненные плейсхолдеры: $leftover" fi printf '%s\n' "$content" >"$dst" } @@ -218,7 +253,14 @@ env_set() { # --- общие проверки ------------------------------------------------------- check_os() { - [ -r /etc/os-release ] || die "не удалось прочитать /etc/os-release" + if [ ! -r /etc/os-release ]; then + # macOS и другие не-Linux системы: допустимо только для --dry-run. + if [ "${DRY_RUN:-0}" = "1" ]; then + warn "не Linux (нет /etc/os-release): проверка ОС пропущена в режиме --dry-run" + return 0 + fi + die "не удалось прочитать /etc/os-release" + fi # shellcheck disable=SC1091 . /etc/os-release case "${ID:-}" in @@ -243,11 +285,15 @@ check_os() { check_resources() { local cpus ram_mb disk_mb - cpus="$(nproc)" - ram_mb="$(awk '/MemTotal/ {printf "%d", $2/1024}' /proc/meminfo)" + cpus="$(detect_cpus)" + if [ -r /proc/meminfo ]; then + ram_mb="$(awk '/MemTotal/ {printf "%d", $2/1024}' /proc/meminfo)" + else + ram_mb=0 + fi 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 + if [ "$ram_mb" -gt 0 ] && [ "$ram_mb" -lt 1800 ]; then warn "меньше 2 GB RAM: профиль lite может не уложиться в бюджет (AGENT.md 9.1)" fi if [ "$disk_mb" -lt 8000 ]; then @@ -255,6 +301,17 @@ check_resources() { fi } +detect_cpus() { + if command -v nproc >/dev/null 2>&1; then + nproc + return + fi + if command -v sysctl >/dev/null 2>&1; then + sysctl -n hw.ncpu 2>/dev/null && return + fi + printf '2' +} + check_command() { have "$1" && ok "найдено: $1" || warn "отсутствует: $1 (будет установлено)" } diff --git a/deploy/livekit/livekit.yaml.tpl b/deploy/livekit/livekit.yaml.tpl index aa8ff0b..ee873e0 100644 --- a/deploy/livekit/livekit.yaml.tpl +++ b/deploy/livekit/livekit.yaml.tpl @@ -1,29 +1,29 @@ # Сгенерировано deploy/install.sh — не редактируйте вручную. # Документация: https://docs.livekit.io/home/self-hosting/deployment/ -port: {{LIVEKIT_WS_PORT}} +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}} + 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}} + 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}} + max_participants: ${LIVEKIT_MAX_PARTICIPANTS} enable_remote_unmute: false audio: @@ -34,7 +34,7 @@ audio: video: dynacast: true - disable_simulcast: {{LIVEKIT_DISABLE_SIMULCAST}} + disable_simulcast: ${LIVEKIT_DISABLE_SIMULCAST} logging: level: info diff --git a/deploy/nftables/glchat.nft.tpl b/deploy/nftables/glchat.nft.tpl index ab59551..4011018 100644 --- a/deploy/nftables/glchat.nft.tpl +++ b/deploy/nftables/glchat.nft.tpl @@ -1,10 +1,10 @@ # Сгенерировано deploy/install.sh — не редактируйте вручную. -# Ограничения против флуда и перебора (AGENT.md 9.7). Один IP: не более {{CONNLIMIT}} соединений -# к 80/443 и не более {{CONN_RATE}} новых соединений в секунду. +# Ограничения против флуда и перебора (AGENT.md 9.7). Один IP: не более ${CONNLIMIT} соединений +# к 80/443 и не более ${CONN_RATE} новых соединений в секунду. table inet glchat { set web_ports { type inet_service - elements = { {{WEB_PORTS}} } + elements = { ${WEB_PORTS} } } chain input_guard { @@ -14,12 +14,12 @@ table inet glchat { 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_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 + 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 + tcp flags & (fin|syn|rst|ack) == syn limit rate over ${SYN_RATE}/second drop } } diff --git a/scripts/debug-install.sh b/scripts/debug-install.sh new file mode 100644 index 0000000..0be0a31 --- /dev/null +++ b/scripts/debug-install.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Диагностика зависания install.sh: трассировка пишется в файл, затем показывается хвост. +set -uo pipefail + +CONTAINER="glchat-dbg-$$" +IMAGE="jrei/systemd-ubuntu:24.04" + +cleanup() { docker rm -f "$CONTAINER" >/dev/null 2>&1 || true; } +trap cleanup EXIT + +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 + +for _ in $(seq 1 30); do + docker exec "$CONTAINER" systemctl is-system-running 2>/dev/null | grep -qE 'running|degraded' && break + sleep 1 +done + +docker exec "$CONTAINER" mkdir -p /opt/glchat-src +docker cp /opt/glchat-src/. "$CONTAINER:/opt/glchat-src/" + +echo "=== dry-run с трассировкой, лог в /tmp/trace.log ===" +timeout 60 docker exec -i "$CONTAINER" bash -lc ' + cd /opt/glchat-src && PS4="+ \${BASH_SOURCE##*/}:\${LINENO}: " bash -x deploy/install.sh \ + --dry-run --domain gl.test.local --email admin@gl.test.local \ + --admin admin@gl.test.local --password "test-password-123" --yes > /tmp/trace.log 2>&1 +' &2 + docker exec "$CONTAINER" bash -lc 'tail -40 /opt/glchat/install.log 2>/dev/null || true' || true + exit 1 + fi +} + log "1/4 install.sh --dry-run" -docker exec "$CONTAINER" bash -lc ' +timeout 600 docker exec -i "$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 -' +'