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
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 и должна совпадать с этой.
+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
# ---------- web ----------
FROM node:24-bookworm-slim AS web
FROM node:24-bookworm-slim@sha256:0e0ff40c39bc087845bfb27465a0df4ea419520094bc35842ff83dd8cbe6f9b6 AS web
WORKDIR /src/web
COPY web/package.json web/package-lock.json ./
RUN npm ci --no-audit --no-fund
@@ -9,7 +12,7 @@ COPY web/ ./
RUN npm run build
# ---------- go ----------
FROM golang:1.27-bookworm AS gobuild
FROM golang:1.27-bookworm@sha256:69a7b9788769bec032d238959b61854e9ae87f57be9029ec04e9885fabf99195 AS gobuild
WORKDIR /src
ARG VERSION=dev
ARG COMMIT=none
@@ -36,7 +39,7 @@ RUN CC=musl-gcc CGO_ENABLED=1 go build \
# ---------- runtime ----------
# База alpine + статический бинарник: образ укладывается в бюджет §9.1
# (<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 \
&& 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
.PHONY: security
security: tools ## AppSec: govulncheck, gitleaks, trivy (по возможности)
security: tools ## AppSec: govulncheck, gitleaks, npm audit, trivy (по возможности)
@mkdir -p $(BUILD_DIR)
@echo "==> govulncheck"
@govulncheck ./cmd/... ./internal/... || { echo "govulncheck нашёл уязвимости"; exit 1; }
@echo "==> gitleaks"
@$(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"
@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: 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
secrets-scan: tools ## Только проверка на утечки секретов
@$(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}"
# 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() {
[ -f "$ENV_FILE" ] || return 0
@@ -56,9 +66,11 @@ cmd_status() {
echo "== compose =="
$COMPOSE ps
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')"
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 "version: ${version:-unknown}"
echo "readyz: ${health}"
+4 -2
View File
@@ -106,8 +106,10 @@ ENABLE_NFTABLES="${ENABLE_NFTABLES:-true}"
ENABLE_SSH_HARDENING="${ENABLE_SSH_HARDENING:-true}"
APP_IMAGE="${APP_IMAGE:-glchat:local}"
IMAGE_TAG="${IMAGE_TAG:-v0.1.0}"
CADDY_IMAGE_TAG="${CADDY_IMAGE_TAG:-2.10-alpine}"
LIVEKIT_IMAGE_TAG="${LIVEKIT_IMAGE_TAG:-v1.9.7}"
# Тег + digest: реестр может перезаписать тег, digest фиксирует содержимое
# (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:-}"
LOG_LEVEL="${LOG_LEVEL:-info}"
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"
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() {
printf '[restore] ошибка: %s\n' "$*" >&2
exit 1
@@ -79,7 +96,7 @@ 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
if curl -fsS "$(app_check_url "${APP_PORT:-8080}" "$(env_get GLCHAT_APP_BIND)")" >/dev/null 2>&1; then
log "приложение готово"
GLCHAT_ETC_DIR="$GLCHAT_ETC_DIR" bash "${GLCHAT_ETC_DIR}/smoke.sh" || true
log "восстановление завершено"
+15 -3
View File
@@ -7,6 +7,16 @@ ENV_FILE="${GLCHAT_ETC_DIR}/.env"
COMPOSE_FILE="${GLCHAT_ETC_DIR}/compose.yaml"
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' "$*"; }
die() {
printf '[update] ошибка: %s\n' "$*" >&2
@@ -72,16 +82,18 @@ fi
log "применяем новую версию"
(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
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 "приложение готово"
break
fi
sleep 2
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"
fi
+4
View File
@@ -63,6 +63,10 @@ func staticContentType(rel string) string {
return "text/css; charset=utf-8"
case ".svg":
return "image/svg+xml"
case ".txt":
// security.txt (RFC 9116): в минимальном alpine-окружении mime может
// не знать расширение и отдать октет-поток.
return "text/plain; charset=utf-8"
}
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 и должна совпадать с этой.