fix(mcp): подготовить доступ и отзыв разрешений для приёмки

This commit is contained in:
Denozordec committed 2026-11-03 03:05:24 +07:00
commit ef7bb79d4e
311 files changed
+73119

No files matched your search

+133
View File
@@ -0,0 +1,133 @@
# Auth Portal + Traefik in one Compose stack (production).
# Docs: docs/deploy-traefik.md
#
# On server:
# mkdir -p /opt/auth-portal/data
# chown -R 1000:1000 /opt/auth-portal/data # container runs as non-root "node"
# cp deploy/docker-compose.traefik.yml /opt/auth-portal/docker-compose.yml
# cp deploy/env.traefik.example /opt/auth-portal/.env # fill secrets
# docker login git.shx.one
# cd /opt/auth-portal && docker compose pull && docker compose up -d
services:
traefik:
image: traefik:${TRAEFIK_IMAGE_TAG:-v3.7}
container_name: auth-portal-traefik
restart: unless-stopped
security_opt:
- no-new-privileges:true
ports:
- "${TRAEFIK_HTTP_PORT:-80}:80"
- "${TRAEFIK_HTTPS_PORT:-443}:443"
environment:
CF_DNS_API_TOKEN: ${CF_DNS_API_TOKEN:?set CF_DNS_API_TOKEN in .env}
# Optional if DNS token lacks Zone:Read:
# CF_ZONE_API_TOKEN: ${CF_ZONE_API_TOKEN:-}
command:
- --log.level=${TRAEFIK_LOG_LEVEL:-INFO}
- --api.dashboard=false
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --providers.docker.network=auth-portal
- --ping=true
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --entrypoints.web.http.redirections.entrypoint.to=websecure
- --entrypoints.web.http.redirections.entrypoint.scheme=https
- --certificatesresolvers.letsencrypt.acme.email=${LETSENCRYPT_EMAIL:?set LETSENCRYPT_EMAIL in .env}
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
- --certificatesresolvers.letsencrypt.acme.dnschallenge=true
- --certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare
- --certificatesresolvers.letsencrypt.acme.dnschallenge.delaybeforecheck=15
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- traefik_letsencrypt:/letsencrypt
networks:
- auth-portal
healthcheck:
test: ["CMD", "traefik", "healthcheck", "--ping"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
app:
image: git.shx.one/denozord/auth-portal:${AUTH_IMAGE_TAG:-latest}
pull_policy: always
container_name: auth-portal
restart: unless-stopped
init: true
stop_grace_period: 15s
# Лимиты ресурсов: heap Node (NODE_OPTIONS в образе) рассчитан на 256 МиБ.
mem_limit: 256m
memswap_limit: 256m
cpus: "1.0"
pids_limit: 128
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
depends_on:
traefik:
condition: service_healthy
# No host ports — only Traefik publishes 80/443.
env_file:
- .env
environment:
DATABASE_URL: sqlite:/data/app.db
STATIC_DIR: /app/static
NODE_ENV: production
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in .env}
JWT_TTL_HOURS: ${JWT_TTL_HOURS:-1}
REFRESH_TTL_DAYS: ${REFRESH_TTL_DAYS:-14}
ISSUER: ${ISSUER:-https://auth.shnt.top}
OIDC_ISSUER: ${OIDC_ISSUER:-}
OIDC_RSA_PRIVATE_KEY: ${OIDC_RSA_PRIVATE_KEY:-}
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@shnt.top}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env}
ADMIN_NAME: ${ADMIN_NAME:-Admin}
RETURN_TO_ALLOWLIST: ${RETURN_TO_ALLOWLIST:-.shnt.top}
LOG_LEVEL: ${LOG_LEVEL:-info}
volumes:
- ./data:/data
networks:
- auth-portal
labels:
- traefik.enable=true
- traefik.docker.network=auth-portal
- traefik.http.routers.auth-portal.rule=Host(`${AUTH_DOMAIN:-auth.shnt.top}`)
- traefik.http.routers.auth-portal.entrypoints=websecure
- traefik.http.routers.auth-portal.tls=true
- traefik.http.routers.auth-portal.tls.certresolver=letsencrypt
- traefik.http.services.auth-portal.loadbalancer.server.port=8080
# Сжатие JSON/текста на стороне Traefik (ноль CPU/RAM в Node);
# предсжатые .br/.gz статики Traefik не пересжимает (есть Content-Encoding).
- traefik.http.middlewares.auth-portal-compress.compress=true
- traefik.http.routers.auth-portal.middlewares=auth-portal-compress
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/ready"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
traefik_letsencrypt:
name: auth_portal_traefik_letsencrypt
networks:
auth-portal:
name: auth-portal
+54
View File
@@ -0,0 +1,54 @@
# Docker-образы Auth Portal
## CI (Gitea Actions)
Сборка образов — **`docker buildx bake`** ([docker-bake.hcl](docker-bake.hcl)), не отдельные `docker build`.
**Publish:** push в `main` после quality gates — workflow [CD](../../.gitea/workflows/cd.yaml) job **publish**: semantic-release + зеркало base-образов + bake с `VERSION` из релиза.
Bake читает переменные из **окружения** (`REGISTRY`, `IMAGE_TAG`, `CACHE_REF_NODE`, `BASE_NODE`).
BuildKit кэширует pnpm store через `RUN --mount=type=cache`. На CI mounts живут, пока named builder `authportal` не удаляют (`cleanup: false`).
### Слои и runtime
| Образ | Runtime base | Заметка |
|-------|----------------|---------|
| `authportal`, `auth-portal` | `node:22-alpine` | один процесс: API + SPA + SQLite; `better-sqlite3` и `@node-rs/argon2` собираются в alpine на stage `deps`/`build` |
`python3` / `make` / `g++` только в `deps`/`build`. Context режется корневым [.dockerignore](../../.dockerignore).
Теги `authportal` и `auth-portal` — один и тот же образ (`auth-portal` — drop-in для текущего `docker pull` / Traefik compose).
### Кэш registry
- `git.shx.one/<owner>/authportal-buildcache:node-buildcache` — **запись** только из `node-build`
- `git.shx.one/<owner>/authportal-buildcache:base-node-22-alpine` — зеркало FROM с Docker Hub. Только `linux/amd64`; skip если тег уже в Gitea. Неуспешный copy не валит CD — bake берёт Docker Hub FROM.
`pull = false` в bake: не перекачивать FROM, если слой уже в builder. Не запускайте `docker system prune -a` на runner.
Параллельный `cache-to` в один ref ломает manifest (`content descriptor … not found`).
Если CI падает на «not found» после смены схемы кэша — один раз удалите теги `authportal-buildcache:*` в registry и пересоберите.
## Локальная сборка
Из корня репозитория:
```bash
cd deploy/docker
export REGISTRY=git.shx.one/<owner>
export IMAGE_TAG=latest
export SHORT_SHA=$(git rev-parse --short HEAD)
export VERSION=dev
export BUILD_TIME=
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl default
```
Проверка манифеста без сборки:
```bash
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl --print default
```
В `docker-bake.hcl`: `context = "../.."` резолвится **от cwd** (каталог `deploy/docker/`). `dockerfile` — путь от этого context (корень репо). Из корня репо не вызывать bake с `-f deploy/docker/docker-bake.hcl`: получится `lstat ../../deploy`. Можно задать `BUILDX_BAKE_FILE_RELATIVE_PATHS=1`.
+78
View File
@@ -0,0 +1,78 @@
# syntax=docker/dockerfile:1.7
# All-in-one Auth Portal (Fastify + SPA + SQLite). Analog cfdm / evofw.
# Закреплён по digest (воспроизводимая сборка); обновляйте вместе с node:22-alpine
# (digest: docker buildx imagetools inspect node:22-alpine).
ARG BASE_NODE=docker.io/library/node:22-alpine@sha256:fe4f6d74e20370acb04ad889077d14d0f64a9c8ea6a42bc60cddf1e844699914
FROM ${BASE_NODE} AS deps
WORKDIR /repo
RUN apk add --no-cache python3 make g++ \
&& corepack enable && corepack prepare pnpm@10.12.1 --activate
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml turbo.json ./
COPY apps/web/package.json apps/web/
COPY apps/api/package.json apps/api/
COPY packages/ui/package.json packages/ui/
COPY packages/shared/package.json packages/shared/
COPY packages/db/package.json packages/db/
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
pnpm install --frozen-lockfile
FROM deps AS build
RUN apk add --no-cache brotli
COPY apps/web apps/web
COPY apps/api apps/api
COPY packages/ui packages/ui
COPY packages/shared packages/shared
COPY packages/db packages/db
# Preview-маршруты дизайна (mock-данные) не нужны в проде: вырезаем до генерации routeTree
# (-45% файлов и ~-10% gzip в бандле). Общие компоненты из src/design-preview остаются.
RUN rm -f apps/web/src/routes/design-preview*.tsx
# API собирается tsup в один файл (все зависимости внутри, кроме нативных).
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
pnpm turbo build --filter=web --filter=@authportal/api
# Рантайм-слой: бандл + статика (только brotli) + нативные модули с теми же версиями, что в lockfile.
RUN mkdir -p /out \
&& cp -r apps/api/dist /out/dist \
&& cp -r apps/web/dist /out/static \
&& find /out/static -type f \( -name '*.js' -o -name '*.css' -o -name '*.html' -o -name '*.svg' -o -name '*.json' \) \
-exec sh -c 'brotli -f -q 11 -o "$1.br" "$1"' _ {} \; \
&& node -e "const v=(p)=>require(p).version;require('fs').writeFileSync('/out/package.json',JSON.stringify({name:'authportal-runtime',private:true,type:'module',dependencies:{'better-sqlite3':v('./packages/db/node_modules/better-sqlite3/package.json'),'@node-rs/argon2':v('./apps/api/node_modules/@node-rs/argon2/package.json')}}))"
RUN --mount=type=cache,target=/root/.npm,sharing=locked \
cd /out && npm install --omit=dev --no-audit --no-fund \
&& rm -rf node_modules/better-sqlite3/deps node_modules/better-sqlite3/src \
node_modules/better-sqlite3/build/Release/obj node_modules/better-sqlite3/build/Release/.deps \
node_modules/better-sqlite3/build/deps \
&& node -e "const D=require('better-sqlite3');new D(':memory:').prepare('select 1').get();import('@node-rs/argon2').then(m=>m.hash('x')).then(()=>console.log('native modules ok'))"
FROM ${BASE_NODE} AS runtime
WORKDIR /app
# Не-root runtime; /data — bind mount с хоста, на сервере нужен
# `chown -R 1000:1000 ./data` (см. deploy/docker-compose.traefik.yml).
RUN mkdir -p /data && chown node:node /data
ARG VERSION=dev
ARG GIT_SHA=unknown
ARG BUILD_TIME=
LABEL org.opencontainers.image.title="auth-portal" \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.revision="${GIT_SHA}" \
org.opencontainers.image.created="${BUILD_TIME}"
# Куча V8 ограничена под контейнер 256 МиБ (RSS = куча + нативная память + argon2);
# при другом mem_limit переопределите NODE_OPTIONS.
ENV NODE_ENV=production \
NODE_OPTIONS=--max-old-space-size=160 \
UV_THREADPOOL_SIZE=4 \
STATIC_DIR=/app/static \
DATABASE_URL=sqlite:/data/app.db \
SERVER_PORT=8080 \
APP_VERSION=${VERSION} \
GIT_SHA=${GIT_SHA} \
BUILD_TIME=${BUILD_TIME}
COPY --from=build --chown=node:node /out ./
USER node
EXPOSE 8080
STOPSIGNAL SIGTERM
VOLUME ["/data"]
# busybox wget вместо запуска второго процесса node каждые 30 с.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/ready"]
CMD ["node", "dist/server.js"]
+123
View File
@@ -0,0 +1,123 @@
# Единая сборка образа Auth Portal (buildx bake: -f deploy/docker/docker-bake.hcl).
# context = "../.." — корень репо. Bake резолвит context от cwd (не от HCL),
# поэтому вызов из deploy/docker/ либо BUILDX_BAKE_FILE_RELATIVE_PATHS=1.
variable "REGISTRY" {
default = "git.shx.one/denozord"
}
variable "IMAGE_TAG" {
default = "latest"
}
variable "SHORT_SHA" {
default = "dev"
}
variable "SHA_FULL" {
default = ""
}
variable "VERSION" {
default = "dev"
}
variable "BUILD_TIME" {
default = ""
}
variable "CACHE_REF_NODE" {
default = ""
}
# provenance + SBOM; включается только при --push (docker exporter attestations не поддерживает).
variable "ATTEST" {
default = "false"
}
variable "BASE_NODE" {
default = "docker.io/library/node:22-alpine@sha256:fe4f6d74e20370acb04ad889077d14d0f64a9c8ea6a42bc60cddf1e844699914"
}
function "node-cache-from" {
params = []
result = notequal("", CACHE_REF_NODE) ? ["type=registry,ref=${CACHE_REF_NODE}"] : []
}
# Экспорт кэша — один writer на ref (параллельный cache-to в один ref ломает manifest).
function "node-cache-to-export" {
params = []
result = notequal("", CACHE_REF_NODE) ? ["type=registry,ref=${CACHE_REF_NODE},mode=max"] : []
}
function "image-tags" {
params = [name]
result = concat(
notequal("", SHA_FULL) ? [
"${REGISTRY}/${name}:${IMAGE_TAG}",
"${REGISTRY}/${name}:${SHORT_SHA}",
"${REGISTRY}/${name}:sha-${SHA_FULL}",
] : [
"${REGISTRY}/${name}:${IMAGE_TAG}",
"${REGISTRY}/${name}:${SHORT_SHA}",
],
notequal(VERSION, "dev") && notequal(VERSION, "") ? [
"${REGISTRY}/${name}:v${VERSION}",
"${REGISTRY}/${name}:${VERSION}",
] : []
)
}
group "default" {
targets = ["authportal"]
}
target "_common" {
platforms = ["linux/amd64"]
pull = false
}
target "node-deps" {
inherits = ["_common"]
context = "../.."
dockerfile = "deploy/docker/authportal/Dockerfile"
target = "deps"
args = {
BASE_NODE = BASE_NODE
}
cache-from = node-cache-from()
}
target "node-build" {
inherits = ["_common"]
context = "../.."
dockerfile = "deploy/docker/authportal/Dockerfile"
target = "build"
args = {
BASE_NODE = BASE_NODE
}
contexts = {
deps = "target:node-deps"
}
cache-from = node-cache-from()
cache-to = node-cache-to-export()
}
target "authportal" {
inherits = ["_common"]
context = "../.."
dockerfile = "deploy/docker/authportal/Dockerfile"
target = "runtime"
args = {
BASE_NODE = BASE_NODE
VERSION = VERSION
GIT_SHA = notequal("", SHA_FULL) ? SHA_FULL : SHORT_SHA
BUILD_TIME = BUILD_TIME
}
contexts = {
build = "target:node-build"
}
cache-from = node-cache-from()
attest = equal(ATTEST, "true") ? ["type=provenance,mode=min", "type=sbom"] : []
tags = concat(image-tags("authportal"), image-tags("auth-portal"))
}
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env sh
# Copy-by-digest зеркало базовых образов в Gitea Container Registry (без rebuild).
# Требует: docker login в REGISTRY host, docker buildx.
# REGISTRY=git.shx.one/<owner> (без суффикса /authportal-buildcache)
#
# Источник library-образов — Docker Hub (docker.io).
# Если тег уже есть в Gitea — skip (без повторного pull).
# Копируем только linux/amd64 — bake platforms совпадает.
# Шаг CD не должен падать: неуспешный copy оставляет bake на Docker Hub FROM.
set -eu
REGISTRY="${REGISTRY:?REGISTRY required (git.shx.one/<owner>)}"
CACHE_REPO="${REGISTRY}/authportal-buildcache"
ENV_FILE="${MIRROR_ENV_FILE:-}"
PLATFORM="${MIRROR_PLATFORM:-linux/amd64}"
RETRIES="${MIRROR_RETRIES:-4}"
tmp="$(mktemp)"
cleanup() { rm -f "$tmp"; }
trap cleanup EXIT
dest_exists() {
docker buildx imagetools inspect "$1" >/dev/null 2>&1
}
copy_retry() {
src="$1"
dest="$2"
n=0
while [ "$n" -lt "$RETRIES" ]; do
n=$((n + 1))
if docker buildx imagetools create --platform "$PLATFORM" --tag "$dest" "$src"; then
return 0
fi
delay=$((n * 25))
echo "mirror retry ${n}/${RETRIES}, sleep ${delay}s: ${dest}"
sleep "$delay"
done
return 1
}
mirror() {
src="$1"
tag="$2"
var="$3"
dest="${CACHE_REPO}:${tag}"
if dest_exists "$dest"; then
echo "skip (already in registry): ${dest}"
printf '%s=%s\n' "$var" "$dest" >>"$tmp"
return 0
fi
echo "mirror ${src} -> ${dest} (${PLATFORM})"
if copy_retry "$src" "$dest"; then
printf '%s=%s\n' "$var" "$dest" >>"$tmp"
return 0
fi
echo "warn: ${dest} not mirrored — bake uses Docker Hub FROM for ${var}"
return 0
}
mirror "docker.io/library/node:22-alpine" "base-node-22-alpine" BASE_NODE
echo "----- mirrored BASE_* -----"
cat "$tmp"
if [ -n "$ENV_FILE" ]; then
env_dir="$(dirname "$ENV_FILE")"
if [ -d "$env_dir" ]; then
cp "$tmp" "$ENV_FILE"
echo "wrote ${ENV_FILE}"
else
echo "warn: MIRROR_ENV_FILE dir missing (${env_dir}) — skip write"
fi
fi
+58
View File
@@ -0,0 +1,58 @@
# Production .env for deploy/docker-compose.traefik.yml (auth + Traefik).
# Copy to /opt/auth-portal/.env and fill secrets. Do not commit.
# --- Traefik / Let's Encrypt (Cloudflare DNS-01) ---
CF_DNS_API_TOKEN=
LETSENCRYPT_EMAIL=admin@shnt.top
AUTH_DOMAIN=auth.shnt.top
# TRAEFIK_IMAGE_TAG=v3.7
# TRAEFIK_HTTP_PORT=80
# TRAEFIK_HTTPS_PORT=443
# TRAEFIK_LOG_LEVEL=INFO
# --- Auth Portal ---
AUTH_IMAGE_TAG=latest
JWT_SECRET=
JWT_TTL_HOURS=1
REFRESH_TTL_DAYS=14
ISSUER=https://auth.shnt.top
# OIDC IdP for Technitium DNS and other external RPs (defaults to ISSUER if empty)
# OIDC_ISSUER=https://auth.shnt.top
# Optional PKCS8 RSA PEM; otherwise key is auto-generated into SQLite (/data)
# OIDC_RSA_PRIVATE_KEY=
ADMIN_EMAIL=admin@shnt.top
ADMIN_PASSWORD=
ADMIN_NAME=Admin
# Include Technitium origin if used (e.g. https://dns.shnt.top)
RETURN_TO_ALLOWLIST=.shnt.top,https://vps.shnt.top,https://cfdm.shnt.top,https://bgp.shnt.top,https://fw.shnt.top,https://dns.shnt.top,https://cdn.shnt.top
LOG_LEVEL=info
NODE_ENV=production
# WebAuthn / passkeys (defaults from ISSUER hostname + origin)
# WEBAUTHN_RP_ID=auth.shnt.top
# WEBAUTHN_ORIGINS=https://auth.shnt.top
# WEBAUTHN_RP_NAME=Auth Portal
# --- Ingest аудита от подключённых приложений (обязателен для /api/v1/ingest/audit) ---
# AUDIT_INGEST_SECRET=
# --- Производительность / эксплуатация (все необязательны) ---
# Максимум на graceful shutdown, мс
# SHUTDOWN_TIMEOUT_MS=8000
# Одновременных argon2-хешей (каждый ~19 МиБ RAM)
# ARGON2_CONCURRENCY=2
# Сколько дней хранить истёкшие/отозванные сессии и коды
# SESSION_RETENTION_DAYS=7
# Доверенные прокси для X-Forwarded-For (IP/CIDR через запятую, true/false)
# TRUST_PROXY=127.0.0.1/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7
# Сжатие ответов в Node (нужно только без Traefik compress)
# ENABLE_COMPRESS=false
# Глобальный лимит запросов на IP в минуту
# RATE_LIMIT_MAX=200
# Доп. origins для CORS (по умолчанию: issuer, RETURN_TO_ALLOWLIST, приложения App Switcher)
# CORS_ORIGINS=
# Бэкап SQLite: период в часах (0 = выключен), число копий, каталог
# BACKUP_INTERVAL_HOURS=24
# BACKUP_KEEP=7
# BACKUP_DIR=/data/backups
# Heap Node (образ рассчитан на mem_limit 256m); при другом лимите:
# NODE_OPTIONS=--max-old-space-size=160