Compare commits

...

3 Commits

Author SHA1 Message Date
grendervill e97f260113 fix(ops): проверка готовности учитывает адрес привязки приложения
`glchat update` на стенде с внешним прокси всегда сообщал «приложение не
поднялось»: readyz проверялся на 127.0.0.1, а приложение слушает адрес в
WireGuard (GLCHAT_APP_BIND). Тот же дефект был в restore.sh и в `glchat status`
(health показывался как unreachable). Добавлен общий помощник app_check_url.
2026-09-22 20:49:13 +03:00
grendervill 6577c1fb39 build(security): образы пинятся по digest
AGENT.md 11.3 требует фиксировать образы по digest: тег в реестре может быть
перезаписан. Закреплены базовые образы Dockerfile (node, golang, alpine) и
образы установщика (caddy, livekit-server). Digest'ы проверены на стенде:
сборка образа и пересоздание LiveKit проходят, caddy тянется по digest.
2026-09-22 20:42:52 +03:00
grendervill c7446a5d4c feat(security): security.txt отдаётся, npm audit и SBOM в AppSec
- /.well-known/security.txt уходил в SPA-заглушку (200 text/html): добавлена
  копия в web/public и явный тип text/plain в статике (RFC 9116, AGENT.md 11.3);
- make security дополнен `npm audit --omit=dev --audit-level=high`;
- новая цель `make sbom` — CycloneDX-отчёт зависимостей (trivy, если есть).
2026-09-22 20:39:44 +03:00
9 changed files with 85 additions and 12 deletions
+3
View File
@@ -4,3 +4,6 @@ Expires: 2027-12-31T00:00:00.000Z
Preferred-Languages: ru, en Preferred-Languages: ru, en
Canonical: https://gl.mhspx.su/.well-known/security.txt Canonical: https://gl.mhspx.su/.well-known/security.txt
Policy: https://gl.mhspx.su/security Policy: https://gl.mhspx.su/security
# Этот файл отдаётся по адресу /.well-known/security.txt: копия для сборки
# лежит в web/public/.well-known/security.txt и должна совпадать с этой.
+6 -3
View File
@@ -1,7 +1,10 @@
# Образы пинятся по digest (AGENT.md 11.3): тег может быть перезаписан в
# реестре, digest — нет. Обновление: получить новый digest
# (`docker buildx imagetools inspect <образ>` или Docker Hub API) и заменить.
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# ---------- web ---------- # ---------- web ----------
FROM node:24-bookworm-slim AS web FROM node:24-bookworm-slim@sha256:0e0ff40c39bc087845bfb27465a0df4ea419520094bc35842ff83dd8cbe6f9b6 AS web
WORKDIR /src/web WORKDIR /src/web
COPY web/package.json web/package-lock.json ./ COPY web/package.json web/package-lock.json ./
RUN npm ci --no-audit --no-fund RUN npm ci --no-audit --no-fund
@@ -9,7 +12,7 @@ COPY web/ ./
RUN npm run build RUN npm run build
# ---------- go ---------- # ---------- go ----------
FROM golang:1.27-bookworm AS gobuild FROM golang:1.27-bookworm@sha256:69a7b9788769bec032d238959b61854e9ae87f57be9029ec04e9885fabf99195 AS gobuild
WORKDIR /src WORKDIR /src
ARG VERSION=dev ARG VERSION=dev
ARG COMMIT=none ARG COMMIT=none
@@ -36,7 +39,7 @@ RUN CC=musl-gcc CGO_ENABLED=1 go build \
# ---------- runtime ---------- # ---------- runtime ----------
# База alpine + статический бинарник: образ укладывается в бюджет §9.1 # База alpine + статический бинарник: образ укладывается в бюджет §9.1
# (<50 MB). Проверка: docs/DECISIONS.md, решение D-007. # (<50 MB). Проверка: docs/DECISIONS.md, решение D-007.
FROM alpine:3.20 AS runtime FROM alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc AS runtime
RUN addgroup -S -g 10001 glchat \ RUN addgroup -S -g 10001 glchat \
&& adduser -S -u 10001 -G glchat -h /app -s /sbin/nologin glchat \ && adduser -S -u 10001 -G glchat -h /app -s /sbin/nologin glchat \
+12 -1
View File
@@ -142,18 +142,29 @@ generate: tools ## Проверить сгенерированные артеф
else echo "==> sqlc: запросов пока нет, пропускаем"; fi else echo "==> sqlc: запросов пока нет, пропускаем"; fi
.PHONY: security .PHONY: security
security: tools ## AppSec: govulncheck, gitleaks, trivy (по возможности) security: tools ## AppSec: govulncheck, gitleaks, npm audit, trivy (по возможности)
@mkdir -p $(BUILD_DIR) @mkdir -p $(BUILD_DIR)
@echo "==> govulncheck" @echo "==> govulncheck"
@govulncheck ./cmd/... ./internal/... || { echo "govulncheck нашёл уязвимости"; exit 1; } @govulncheck ./cmd/... ./internal/... || { echo "govulncheck нашёл уязвимости"; exit 1; }
@echo "==> gitleaks" @echo "==> gitleaks"
@$(ROOT)/scripts/gitleaks-scan.sh @$(ROOT)/scripts/gitleaks-scan.sh
@echo "==> npm audit (web, только production-зависимости)"
@cd $(WEB_DIR) && npm audit --omit=dev --audit-level=high || { echo "npm audit нашёл уязвимости"; exit 1; }
@echo "==> trivy filesystem scan" @echo "==> trivy filesystem scan"
@if command -v trivy >/dev/null 2>&1; then \ @if command -v trivy >/dev/null 2>&1; then \
trivy fs --quiet --scanners vuln,secret,misconfig --exit-code 1 \ trivy fs --quiet --scanners vuln,secret,misconfig --exit-code 1 \
--skip-dirs .cache --skip-dirs .git --skip-dirs web/node_modules .; \ --skip-dirs .cache --skip-dirs .git --skip-dirs web/node_modules .; \
else echo "trivy not installed — skipped"; fi else echo "trivy not installed — skipped"; fi
.PHONY: sbom
sbom: ## SBOM зависимостей в build/sbom.cdx.json (trivy, если установлен)
@mkdir -p $(BUILD_DIR)
@if command -v trivy >/dev/null 2>&1; then \
trivy fs --quiet --format cyclonedx --output $(BUILD_DIR)/sbom.cdx.json \
--skip-dirs .cache --skip-dirs web/node_modules . && \
echo "SBOM: $(BUILD_DIR)/sbom.cdx.json"; \
else echo "trivy не установлен — SBOM не собран (запустите на сервере сборки)"; fi
.PHONY: secrets-scan .PHONY: secrets-scan
secrets-scan: tools ## Только проверка на утечки секретов secrets-scan: tools ## Только проверка на утечки секретов
@$(ROOT)/scripts/gitleaks-scan.sh @$(ROOT)/scripts/gitleaks-scan.sh
+14 -2
View File
@@ -9,6 +9,16 @@ ENV_FILE="${GLCHAT_ETC_DIR}/.env"
COMPOSE="docker compose -f ${COMPOSE_FILE}" COMPOSE="docker compose -f ${COMPOSE_FILE}"
# app_check_url печатает адрес /readyz с учётом привязки приложения: во внешнем
# режиме оно слушает WireGuard-адрес, и проверка localhost всегда падала бы.
app_check_url() {
local port="$1" host="${2:-}"
case "$host" in
0.0.0.0 | "" | "::") host="127.0.0.1" ;;
esac
printf 'http://%s:%s/readyz' "$host" "$port"
}
# env_get читает значение из .env инстанса (пусто, если ключа нет). # env_get читает значение из .env инстанса (пусто, если ключа нет).
env_get() { env_get() {
[ -f "$ENV_FILE" ] || return 0 [ -f "$ENV_FILE" ] || return 0
@@ -56,9 +66,11 @@ cmd_status() {
echo "== compose ==" echo "== compose =="
$COMPOSE ps $COMPOSE ps
echo echo
meta="$(curl -fsS "http://127.0.0.1:8080/api/v1/meta" 2>/dev/null || true)" local check_url
check_url="$(app_check_url "${APP_PORT:-8080}" "$(env_get GLCHAT_APP_BIND)")"
meta="$(curl -fsS "${check_url%/readyz}/api/v1/meta" 2>/dev/null || true)"
version="$(printf '%s' "$meta" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')" 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"}')" health="$(curl -fsS "${check_url}" 2>/dev/null || echo '{"status":"unreachable"}')"
echo "== app ==" echo "== app =="
echo "version: ${version:-unknown}" echo "version: ${version:-unknown}"
echo "readyz: ${health}" echo "readyz: ${health}"
+4 -2
View File
@@ -106,8 +106,10 @@ ENABLE_NFTABLES="${ENABLE_NFTABLES:-true}"
ENABLE_SSH_HARDENING="${ENABLE_SSH_HARDENING:-true}" ENABLE_SSH_HARDENING="${ENABLE_SSH_HARDENING:-true}"
APP_IMAGE="${APP_IMAGE:-glchat:local}" APP_IMAGE="${APP_IMAGE:-glchat:local}"
IMAGE_TAG="${IMAGE_TAG:-v0.1.0}" IMAGE_TAG="${IMAGE_TAG:-v0.1.0}"
CADDY_IMAGE_TAG="${CADDY_IMAGE_TAG:-2.10-alpine}" # Тег + digest: реестр может перезаписать тег, digest фиксирует содержимое
LIVEKIT_IMAGE_TAG="${LIVEKIT_IMAGE_TAG:-v1.9.7}" # (AGENT.md 11.3). Обновление — заменить digest после проверки образа.
CADDY_IMAGE_TAG="${CADDY_IMAGE_TAG:-2.10-alpine@sha256:4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d}"
LIVEKIT_IMAGE_TAG="${LIVEKIT_IMAGE_TAG:-v1.9.7@sha256:ee33f59dc8d2fe176db7f2ef74e1b3ec754f29b50b0ae519b3fa893f7b015d07}"
MAX_UPLOAD_MB="${MAX_UPLOAD_MB:-}" MAX_UPLOAD_MB="${MAX_UPLOAD_MB:-}"
LOG_LEVEL="${LOG_LEVEL:-info}" LOG_LEVEL="${LOG_LEVEL:-info}"
SKIP_TLS="${SKIP_TLS:-0}" SKIP_TLS="${SKIP_TLS:-0}"
+18 -1
View File
@@ -8,6 +8,23 @@ ENV_FILE="${GLCHAT_ETC_DIR}/.env"
COMPOSE_FILE="${GLCHAT_ETC_DIR}/compose.yaml" COMPOSE_FILE="${GLCHAT_ETC_DIR}/compose.yaml"
log() { printf '[restore] %s\n' "$*"; } log() { printf '[restore] %s\n' "$*"; }
# env_get читает значение из .env инстанса (пусто, если ключа нет).
env_get() {
[ -f "$ENV_FILE" ] || return 0
sed -n "s/^$1=//p" "$ENV_FILE" | tail -1
}
# app_check_url печатает адрес /readyz с учётом привязки приложения: во внешнем
# режиме приложение слушает WireGuard-адрес, и проверка localhost не работает.
app_check_url() {
local port="$1" host="${2:-}"
case "$host" in
0.0.0.0 | "" | "::") host="127.0.0.1" ;;
esac
printf 'http://%s:%s/readyz' "$host" "$port"
}
die() { die() {
printf '[restore] ошибка: %s\n' "$*" >&2 printf '[restore] ошибка: %s\n' "$*" >&2
exit 1 exit 1
@@ -79,7 +96,7 @@ log "запускаем приложение"
(cd "$GLCHAT_ETC_DIR" && docker compose start app) || (cd "$GLCHAT_ETC_DIR" && docker compose up -d app) (cd "$GLCHAT_ETC_DIR" && docker compose start app) || (cd "$GLCHAT_ETC_DIR" && docker compose up -d app)
for _ in $(seq 1 30); do for _ in $(seq 1 30); do
if curl -fsS "http://127.0.0.1:${APP_PORT:-8080}/readyz" >/dev/null 2>&1; then if curl -fsS "$(app_check_url "${APP_PORT:-8080}" "$(env_get GLCHAT_APP_BIND)")" >/dev/null 2>&1; then
log "приложение готово" log "приложение готово"
GLCHAT_ETC_DIR="$GLCHAT_ETC_DIR" bash "${GLCHAT_ETC_DIR}/smoke.sh" || true GLCHAT_ETC_DIR="$GLCHAT_ETC_DIR" bash "${GLCHAT_ETC_DIR}/smoke.sh" || true
log "восстановление завершено" log "восстановление завершено"
+15 -3
View File
@@ -7,6 +7,16 @@ ENV_FILE="${GLCHAT_ETC_DIR}/.env"
COMPOSE_FILE="${GLCHAT_ETC_DIR}/compose.yaml" COMPOSE_FILE="${GLCHAT_ETC_DIR}/compose.yaml"
REPO_DIR_DEFAULT="${GLCHAT_REPO_DIR:-/opt/glchat-src}" REPO_DIR_DEFAULT="${GLCHAT_REPO_DIR:-/opt/glchat-src}"
# app_check_url печатает адрес /readyz с учётом привязки приложения: во внешнем
# режиме оно слушает WireGuard-адрес, и проверка localhost всегда падала бы.
app_check_url() {
local port="$1" host="${2:-}"
case "$host" in
0.0.0.0 | "" | "::") host="127.0.0.1" ;;
esac
printf 'http://%s:%s/readyz' "$host" "$port"
}
log() { printf '[update] %s\n' "$*"; } log() { printf '[update] %s\n' "$*"; }
die() { die() {
printf '[update] ошибка: %s\n' "$*" >&2 printf '[update] ошибка: %s\n' "$*" >&2
@@ -72,16 +82,18 @@ fi
log "применяем новую версию" log "применяем новую версию"
(cd "$GLCHAT_ETC_DIR" && docker compose "${compose_args[@]}" up -d --remove-orphans) || die "не удалось поднять стек" (cd "$GLCHAT_ETC_DIR" && docker compose "${compose_args[@]}" up -d --remove-orphans) || die "не удалось поднять стек"
log "ожидаем готовности (миграции выполняются на старте приложения)" READY_URL="$(app_check_url "${APP_PORT:-8080}" "${GLCHAT_APP_BIND:-}")"
log "ожидаем готовности ${READY_URL} (миграции выполняются на старте приложения)"
for _ in $(seq 1 45); do for _ in $(seq 1 45); do
if curl -fsS "http://127.0.0.1:${APP_PORT:-8080}/readyz" >/dev/null 2>&1; then if curl -fsS "$READY_URL" >/dev/null 2>&1; then
log "приложение готово" log "приложение готово"
break break
fi fi
sleep 2 sleep 2
done done
if ! curl -fsS "http://127.0.0.1:${APP_PORT:-8080}/readyz" >/dev/null 2>&1; then if ! curl -fsS "$READY_URL" >/dev/null 2>&1; then
die "приложение не поднялось; откат: glchat restore <бэкап>, затем docker compose up -d" die "приложение не поднялось; откат: glchat restore <бэкап>, затем docker compose up -d"
fi fi
+4
View File
@@ -63,6 +63,10 @@ func staticContentType(rel string) string {
return "text/css; charset=utf-8" return "text/css; charset=utf-8"
case ".svg": case ".svg":
return "image/svg+xml" return "image/svg+xml"
case ".txt":
// security.txt (RFC 9116): в минимальном alpine-окружении mime может
// не знать расширение и отдать октет-поток.
return "text/plain; charset=utf-8"
} }
return mime.TypeByExtension(filepath.Ext(rel)) return mime.TypeByExtension(filepath.Ext(rel))
} }
+9
View File
@@ -0,0 +1,9 @@
# Политика безопасности glchat (RFC 9116)
Contact: mailto:security@gl.mhspx.su
Expires: 2027-12-31T00:00:00.000Z
Preferred-Languages: ru, en
Canonical: https://gl.mhspx.su/.well-known/security.txt
Policy: https://gl.mhspx.su/security
# Этот файл отдаётся по адресу /.well-known/security.txt: копия для сборки
# лежит в web/public/.well-known/security.txt и должна совпадать с этой.