Compare commits

...

9 Commits

35 changed files with 2888 additions and 9 deletions

View File

@ -22,6 +22,7 @@ Git repo:
Артефакты: Артефакты:
- `docs/CURRENT_INFRA_HANDOFF.md`
- `docs/DISCOVERY_REPORT.md` - `docs/DISCOVERY_REPORT.md`
- `docs/ARCHITECTURE.md` - `docs/ARCHITECTURE.md`
- `docs/AUTH_MODEL.md` - `docs/AUTH_MODEL.md`

View File

@ -0,0 +1,456 @@
# NODE.DC current infra handoff
Last updated: 2026-05-15.
This document is the fast context entrypoint for a new engineering chat. Read it first before touching deploy, Synology, Authentik, Launcher, or Tasker.
## Product shape
NODE.DC is intentionally modular. It is not a single monorepo with all products merged into one codebase.
Source-of-truth repositories:
| Module | Responsibility | Local source |
| --- | --- | --- |
| Platform | Infra, Authentik, reverse proxy, Synology deploy scripts, architecture docs | `/Users/dcconstructions/Downloads/mnt/NODEDC/platform` |
| Launcher / Hub | NODE.DC control plane, user/admin UI, access requests, access matrix, Authentik sync | `/Users/dcconstructions/Downloads/mnt/data/nodedc_launcher` |
| Tasker / Operational Core | Plane fork, tasks/workspaces/projects, standalone-capable product module | `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER` |
| Ops Agents Gateway | Standalone MCP/API router for Tasker operational agents | `/Users/dcconstructions/Downloads/mnt/data/NODEDC_TASKMANAGER_CODEXAPI` |
Current Git branches:
- Platform: `main`
- Launcher: `main`
- Tasker: `master`
- Ops Agents Gateway: `main`
The modules communicate through HTTP/OIDC/internal APIs. They must remain independently buildable and deployable.
## Ownership boundaries
Do not collapse data into one shared database.
| Area | Owner | Runtime state |
| --- | --- | --- |
| Identity, SSO, OIDC providers | Authentik | Authentik PostgreSQL volume in `nodedc-platform` |
| Clients, users, groups, Launcher access matrix | Launcher | Launcher JSON/runtime storage under `/volume1/docker/nodedc-platform/launcher` |
| Workspaces, projects, issues, comments, files | Tasker | Tasker PostgreSQL + MinIO/RabbitMQ/Redis volumes under `nodedc-tasker` |
Launcher can project access into Tasker, but it must not become the owner of Tasker domain tables. Tasker can run standalone if NODE.DC integration env vars are disabled or pointed elsewhere.
## Synology topology
Current NAS root:
```text
/volume1/docker/nodedc-platform
```
Mounted on macOS as:
```text
/Volumes/docker/nodedc-platform
```
Public domains:
```text
https://id.nodedc.ru -> Authentik
https://hub.nodedc.ru -> Launcher / Hub
https://ops.nodedc.ru -> Tasker / Operational Core
https://ops-agents.nodedc.ru -> Ops Agents Gateway / MCP endpoint
```
Local/NAS check domains used during rollout:
```text
auth.nas.nodedc
launcher.nas.nodedc
task.nas.nodedc
auth.local.nodedc
launcher.local.nodedc
task.local.nodedc
```
### Platform compose project
Compose project:
```text
nodedc-platform
```
Compose file:
```text
/volume1/docker/nodedc-platform/platform/docker-compose.platform-http.yml
```
Source copy:
```text
/Users/dcconstructions/Downloads/mnt/NODEDC/platform/infra/synology/docker-compose.platform-http.yml
```
Main services:
- `reverse-proxy`: Caddy HTTP edge, publishes host port `18080`.
- `launcher`: `nodedc/launcher:local`, serves Hub on container port `5173`.
- `postgresql-authentik`: Authentik PostgreSQL.
- `authentik-server`: Authentik web/server.
- `authentik-worker`: Authentik worker.
Main Docker volumes:
- `authentik-database`
- `authentik-data`
- `authentik-certs`
- `caddy-data`
- `caddy-config`
Launcher bind-mounted runtime state:
```text
/volume1/docker/nodedc-platform/launcher/server-storage -> /app/server/storage
/volume1/docker/nodedc-platform/launcher/uploads -> /app/dist/storage/uploads
/volume1/docker/nodedc-platform/launcher/uploads -> /app/public/storage/uploads
```
These directories must be writable by container user `node` / UID `1000`.
### Tasker compose project
Compose project:
```text
nodedc-tasker
```
NAS runtime/deploy directory:
```text
/volume1/docker/nodedc-platform/tasker/plane-app
```
NAS source/deploy copy:
```text
/volume1/docker/nodedc-platform/tasker/plane-src
```
Repository source:
```text
/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src
```
Repository compose/runtime template:
```text
/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-app/docker-compose.yaml
```
Main services:
- `web`: Tasker frontend, image `nodedc/plane-frontend:ru`.
- `admin`: Tasker admin frontend, image `nodedc/plane-admin:ru`.
- `space`: public/space frontend, image `nodedc/plane-space:ru`.
- `live`: live server, image `nodedc/plane-live:local`.
- `api`: backend API, image `nodedc/plane-backend:local`.
- `worker`, `beat-worker`, `migrator`: backend workers/migrations.
- `plane-db`: PostgreSQL.
- `plane-redis`: Valkey/Redis.
- `plane-mq`: RabbitMQ.
- `plane-minio`: uploads/files.
- `proxy`: Tasker edge proxy, publishes `LISTEN_HTTP_PORT` / current NAS port `18090`.
Main Docker volumes under the `nodedc-tasker` project:
- `nodedc-tasker_pgdata`
- `nodedc-tasker_redisdata`
- `nodedc-tasker_rabbitmq_data`
- `nodedc-tasker_uploads`
- log and proxy volumes from `plane-app/docker-compose.yaml`
NAS-only runtime files currently exist and are intentionally not committed with secrets:
```text
/volume1/docker/nodedc-platform/tasker/plane-app/.env.synology
/volume1/docker/nodedc-platform/tasker/plane-app/docker-compose.synology.override.yml
```
The current override only adds `extra_hosts` entries so Tasker backend containers can resolve `id.nodedc.ru`, `hub.nodedc.ru`, `ops.nodedc.ru`, local domains, NAS domains, and `host.docker.internal` through `host-gateway`.
### Ops Agents Gateway compose project
Compose project:
```text
nodedc-ops-agents
```
Recommended NAS runtime/deploy directory:
```text
/volume1/docker/nodedc-platform/ops-agents
```
Repository source:
```text
/Users/dcconstructions/Downloads/mnt/data/NODEDC_TASKMANAGER_CODEXAPI
```
Public entrypoint:
```text
https://ops-agents.nodedc.ru
```
Synology reverse proxy should route:
```text
HTTPS ops-agents.nodedc.ru:443 -> HTTP 127.0.0.1:18190
```
Do not use host port `18090` for this module: `18090` is the Tasker proxy port. Gateway container port remains `4100`; host port is controlled by `HOST_PORT=18190`.
Main services:
- `agent-gateway`: NODE.DC Operational Agents Gateway, image built from source by compose.
- `postgres`: Gateway-owned PostgreSQL database for agents, grants, tokens, idempotency, and audit.
Deployment checklist:
```text
/Users/dcconstructions/Downloads/mnt/data/NODEDC_TASKMANAGER_CODEXAPI/docs/SYNOLOGY_DEPLOY.md
```
## Deploy model
NAS is a deploy target and runtime state holder. It is not the source of truth for code.
Normal direction:
```text
Git repo -> build image / sync deploy files -> Synology compose recreate selected service
```
Do not edit NAS copies as the long-term fix. If an emergency live edit is made on NAS, port it back into the relevant repo before continuing product work.
## Platform / Launcher deploy
From macOS with `/Volumes/docker` mounted:
```bash
cd /Users/dcconstructions/Downloads/mnt/NODEDC/platform
NAS_ROOT=/Volumes/docker/nodedc-platform \
LAUNCHER_REPO=/Users/dcconstructions/Downloads/mnt/data/nodedc_launcher \
./infra/synology/deploy-current.sh
```
The script syncs platform deploy files and prints the Docker commands that must be run on Synology, because Docker there requires interactive `sudo`.
Launcher image build is done from the Launcher repo when frontend/backend code changes:
```bash
cd /volume1/docker/nodedc-platform/launcher/source
sudo /usr/local/bin/docker build --no-cache -t nodedc/launcher:local .
```
Launcher container recreate:
```bash
cd /volume1/docker/nodedc-platform/platform
sudo /usr/local/bin/docker compose \
--env-file /volume1/docker/nodedc-platform/platform/.env.synology \
-f /volume1/docker/nodedc-platform/platform/docker-compose.platform-http.yml \
up -d --force-recreate --no-deps launcher
```
## Tasker deploy
Tasker rebuild script is in the Tasker repo and copied to NAS:
```text
/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/rebuild-nas-legacy.sh
```
Production-style frontend rebuild on NAS:
```bash
cd /volume1/docker/nodedc-platform/tasker/plane-src
BUILD_BACKEND=0 BUILD_WEB=1 BUILD_ADMIN=0 sh rebuild-nas-legacy.sh
```
Admin frontend rebuild:
```bash
cd /volume1/docker/nodedc-platform/tasker/plane-src
BUILD_BACKEND=0 BUILD_WEB=0 BUILD_ADMIN=1 sh rebuild-nas-legacy.sh
```
Combined web/admin rebuild:
```bash
cd /volume1/docker/nodedc-platform/tasker/plane-src
BUILD_BACKEND=0 BUILD_WEB=1 BUILD_ADMIN=1 sh rebuild-nas-legacy.sh
```
The script defaults production frontend links to:
```text
VITE_NODEDC_LAUNCHER_URL=https://hub.nodedc.ru
```
Local/staging can override:
```bash
VITE_NODEDC_LAUNCHER_URL=http://launcher.local.nodedc \
BUILD_BACKEND=0 BUILD_WEB=1 BUILD_ADMIN=0 \
sh rebuild-nas-legacy.sh
```
Manual recreate command used on NAS:
```bash
cd /volume1/docker/nodedc-platform/tasker/plane-app
sudo /usr/local/bin/docker compose -p nodedc-tasker \
--env-file .env.synology \
-f docker-compose.yaml \
-f docker-compose.synology.override.yml \
up -d --no-build --force-recreate web
```
Replace `web` with `admin`, `api`, or another service only when that service image was intentionally rebuilt.
Tasker backend must receive the Gateway internal URL/token before Codex/Ops Agent UI can create agents in production:
```env
PLANE_NODEDC_AGENT_GATEWAY_URL=http://127.0.0.1:18190
PLANE_NODEDC_AGENT_GATEWAY_TOKEN=<same value as NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN>
```
After changing these env vars, rebuild/recreate Tasker backend services: `api`, `worker`, `beat-worker`, and `migrator` when migrations are expected.
## Ops Agents Gateway deploy
From the Gateway repo on Synology or from a synced source copy:
```bash
cd /volume1/docker/nodedc-platform/ops-agents
sudo /usr/local/bin/docker compose --env-file .env -f docker-compose.synology.yml up -d --build
```
Required Synology `.env` values are documented in:
```text
/Users/dcconstructions/Downloads/mnt/data/NODEDC_TASKMANAGER_CODEXAPI/docs/SYNOLOGY_DEPLOY.md
```
Minimum checks:
```bash
curl -fsS http://127.0.0.1:18190/healthz
curl -fsS http://127.0.0.1:18190/readyz
curl -fsS -i https://ops-agents.nodedc.ru/mcp | head
```
## Backup rules
Platform backup script:
```bash
cd /Users/dcconstructions/Downloads/mnt/NODEDC/platform
NAS_ROOT=/Volumes/docker/nodedc-platform ./infra/synology/backup-current.sh
```
It creates filesystem backups and generates a Synology-side Authentik database dump command.
Tasker safety:
- Frontend-only Tasker rebuilds do not touch Tasker PostgreSQL or MinIO uploads.
- Backend image rebuild is not enough to lose data by itself, but backend migrations can change DB schema.
- Before backend migrations, backup `nodedc-tasker_pgdata` and `nodedc-tasker_uploads`.
- Never run destructive volume commands on NAS production data.
Hard rules:
```text
Never run docker compose down -v on production/staging data.
Never delete nodedc-tasker_* volumes without a verified backup.
Never use docker system prune blindly on the NAS.
Never commit .env.synology or real secrets.
```
## Local development model
The same codebase must support local and production-like runs. Differences must come from env vars and build args, not branches with hardcoded production URLs.
Local target domains:
```text
127.0.0.1 auth.local.nodedc
127.0.0.1 launcher.local.nodedc
127.0.0.1 task.local.nodedc
```
Platform local docs:
```text
/Users/dcconstructions/Downloads/mnt/NODEDC/platform/docs/DEPLOYMENT_LOCAL.md
/Users/dcconstructions/Downloads/mnt/NODEDC/platform/infra/README.md
```
Tasker local/prod integration env contract:
```text
/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/docs_prod/NODEDC_TASKER_DEPLOY_MODEL.md
```
Useful local Tasker frontend build checks:
```bash
cd /Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src
pnpm turbo run build --filter=web
pnpm turbo run build --filter=admin
```
Known current validation note: `admin` typecheck passes. Full `web` typecheck currently has pre-existing upstream/local Plane TypeScript debt unrelated to the NODE.DC logo/deploy baseline work.
## Current docs map
Read these in order:
1. This file: `/Users/dcconstructions/Downloads/mnt/NODEDC/platform/docs/CURRENT_INFRA_HANDOFF.md`
2. Architecture: `/Users/dcconstructions/Downloads/mnt/NODEDC/platform/docs/ARCHITECTURE.md`
3. Synology deploy: `/Users/dcconstructions/Downloads/mnt/NODEDC/platform/infra/synology/README.md`
4. Local deploy: `/Users/dcconstructions/Downloads/mnt/NODEDC/platform/docs/DEPLOYMENT_LOCAL.md`
5. Tasker deploy model: `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/docs_prod/NODEDC_TASKER_DEPLOY_MODEL.md`
6. Ops Agents Gateway deploy: `/Users/dcconstructions/Downloads/mnt/data/NODEDC_TASKMANAGER_CODEXAPI/docs/SYNOLOGY_DEPLOY.md`
## New-chat bootstrap prompt
Use this when starting a new engineering chat:
```text
We are working on NODE.DC. First read:
/Users/dcconstructions/Downloads/mnt/NODEDC/platform/docs/CURRENT_INFRA_HANDOFF.md
Respect the modular architecture:
- Platform repo owns infra/Synology/Auth/reverse-proxy docs and scripts.
- Launcher repo owns Hub/control-plane.
- Tasker repo owns Plane fork and must remain standalone-capable.
- Ops Agents Gateway repo owns MCP/API routing for external operational agents.
- NAS is deploy target/runtime state, not source of truth.
- Do not touch production volumes destructively.
- Do not commit secrets or .env.synology.
```
## Known follow-up hardening
- Add a sanitized committed Tasker `docker-compose.synology.override.example.yml`.
- Add a Tasker volume backup script equivalent to Platform `backup-current.sh`.
- Add an Ops Agents Gateway backup script for its Postgres volume/dump.
- Add a single smoke-check script for public domains: `id.nodedc.ru`, `hub.nodedc.ru`, `ops.nodedc.ru`, `ops-agents.nodedc.ru`.
- Decide whether release tags should be coordinated across the three repos after stable production milestones.

View File

@ -0,0 +1,275 @@
# DevOps Security Handoff
Актуализировано: 2026-05-12.
Документ фиксирует границу передачи NODE.DC platform DevOps-инженеру. Локальная разработка закрывает код, конфиги, preflight и smoke-сценарии, которые можно честно проверить без реального staging host. DevOps закрывает серверные домены, TLS, secrets, firewall и финальную staging acceptance.
## Что уже подготовлено локально
### Platform / Authentik / reverse proxy
Репозиторий:
```text
/Users/dcconstructions/Downloads/mnt/NODEDC/platform
```
Готовые артефакты:
```text
docs/STAGING_SECURITY_PLAN.md
docs/SECURITY_CHECKLIST.md
infra/docker-compose.staging.example.yml
infra/reverse-proxy/Caddyfile.staging
infra/.env.staging.example
infra/scripts/check-staging-env.sh
```
Локально проверено:
```bash
cd platform/infra
NODEDC_STAGING_ENV_FILE=.env.staging.example docker compose --env-file .env.staging.example -f docker-compose.staging.example.yml config
docker run --rm --env-file .env.staging.example -v "$PWD/reverse-proxy/Caddyfile.staging:/etc/caddy/Caddyfile:ro" caddy:2-alpine caddy validate --config /etc/caddy/Caddyfile
./scripts/check-staging-env.sh .env.staging.example
```
Ожидаемый результат: compose и Caddyfile валидны; preflight на `.env.staging.example` падает, потому что example содержит placeholder secrets.
### Launcher / control plane
Репозиторий:
```text
/Users/dcconstructions/Downloads/mnt/data/nodedc_launcher
```
Подготовлено:
- control-plane snapshot перенесён из public static в server-only storage;
- `/storage/launcher-data.json` закрыт;
- `/api/storage/data` и `/api/storage/upload` требуют session;
- `/api/apps` отдаёт каталог сервисов с флагами доступа; карточки сервисов видны всем authenticated users, но launch разрешён только при app access;
- hard delete вызывает Tasker cleanup: sessions, identity links, workspace/project memberships, issue assignees;
- internal API token отделён от OIDC client secret;
- повторный accept уже принятого invite отклоняется.
Локально проверено:
```bash
cd nodedc_launcher
node --check server/dev-server.mjs
npm run build
npm test
curl -i http://launcher.local.nodedc/api/me
curl -i http://launcher.local.nodedc/api/apps
curl -i http://launcher.local.nodedc/api/services/task-manager/launch
```
Ожидаемый результат без session: `401`.
### Task Manager / Operational Core
Репозиторий:
```text
/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER
```
Готовые артефакты:
```text
plane-app/plane.env.staging.example
scripts/check-tasker-staging-env.sh
```
Подготовлено:
- `PLANE_NODEDC_ACCESS_TOKEN` больше не fallback-ится на `PLANE_OIDC_CLIENT_SECRET`;
- unlinked old sessions могут отзываться через `PLANE_NODEDC_ACCESS_ENFORCE_UNLINKED=1`;
- internal logout умеет чистить ExternalIdentityLink, WorkspaceMember, ProjectMember, IssueAssignee;
- self-host workspace invite снова создаёт pending request в Launcher;
- launcher-managed workspace отклоняет self-service invite request;
- Tasker proxy получает `TRUSTED_PROXIES` из env.
Локально проверено:
```bash
cd NODEDC_TASKMANAGER
docker compose --env-file plane-app/plane.env.staging.example -f plane-app/docker-compose.yaml config
./scripts/check-tasker-staging-env.sh plane-app/plane.env.staging.example
```
Ожидаемый результат: compose валиден; preflight на example падает из-за placeholder secrets.
## Что DevOps должен сделать на сервере
## Последний локальный smoke перед передачей
Выполнено 2026-05-12 после rebuild `nodedc/plane-backend:local` и `nodedc/plane-frontend:ru`, затем `./setup.sh stop && ./setup.sh start`.
Контейнеры Tasker:
```text
admin, api, beat-worker, live, plane-db, plane-minio, plane-mq, plane-redis, proxy, space, web, worker — Up
web/admin/space — healthy
```
HTTP smoke:
```text
http://auth.local.nodedc/ -> 302
http://launcher.local.nodedc/healthz -> 200
http://task.local.nodedc/ -> 200
http://localhost:8090/ -> 200
```
Unauth negative paths:
```text
GET http://launcher.local.nodedc/api/me -> 401
GET http://launcher.local.nodedc/api/apps -> 401
GET http://launcher.local.nodedc/api/services/task-manager/launch -> 401
POST http://launcher.local.nodedc/api/internal/access/check without token -> 401
POST http://task.local.nodedc/api/internal/nodedc/logout/ without token -> 401
```
Этот smoke закрывает локальную runtime-ready часть. Он не заменяет DevOps staging smoke на реальных HTTPS-доменах.
### 1. Подготовить DNS и host
Выбрать реальные домены:
```text
auth.<staging-domain>
launcher.<staging-domain>
task.<staging-domain>
```
Все DNS-записи должны указывать на staging host или ingress. Порты `80/tcp` и `443/tcp` должны быть доступны снаружи для TLS/ACME.
### 2. Создать реальные env-файлы
Platform:
```bash
cd platform/infra
cp .env.staging.example .env.staging
```
Tasker:
```bash
cd NODEDC_TASKMANAGER
cp plane-app/plane.env.staging.example plane-app/plane.env.staging
```
Заменить все `replace-with-*` на реальные значения. Нельзя использовать local/dev secrets, `change-me`, `local-dev`, `.local.nodedc`, `localhost`.
Обязательное правило:
- `NODEDC_INTERNAL_ACCESS_TOKEN` должен совпадать с `PLANE_NODEDC_ACCESS_TOKEN`;
- `NODEDC_INTERNAL_ACCESS_TOKEN` не должен совпадать ни с одним OIDC client secret;
- `LAUNCHER_OIDC_CLIENT_SECRET` и `PLANE_OIDC_CLIENT_SECRET` должны быть разными;
- `COOKIE_SECURE=true`;
- все public/OIDC/internal URLs должны быть `https://`.
### 3. Ограничить proxy trust
В staging нельзя оставлять broad ranges:
```text
0.0.0.0/0
::/0
10.0.0.0/8
172.16.0.0/12
192.168.0.0/16
127.0.0.0/8
```
Нужно указать только фактический subnet reverse proxy / ingress:
```text
AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS=<edge-or-ingress-cidr>
TRUSTED_PROXIES=<edge-or-ingress-cidr>
```
### 4. Прогнать preflight
Platform:
```bash
cd platform/infra
./scripts/check-staging-env.sh .env.staging
docker compose --env-file .env.staging -f docker-compose.staging.example.yml config
```
Tasker:
```bash
cd NODEDC_TASKMANAGER
./scripts/check-tasker-staging-env.sh plane-app/plane.env.staging
docker compose --env-file plane-app/plane.env.staging -f plane-app/docker-compose.yaml config
```
Preflight обязан пройти на реальных env-файлах. Если падает — staging не запускать.
### 5. Поднять runtime
Platform:
```bash
cd platform/infra
docker compose --env-file .env.staging -f docker-compose.staging.example.yml up -d
```
Tasker команда зависит от выбранной topology. Если используется текущий Plane runtime:
```bash
cd NODEDC_TASKMANAGER
docker compose --env-file plane-app/plane.env.staging -f plane-app/docker-compose.yaml up -d
```
Если Tasker стоит за platform reverse proxy, наружу публикуется только platform edge. Postgres, Redis/Valkey, MinIO, RabbitMQ, Authentik server/worker, Launcher BFF и Tasker API не публикуются напрямую наружу.
### 6. Bootstrap Authentik
Создать/обновить:
- отдельное Application/Provider для Launcher;
- отдельное Application/Provider для Tasker;
- отдельные OIDC client secrets;
- группы `nodedc:superadmin`, `nodedc:launcher:admin`, `nodedc:launcher:user`, `nodedc:taskmanager:admin`, `nodedc:taskmanager:user`;
- access policies для каждого приложения;
- redirect/logout URI только на staging HTTPS domains.
### 7. Проверить финальный staging smoke
Минимальный smoke:
1. `https://auth...`, `https://launcher...`, `https://task...` открываются по HTTPS.
2. HTTP делает redirect на HTTPS.
3. Ответы содержат HSTS.
4. Cookies выставляются как `Secure` и `HttpOnly`.
5. Без login Launcher ведёт в Authentik.
6. Active user видит все карточки сервисов, но открыть может только разрешённые сервисы.
7. User без Task Manager app access видит карточку Task Manager в Launcher, не может открыть сервис и получает deny по прямому `https://task...`.
8. Blocked/annulled user теряет Launcher и Tasker session после hard refresh.
9. Self-host workspace invite создаёт pending request в Launcher.
10. Launcher-managed workspace не принимает self-service invite request из Tasker.
11. Hard delete удаляет active Tasker WorkspaceMember/ProjectMember/IssueAssignee.
12. Повторный accept уже принятого invite отклоняется.
13. Internal endpoints без token дают `401`.
14. Audit содержит admin actions: approve/reject/access change/hard delete.
15. Снаружи не доступны Postgres, Redis/Valkey, MinIO, RabbitMQ, Authentik server/worker, Launcher BFF и Tasker API-порты.
## Что не входит в текущую передачу
- billing;
- тарифы;
- email automation;
- production HA/backup automation;
- production monitoring/SIEM;
- public self-service без ручного approve.
Эти темы не являются блокерами текущего закрытого demo release.

View File

@ -1,5 +1,9 @@
# Security Checklist # Security Checklist
Staging path and runbook: `docs/STAGING_SECURITY_PLAN.md`.
DevOps handoff: `docs/DEVOPS_SECURITY_HANDOFF.md`.
Service catalog UX rules: `docs/SERVICE_CATALOG_UX_RULES.md`.
## Network ## Network
- [x] Local dev compose публикует только reverse proxy port. - [x] Local dev compose публикует только reverse proxy port.
@ -26,6 +30,8 @@
- [ ] Admin endpoints требуют `nodedc:superadmin` или `nodedc:launcher:admin`. - [ ] Admin endpoints требуют `nodedc:superadmin` или `nodedc:launcher:admin`.
- [ ] Все admin actions пишутся в audit log. - [ ] Все admin actions пишутся в audit log.
- [ ] Удаление пользователя реализовано как deactivate/disable, не hard delete. - [ ] Удаление пользователя реализовано как deactivate/disable, не hard delete.
- [x] Local runtime не отдает `storage/launcher-data.json` напрямую через public static route.
- [x] Local runtime требует user session для `/api/storage/data`.
## Plane ## Plane
@ -43,13 +49,20 @@
- [ ] Access/refresh tokens не логируются. - [ ] Access/refresh tokens не логируются.
- [ ] Session cookies имеют `secure=true` в staging/production. - [ ] Session cookies имеют `secure=true` в staging/production.
- [ ] В production включены HTTPS и HSTS. - [ ] В production включены HTTPS и HSTS.
- [x] Local internal API token отделен от `PLANE_OIDC_CLIENT_SECRET`.
- [x] Launcher internal access API отклоняет запросы без token, с неверным token и со старым OIDC client secret.
- [x] Tasker internal logout API отклоняет запросы без token и со старым OIDC client secret.
## Acceptance scenarios ## Acceptance scenarios
- [ ] Без логина Launcher отправляет в Authentik. - [x] Без логина Launcher отправляет в Authentik.
- [ ] Пользователь без `nodedc:taskmanager:access` не видит Task Manager в Launcher. - [ ] Пользователь без Task Manager app access видит карточку Task Manager в Launcher, но не может открыть сервис.
- [ ] Пользователь без `nodedc:taskmanager:access` получает deny на прямой `task.local.nodedc`. - [x] Пользователь без Task Manager app access получает deny в Tasker access middleware при прямом доступе.
- [ ] Пользователь с доступом открывает Task Manager. - [ ] Пользователь с доступом открывает Task Manager.
- [ ] Старый Plane admin после OIDC видит старые workspace/tasks/comments. - [ ] Старый Plane admin после OIDC видит старые workspace/tasks/comments.
- [ ] Деактивированный пользователь теряет доступ. - [ ] Деактивированный пользователь теряет доступ.
- [ ] Admin action появляется в audit log. - [x] Hard-deleted/annulled user теряет stale Tasker identity link и старая unlinked-сессия отзывается middleware.
- [x] Hard-deleted/annulled user удаляется из active Tasker workspace/project members и issue assignees.
- [x] Self-host Tasker workspace invite создает pending request в Launcher, а launcher-managed workspace не принимает self-service invite request.
- [x] Admin action появляется в audit log.
- [x] Повторный accept уже принятого invite отклоняется.

View File

@ -0,0 +1,56 @@
# Service Catalog UX Rules
Актуализировано: 2026-05-12.
Этот документ фиксирует обязательное правило Launcher-витрины NODE.DC.
## Главное правило
Все authenticated users видят карточки всех сервисов, добавленных в каталог Launcher, независимо от текущего app access.
Видимость карточки сервиса не означает право запуска сервиса.
## Зачем это нужно
Launcher — это не только access gate, но и витрина платформы. Пользователь должен видеть, какие приложения существуют в NODE.DC, даже если доступ к конкретному приложению ещё не выдан.
Это нужно для:
- демонстрации состава платформы;
- ручного запроса доступа;
- будущих тарифов и paid access;
- понимания, какие модули доступны в экосистеме.
## Как должен работать UI
Для сервиса без доступа:
- карточка сервиса остаётся видимой в нижней/сервисной панели;
- описание, медиа и карточка доступны для чтения;
- статус/chip показывает отсутствие доступа;
- кнопка открытия сервиса disabled или ведёт в controlled request/access flow;
- прямой запуск через `/api/services/:serviceSlug/launch` обязан возвращать deny.
Для сервиса с доступом:
- карточка сервиса видима;
- кнопка открытия активна;
- launch идёт только через Launcher BFF, а не прямым trust из UI.
## Realtime правило
Изменение доступа должно обновлять состояние карточки без hard refresh:
- выдали доступ — кнопка открытия становится активной;
- сняли доступ — карточка остаётся видимой, но кнопка открытия выключается;
- direct launch после снятия доступа остаётся запрещённым server-side.
Нельзя чинить безопасность через скрытие карточек. Безопасность обеспечивается server-side launch deny, Tasker middleware и internal access enforcement.
## Что запрещено
- Фильтровать `/api/apps` только до доступных пользователю приложений, если это ломает витрину.
- Убирать карточку сервиса из UI только потому, что у пользователя нет app access.
- Считать скрытие карточки security control.
- Ломать realtime-переключение кнопки `Открыть` при approve/revoke доступа.

View File

@ -0,0 +1,157 @@
# Staging Security Plan
Актуализировано: 2026-05-12.
Этот документ фиксирует минимальный staging path для закрытого demo release NODE.DC platform. Billing, тарифы и email automation не входят в текущий scope: доступы, approve и передача ссылок остаются ручными через Launcher/root-admin.
## Целевые домены
Для staging нужны отдельные домены от локального `.local.nodedc`:
```text
auth.staging.nodedc.example -> Authentik
launcher.staging.nodedc.example -> Launcher BFF/web
task.staging.nodedc.example -> Task Manager / Operational Core
```
Финальные DNS-имена заменяются перед deploy. Все redirect URI в Authentik должны совпадать с этими доменами и использовать `https://`.
## Публичная поверхность
Снаружи публикуется только edge reverse proxy:
- `80/tcp` — только redirect на HTTPS;
- `443/tcp` — HTTPS termination, HSTS, proxy headers;
- Postgres, Redis/Valkey, MinIO, Authentik server/worker, Launcher BFF и Tasker backend не публикуются напрямую наружу.
Reverse proxy обязан прокидывать:
- `Host`;
- `X-Forwarded-Proto`;
- `X-Forwarded-For`;
- WebSocket/HTTP upgrade для Tasker realtime/live.
## Runtime topology
Staging baseline добавлен как пример:
```text
platform/infra/docker-compose.staging.example.yml
platform/infra/reverse-proxy/Caddyfile.staging
platform/infra/.env.staging.example
NODEDC_TASKMANAGER/plane-app/plane.env.staging.example
```
Запускать его нужно только после копирования `.env.staging.example` в ignored `.env.staging` и замены всех placeholder secrets:
```bash
cd /Users/dcconstructions/Downloads/mnt/NODEDC/platform/infra
./scripts/check-staging-env.sh .env.staging
docker compose --env-file .env.staging -f docker-compose.staging.example.yml config
docker compose --env-file .env.staging -f docker-compose.staging.example.yml up -d
```
Для Tasker staging env:
```bash
cd /Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER
./scripts/check-tasker-staging-env.sh plane-app/plane.env.staging
docker compose --env-file plane-app/plane.env.staging -f plane-app/docker-compose.yaml config
```
Можно проверить example compose без реальных secret так:
```bash
NODEDC_STAGING_ENV_FILE=.env.staging.example \
docker compose --env-file .env.staging.example -f docker-compose.staging.example.yml config
```
Runtime правила:
- Authentik `server` и `worker` используют отдельный Postgres volume/network;
- Authentik worker не монтирует `/var/run/docker.sock`;
- Launcher хранит control-plane snapshot только в server-side storage;
- Tasker остаётся отдельным приложением со своей БД, storage и доменной моделью;
- Launcher не читает и не пишет Plane DB напрямую;
- интеграция Launcher -> Tasker идёт через internal API с отдельным `NODEDC_INTERNAL_ACCESS_TOKEN`.
## Обязательные env
Значения с `change-me`, local token и dev secrets запрещены в staging.
```text
COOKIE_SECURE=true
COOKIE_DOMAIN=.staging.nodedc.example
NODEDC_INTERNAL_ACCESS_TOKEN=<random-32-bytes-min>
SESSION_SECRET=<random-32-bytes-min>
AUTHENTIK_SECRET_KEY=<random-32-bytes-min>
PG_PASS=<random-32-bytes-min>
LAUNCHER_OIDC_ISSUER=https://auth.staging.nodedc.example/application/o/launcher/
LAUNCHER_OIDC_REDIRECT_URI=https://launcher.staging.nodedc.example/auth/callback
LAUNCHER_OIDC_LOGGED_OUT_REDIRECT_URI=https://launcher.staging.nodedc.example/auth/logged-out
PLANE_OIDC_ISSUER=https://auth.staging.nodedc.example/application/o/task-manager/
PLANE_OIDC_REDIRECT_URI=https://task.staging.nodedc.example/auth/oidc/callback
PLANE_NODEDC_ACCESS_ENFORCEMENT=1
PLANE_NODEDC_ACCESS_ENFORCE_UNLINKED=1
PLANE_NODEDC_ACCESS_TOKEN=<same-as-NODEDC_INTERNAL_ACCESS_TOKEN>
```
OIDC client secrets для Launcher и Tasker должны быть разными. Internal API token не должен совпадать ни с одним OIDC client secret.
## Trusted proxies
Локальный default `127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,::1/128` допустим только для dev.
В staging `AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS` и аналогичные параметры Tasker должны указывать только на фактический reverse-proxy subnet или ingress CIDR. Широкие private ranges без необходимости запрещены.
## Cookies и HTTPS
Для staging:
- TLS обязателен на всех публичных доменах;
- HTTP должен делать permanent redirect на HTTPS;
- `COOKIE_SECURE=true`;
- session cookies остаются `HttpOnly`;
- `SameSite=Lax` допустим для текущего top-level OIDC flow;
- включить HSTS после проверки сертификатов и redirect URI.
## Backup перед staging
Перед первым staging deploy и перед destructive security smoke:
- dump Authentik Postgres;
- dump Tasker/Plane Postgres;
- архив Launcher `server/storage`;
- архив Tasker uploads/MinIO data;
- архив `.env`/secret references без публикации в git;
- зафиксировать image tags Launcher/Tasker/Authentik.
Минимальный restore check: поднять копию DB/storage на isolated host или namespace и проверить login + открытие Tasker.
## Secrets rotation
Rotation policy до допуска внешних пользователей:
- `NODEDC_INTERNAL_ACCESS_TOKEN` — rotate при любом подозрении на утечку и перед production;
- OIDC client secrets — rotate отдельно для Launcher и Tasker;
- `SESSION_SECRET` — rotate с принудительным logout всех пользователей;
- DB passwords — rotate через maintenance window;
- старые secrets удаляются из `.env`, shell history и runtime logs.
## Acceptance перед людьми
Минимальный staging smoke:
1. Super sudo входит в Launcher через `https://launcher...`.
2. Обычный active user видит все карточки сервисов, но открыть может только разрешённые сервисы.
3. User без Task Manager app access видит карточку Task Manager в витрине, не может открыть сервис и получает deny по прямому `https://task...`.
4. Blocked/annulled user теряет Launcher и Tasker session после hard refresh.
5. Self-host workspace invite создаёт pending request в Launcher.
6. Launcher-managed workspace не принимает self-service invite request из Tasker.
7. Hard delete удаляет active WorkspaceMember/ProjectMember/IssueAssignee в Tasker.
8. Audit сохраняет admin actions, approve/reject и hard delete.
9. Повторный accept уже принятого invite отклоняется.
10. Прямые backend/internal endpoints без token возвращают `401`.

View File

@ -42,5 +42,6 @@ PLANE_OIDC_REDIRECT_URI=http://task.local.nodedc/auth/oidc/callback
# security # security
SESSION_SECRET=change-me-generate-with-infra-scripts-init-dev-env SESSION_SECRET=change-me-generate-with-infra-scripts-init-dev-env
NODEDC_INTERNAL_ACCESS_TOKEN=change-me-generate-with-infra-scripts-init-dev-env
COOKIE_DOMAIN=.local.nodedc COOKIE_DOMAIN=.local.nodedc
COOKIE_SECURE=false COOKIE_SECURE=false

View File

@ -0,0 +1,50 @@
# staging domains
AUTH_DOMAIN=auth.staging.nodedc.example
LAUNCHER_DOMAIN=launcher.staging.nodedc.example
TASK_DOMAIN=task.staging.nodedc.example
# edge proxy
ACME_EMAIL=admin@nodedc.example
PLATFORM_HTTP_PORT=80
PLATFORM_HTTPS_PORT=443
PLATFORM_PROXY_IMAGE=caddy:2-alpine
STAGING_LAUNCHER_UPSTREAM=launcher:5173
STAGING_TASK_MANAGER_UPSTREAM=task-manager-proxy:80
# authentik image
AUTHENTIK_IMAGE=ghcr.io/goauthentik/server
AUTHENTIK_TAG=2026.2.2
# authentik database
PG_DB=authentik
PG_USER=authentik
PG_PASS=replace-with-random-staging-secret
# authentik
AUTHENTIK_SECRET_KEY=replace-with-random-staging-secret
AUTHENTIK_ERROR_REPORTING__ENABLED=false
AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS=replace-with-reverse-proxy-subnet
# launcher oidc
LAUNCHER_OIDC_ISSUER=https://auth.staging.nodedc.example/application/o/launcher/
LAUNCHER_OIDC_CLIENT_ID=nodedc-launcher
LAUNCHER_OIDC_CLIENT_SECRET=replace-with-random-staging-secret
LAUNCHER_OIDC_REDIRECT_URI=https://launcher.staging.nodedc.example/auth/callback
LAUNCHER_OIDC_LOGGED_OUT_REDIRECT_URI=https://launcher.staging.nodedc.example/auth/logged-out
# plane oidc
PLANE_OIDC_ISSUER=https://auth.staging.nodedc.example/application/o/task-manager/
PLANE_OIDC_CLIENT_ID=nodedc-task-manager
PLANE_OIDC_CLIENT_SECRET=replace-with-random-staging-secret
PLANE_OIDC_REDIRECT_URI=https://task.staging.nodedc.example/auth/oidc/callback
# security
SESSION_SECRET=replace-with-random-staging-secret
NODEDC_INTERNAL_ACCESS_TOKEN=replace-with-random-staging-secret
COOKIE_DOMAIN=.staging.nodedc.example
COOKIE_SECURE=true
# tasker downstream security
PLANE_NODEDC_ACCESS_ENFORCEMENT=1
PLANE_NODEDC_ACCESS_ENFORCE_UNLINKED=1
PLANE_NODEDC_ACCESS_TOKEN=replace-with-same-value-as-NODEDC_INTERNAL_ACCESS_TOKEN

View File

@ -41,7 +41,9 @@ APP_SPECS = [
"redirect_uri_env": "LAUNCHER_OIDC_REDIRECT_URI", "redirect_uri_env": "LAUNCHER_OIDC_REDIRECT_URI",
"logged_out_redirect_uri_env": "LAUNCHER_OIDC_LOGGED_OUT_REDIRECT_URI", "logged_out_redirect_uri_env": "LAUNCHER_OIDC_LOGGED_OUT_REDIRECT_URI",
"default_logged_out_redirect_uri": "http://launcher.local.nodedc/auth/logged-out", "default_logged_out_redirect_uri": "http://launcher.local.nodedc/auth/logged-out",
"launch_url_env": "LAUNCHER_BASE_URL",
"launch_url": "http://launcher.local.nodedc", "launch_url": "http://launcher.local.nodedc",
"logout_uri_env": "LAUNCHER_LOGOUT_URI",
"logout_uri": "http://launcher.local.nodedc/logout", "logout_uri": "http://launcher.local.nodedc/logout",
"groups": ["nodedc:superadmin", "nodedc:launcher:admin", "nodedc:launcher:user"], "groups": ["nodedc:superadmin", "nodedc:launcher:admin", "nodedc:launcher:user"],
"description": "NODE.DC control plane launcher.", "description": "NODE.DC control plane launcher.",
@ -53,7 +55,9 @@ APP_SPECS = [
"client_id_env": "PLANE_OIDC_CLIENT_ID", "client_id_env": "PLANE_OIDC_CLIENT_ID",
"client_secret_env": "PLANE_OIDC_CLIENT_SECRET", "client_secret_env": "PLANE_OIDC_CLIENT_SECRET",
"redirect_uri_env": "PLANE_OIDC_REDIRECT_URI", "redirect_uri_env": "PLANE_OIDC_REDIRECT_URI",
"launch_url_env": "TASK_BASE_URL",
"launch_url": "http://task.local.nodedc", "launch_url": "http://task.local.nodedc",
"logout_uri_env": "TASK_LOGOUT_URI",
"logout_uri": "http://task.local.nodedc/logout", "logout_uri": "http://task.local.nodedc/logout",
"groups": ["nodedc:superadmin", "nodedc:taskmanager:admin", "nodedc:taskmanager:user"], "groups": ["nodedc:superadmin", "nodedc:taskmanager:admin", "nodedc:taskmanager:user"],
"description": "NODE.DC Plane-based task manager.", "description": "NODE.DC Plane-based task manager.",
@ -260,7 +264,7 @@ def ensure_provider(spec, mappings):
RedirectURI(RedirectURIMatchingMode.STRICT, redirect_uri) RedirectURI(RedirectURIMatchingMode.STRICT, redirect_uri)
for redirect_uri in dict.fromkeys(redirect_uri_values) for redirect_uri in dict.fromkeys(redirect_uri_values)
] ]
provider.logout_uri = spec["logout_uri"] provider.logout_uri = optional_env(spec.get("logout_uri_env", ""), spec["logout_uri"])
provider.logout_method = OAuth2LogoutMethod.FRONTCHANNEL provider.logout_method = OAuth2LogoutMethod.FRONTCHANNEL
provider.include_claims_in_id_token = True provider.include_claims_in_id_token = True
provider.sub_mode = SubModes.USER_UUID provider.sub_mode = SubModes.USER_UUID
@ -282,7 +286,7 @@ def ensure_application(spec, provider, groups):
application.slug = spec["slug"] application.slug = spec["slug"]
application.group = "NODE.DC" application.group = "NODE.DC"
application.provider = provider application.provider = provider
application.meta_launch_url = spec["launch_url"] application.meta_launch_url = optional_env(spec.get("launch_url_env", ""), spec["launch_url"])
application.meta_description = spec["description"] application.meta_description = spec["description"]
application.meta_publisher = "NODE.DC" application.meta_publisher = "NODE.DC"
application.open_in_new_tab = False application.open_in_new_tab = False

View File

@ -266,7 +266,19 @@
function getLauncherBaseUrl() { function getLauncherBaseUrl() {
const hostname = window.location.hostname; const hostname = window.location.hostname;
const launcherHostname = hostname.startsWith("auth.") ? `launcher.${hostname.slice(5)}` : "launcher.local.nodedc"; const launcherHostnames = {
"id.nodedc.ru": "hub.nodedc.ru",
"auth.nodedc.ru": "hub.nodedc.ru",
"id.notdc.ru": "launcher.notdc.ru",
"auth.notdc.ru": "launcher.notdc.ru",
};
const launcherHostname =
launcherHostnames[hostname] ||
(hostname.startsWith("auth.")
? `launcher.${hostname.slice(5)}`
: hostname.startsWith("id.")
? `hub.${hostname.slice(3)}`
: "hub.nodedc.ru");
const port = window.location.port ? `:${window.location.port}` : ""; const port = window.location.port ? `:${window.location.port}` : "";
return `${window.location.protocol}//${launcherHostname}${port}/`; return `${window.location.protocol}//${launcherHostname}${port}/`;
@ -691,8 +703,8 @@
try { try {
const url = new URL(rawUrl); const url = new URL(rawUrl);
const allowedHosts = new Set([ const allowedHosts = new Set([
"launcher.local.nodedc", "hub.nodedc.ru",
"launcher.local.notdc", "hub.notdc.ru",
"launcher.notdc.ru", "launcher.notdc.ru",
"platform.notdc.ru", "platform.notdc.ru",
"notdc.ru", "notdc.ru",

View File

@ -0,0 +1,108 @@
name: nodedc-platform-staging
services:
reverse-proxy:
image: ${PLATFORM_PROXY_IMAGE:-caddy:2-alpine}
restart: unless-stopped
env_file:
- path: ${NODEDC_STAGING_ENV_FILE:-.env.staging}
required: true
ports:
- "${PLATFORM_HTTP_PORT:-80}:80"
- "${PLATFORM_HTTPS_PORT:-443}:443"
volumes:
- ./reverse-proxy/Caddyfile.staging:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
depends_on:
authentik-server:
condition: service_started
networks:
- edge
- identity
postgresql-authentik:
image: docker.io/library/postgres:16-alpine
restart: unless-stopped
env_file:
- path: ${NODEDC_STAGING_ENV_FILE:-.env.staging}
required: true
environment:
POSTGRES_DB: ${PG_DB:-authentik}
POSTGRES_PASSWORD: ${PG_PASS:?database password required}
POSTGRES_USER: ${PG_USER:-authentik}
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
interval: 30s
timeout: 5s
retries: 5
start_period: 20s
volumes:
- authentik-database:/var/lib/postgresql/data
networks:
- identity
authentik-server:
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.2.2}
command: server
restart: unless-stopped
env_file:
- path: ${NODEDC_STAGING_ENV_FILE:-.env.staging}
required: true
environment:
AUTHENTIK_POSTGRESQL__HOST: postgresql-authentik
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS:?database password required}
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
AUTHENTIK_ERROR_REPORTING__ENABLED: ${AUTHENTIK_ERROR_REPORTING__ENABLED:-false}
AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS: ${AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS:?trusted proxy CIDR required}
depends_on:
postgresql-authentik:
condition: service_healthy
expose:
- "9000"
- "9443"
shm_size: 512mb
volumes:
- authentik-data:/data
- ./authentik/custom-templates:/templates:ro
networks:
- identity
authentik-worker:
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.2.2}
command: worker
restart: unless-stopped
env_file:
- path: ${NODEDC_STAGING_ENV_FILE:-.env.staging}
required: true
environment:
AUTHENTIK_POSTGRESQL__HOST: postgresql-authentik
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS:?database password required}
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
AUTHENTIK_ERROR_REPORTING__ENABLED: ${AUTHENTIK_ERROR_REPORTING__ENABLED:-false}
depends_on:
postgresql-authentik:
condition: service_healthy
shm_size: 512mb
volumes:
- authentik-data:/data
- authentik-certs:/certs
- ./authentik/custom-templates:/templates:ro
networks:
- identity
networks:
edge:
identity:
internal: true
volumes:
authentik-database:
authentik-data:
authentik-certs:
caddy-data:
caddy-config:

View File

@ -0,0 +1,57 @@
{
email {$ACME_EMAIL}
}
http://{$AUTH_DOMAIN} {
redir https://{host}{uri} permanent
}
http://{$LAUNCHER_DOMAIN} {
redir https://{host}{uri} permanent
}
http://{$TASK_DOMAIN} {
redir https://{host}{uri} permanent
}
{$AUTH_DOMAIN} {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
}
@auth_root path /
redir @auth_root https://{$LAUNCHER_DOMAIN}/ 302
@auth_user_dashboard path /if/user /if/user/*
redir @auth_user_dashboard https://{$LAUNCHER_DOMAIN}/ 302
reverse_proxy authentik-server:9000 {
header_up Host {host}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-For {remote_host}
}
}
{$LAUNCHER_DOMAIN} {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
}
reverse_proxy {$STAGING_LAUNCHER_UPSTREAM} {
header_up Host {host}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-For {remote_host}
}
}
{$TASK_DOMAIN} {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
}
reverse_proxy {$STAGING_TASK_MANAGER_UPSTREAM} {
header_up Host {host}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-For {remote_host}
}
}

View File

@ -58,6 +58,7 @@ ensure_env_value LAUNCHER_OIDC_CLIENT_ID nodedc-launcher
ensure_env_value PLANE_OIDC_CLIENT_ID nodedc-task-manager ensure_env_value PLANE_OIDC_CLIENT_ID nodedc-task-manager
ensure_env_value LAUNCHER_OIDC_CLIENT_SECRET "$(rand_hex 48)" ensure_env_value LAUNCHER_OIDC_CLIENT_SECRET "$(rand_hex 48)"
ensure_env_value PLANE_OIDC_CLIENT_SECRET "$(rand_hex 48)" ensure_env_value PLANE_OIDC_CLIENT_SECRET "$(rand_hex 48)"
ensure_env_value NODEDC_INTERNAL_ACCESS_TOKEN "$(rand_hex 48)"
ensure_env_value LAUNCHER_OIDC_REDIRECT_URI http://launcher.local.nodedc/auth/callback ensure_env_value LAUNCHER_OIDC_REDIRECT_URI http://launcher.local.nodedc/auth/callback
ensure_env_value PLANE_OIDC_REDIRECT_URI http://task.local.nodedc/auth/oidc/callback ensure_env_value PLANE_OIDC_REDIRECT_URI http://task.local.nodedc/auth/oidc/callback

View File

@ -0,0 +1,137 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ENV_FILE="${1:-"$ROOT_DIR/.env.staging"}"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Missing staging env file: $ENV_FILE" >&2
exit 1
fi
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
failures=0
fail() {
echo "FAIL: $*" >&2
failures=$((failures + 1))
}
require_value() {
local name="$1"
local value="${!name:-}"
if [[ -z "$value" ]]; then
fail "$name is required"
fi
}
require_secret() {
local name="$1"
local value="${!name:-}"
require_value "$name"
if [[ "$value" =~ change-me|local-dev|replace-with|example ]]; then
fail "$name uses a placeholder/dev value"
fi
if [[ ${#value} -lt 32 ]]; then
fail "$name must be at least 32 characters"
fi
}
require_https_url() {
local name="$1"
local value="${!name:-}"
require_value "$name"
if [[ "$value" != https://* ]]; then
fail "$name must use https://"
fi
}
require_staging_domain() {
local name="$1"
local value="${!name:-}"
require_value "$name"
if [[ "$value" == *.local.nodedc || "$value" == "localhost" || "$value" == 127.* ]]; then
fail "$name must not use local/dev domain"
fi
}
require_value AUTH_DOMAIN
require_value LAUNCHER_DOMAIN
require_value TASK_DOMAIN
require_value STAGING_LAUNCHER_UPSTREAM
require_value STAGING_TASK_MANAGER_UPSTREAM
require_value AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS
require_staging_domain AUTH_DOMAIN
require_staging_domain LAUNCHER_DOMAIN
require_staging_domain TASK_DOMAIN
require_https_url LAUNCHER_OIDC_ISSUER
require_https_url LAUNCHER_OIDC_REDIRECT_URI
require_https_url LAUNCHER_OIDC_LOGGED_OUT_REDIRECT_URI
require_https_url PLANE_OIDC_ISSUER
require_https_url PLANE_OIDC_REDIRECT_URI
require_secret PG_PASS
require_secret AUTHENTIK_SECRET_KEY
require_secret SESSION_SECRET
require_secret NODEDC_INTERNAL_ACCESS_TOKEN
require_secret LAUNCHER_OIDC_CLIENT_SECRET
require_secret PLANE_OIDC_CLIENT_SECRET
if [[ "${COOKIE_SECURE:-}" != "true" ]]; then
fail "COOKIE_SECURE must be true"
fi
if [[ "${COOKIE_DOMAIN:-}" == *.local.nodedc || "${COOKIE_DOMAIN:-}" == "localhost" ]]; then
fail "COOKIE_DOMAIN must not use local/dev domain"
fi
if [[ "${NODEDC_INTERNAL_ACCESS_TOKEN:-}" == "${LAUNCHER_OIDC_CLIENT_SECRET:-}" ]]; then
fail "NODEDC_INTERNAL_ACCESS_TOKEN must differ from LAUNCHER_OIDC_CLIENT_SECRET"
fi
if [[ "${NODEDC_INTERNAL_ACCESS_TOKEN:-}" == "${PLANE_OIDC_CLIENT_SECRET:-}" ]]; then
fail "NODEDC_INTERNAL_ACCESS_TOKEN must differ from PLANE_OIDC_CLIENT_SECRET"
fi
if [[ "${LAUNCHER_OIDC_CLIENT_SECRET:-}" == "${PLANE_OIDC_CLIENT_SECRET:-}" ]]; then
fail "Launcher and Tasker OIDC client secrets must differ"
fi
if [[ "${PLANE_NODEDC_ACCESS_TOKEN:-}" != "${NODEDC_INTERNAL_ACCESS_TOKEN:-}" ]]; then
fail "PLANE_NODEDC_ACCESS_TOKEN must match NODEDC_INTERNAL_ACCESS_TOKEN"
fi
if [[ "${PLANE_NODEDC_ACCESS_ENFORCEMENT:-}" != "1" ]]; then
fail "PLANE_NODEDC_ACCESS_ENFORCEMENT must be 1"
fi
if [[ "${PLANE_NODEDC_ACCESS_ENFORCE_UNLINKED:-}" != "1" ]]; then
fail "PLANE_NODEDC_ACCESS_ENFORCE_UNLINKED must be 1"
fi
case "${AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS:-}" in
*"0.0.0.0/0"*|*"::/0"*|*"10.0.0.0/8"*|*"172.16.0.0/12"*|*"192.168.0.0/16"*|*"127.0.0.0/8"*)
fail "AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS must be limited to the actual reverse-proxy/ingress subnet"
;;
esac
if [[ $failures -gt 0 ]]; then
echo "Staging env check failed with $failures issue(s)." >&2
exit 1
fi
echo "Staging env check passed: $ENV_FILE"

View File

@ -56,6 +56,7 @@ PLANE_OIDC_REDIRECT_URI=http://task.local.nodedc/auth/oidc/callback
# security # security
SESSION_SECRET=$(rand 48) SESSION_SECRET=$(rand 48)
NODEDC_INTERNAL_ACCESS_TOKEN=$(openssl rand -hex 48 | tr -d '\n')
COOKIE_DOMAIN=.local.nodedc COOKIE_DOMAIN=.local.nodedc
COOKIE_SECURE=false COOKIE_SECURE=false
EOF EOF

View File

@ -0,0 +1,46 @@
# Parallel Synology HTTP deployment.
# This intentionally uses high ports and does not touch existing nodedc-demo.
AUTH_DOMAIN=auth.nas.nodedc
LAUNCHER_DOMAIN=launcher.nas.nodedc
TASK_DOMAIN=task.nas.nodedc
NODEDC_PUBLIC_HTTP_PORT=18080
PLATFORM_HTTP_PORT=18080
SYNOLOGY_TASK_MANAGER_UPSTREAM=host.docker.internal:18090
AUTHENTIK_IMAGE=ghcr.io/goauthentik/server
AUTHENTIK_TAG=2026.2.2
AUTHENTIK_ERROR_REPORTING__ENABLED=false
AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS=127.0.0.0/8,172.16.0.0/12
PG_DB=authentik
PG_USER=authentik
PG_PASS=replace-with-random-synology-secret
AUTHENTIK_SECRET_KEY=replace-with-random-synology-secret
AUTHENTIK_BOOTSTRAP_EMAIL=admin@nodedc.local
AUTHENTIK_BOOTSTRAP_PASSWORD=replace-with-random-synology-secret
AUTHENTIK_BOOTSTRAP_TOKEN=replace-with-random-synology-secret
LAUNCHER_BASE_URL=https://hub.nodedc.ru
TASK_BASE_URL=https://ops.nodedc.ru
TASK_LOGOUT_URI=https://ops.nodedc.ru/logout
TASK_INTERNAL_LOGOUT_URL=https://ops.nodedc.ru/api/internal/nodedc/logout/
LAUNCHER_OIDC_ISSUER=https://id.nodedc.ru/application/o/launcher/
LAUNCHER_OIDC_CLIENT_ID=nodedc-launcher
LAUNCHER_OIDC_CLIENT_SECRET=replace-with-random-synology-secret
LAUNCHER_OIDC_REDIRECT_URI=https://hub.nodedc.ru/auth/callback
LAUNCHER_OIDC_LOGGED_OUT_REDIRECT_URI=https://hub.nodedc.ru/auth/logged-out
LAUNCHER_LOGOUT_URI=https://hub.nodedc.ru/logout
LAUNCHER_COOKIE_DOMAIN=.nodedc.ru
PLANE_OIDC_ISSUER=https://id.nodedc.ru/application/o/task-manager/
PLANE_OIDC_CLIENT_ID=nodedc-task-manager
PLANE_OIDC_CLIENT_SECRET=replace-with-random-synology-secret
PLANE_OIDC_REDIRECT_URI=https://ops.nodedc.ru/auth/oidc/callback
NODEDC_AUTHENTIK_BASE_URL=http://authentik-server:9000
NODEDC_INTERNAL_ACCESS_TOKEN=replace-with-random-synology-secret
SESSION_SECRET=replace-with-random-synology-secret
COOKIE_DOMAIN=.nas.nodedc
COOKIE_SECURE=false

View File

@ -0,0 +1,71 @@
{
auto_https off
}
http://{$AUTH_DOMAIN} {
@auth_root path /
redir @auth_root http://{$LAUNCHER_DOMAIN}:{$NODEDC_PUBLIC_HTTP_PORT}/ 302
@auth_user_dashboard path /if/user /if/user/*
redir @auth_user_dashboard http://{$LAUNCHER_DOMAIN}:{$NODEDC_PUBLIC_HTTP_PORT}/ 302
reverse_proxy authentik-server:9000 {
header_up Host {http.request.host}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-For {remote_host}
}
}
http://{$LAUNCHER_DOMAIN} {
reverse_proxy launcher:5173 {
header_up Host {http.request.host}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-For {remote_host}
}
}
http://{$TASK_DOMAIN} {
reverse_proxy {$SYNOLOGY_TASK_MANAGER_UPSTREAM} {
header_up Host {http.request.host}
header_up X-Forwarded-Host {http.request.host}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Port {$NODEDC_PUBLIC_HTTP_PORT}
header_up X-Forwarded-For {remote_host}
}
}
http://id.nodedc.ru {
@auth_root path /
redir @auth_root https://hub.nodedc.ru/ 302
@auth_user_dashboard path /if/user /if/user/*
redir @auth_user_dashboard https://hub.nodedc.ru/ 302
reverse_proxy authentik-server:9000 {
header_up Host id.nodedc.ru
header_up X-Forwarded-Host id.nodedc.ru
header_up X-Forwarded-Proto https
header_up X-Forwarded-Port 443
header_up X-Forwarded-For {remote_host}
}
}
http://hub.nodedc.ru {
reverse_proxy launcher:5173 {
header_up Host hub.nodedc.ru
header_up X-Forwarded-Host hub.nodedc.ru
header_up X-Forwarded-Proto https
header_up X-Forwarded-Port 443
header_up X-Forwarded-For {remote_host}
}
}
http://ops.nodedc.ru {
reverse_proxy {$SYNOLOGY_TASK_MANAGER_UPSTREAM} {
header_up Host ops.nodedc.ru
header_up X-Forwarded-Host ops.nodedc.ru
header_up X-Forwarded-Proto https
header_up X-Forwarded-Port 443
header_up X-Forwarded-For {remote_host}
}
}

121
infra/synology/README.md Normal file
View File

@ -0,0 +1,121 @@
# NODE.DC Synology deploy
Эта папка фиксирует текущий воспроизводимый NAS-deploy для `nodedc-platform` на Synology RS1221RP+.
## Правила
- Не выполнять `docker stop`, `docker restart`, `docker compose down`, `docker system prune` для старых проектов.
- Новый compose project: `nodedc-platform`.
- Новая папка на NAS: `/volume1/docker/nodedc-platform`.
- Внутренний HTTP edge использует `18080`, Tasker upstream — `18090`, Ops Agents Gateway upstream — `18190`.
- Старые порты `9000` и `5678` заняты старым `nodedc-demo` и не используются.
## Текущие внешние домены
```text
https://id.nodedc.ru -> Authentik
https://hub.nodedc.ru -> Launcher / Hub
https://ops.nodedc.ru -> Tasker / Operational Core
https://ops-agents.nodedc.ru -> Ops Agents Gateway / MCP
```
В `Caddyfile.http` эти домены проксируются через локальный HTTP edge, но upstream получает `X-Forwarded-Proto: https` и `X-Forwarded-Port: 443`.
## Локальные домены для первичной проверки
На Mac для первичной проверки добавить в `/etc/hosts`:
```text
172.22.0.222 auth.nas.nodedc
172.22.0.222 launcher.nas.nodedc
172.22.0.222 task.nas.nodedc
```
Первичные URL:
```text
http://auth.nas.nodedc:18080
http://launcher.nas.nodedc:18080
http://task.nas.nodedc:18080
http://task.nas.nodedc:18090
```
## Что входит
- `docker-compose.platform-http.yml` поднимает новый Authentik, Launcher и Caddy edge.
- `Caddyfile.http` маршрутизирует локальные `auth/launcher/task.nas.nodedc` и внешние `id/hub/ops.nodedc.ru`.
- `deploy-current.sh` синхронизирует compose, Caddyfile, Authentik templates и опционально Launcher source в NAS mount.
- `backup-current.sh` делает snapshot Launcher runtime/uploads/Auth templates/config и готовит команду `pg_dump` для Authentik Postgres.
- Tasker поднимается отдельным compose из `NODEDC_TASKMANAGER/plane-app/docker-compose.yaml` на порту `18090`.
- Ops Agents Gateway поднимается отдельным compose из `NODEDC_TASKMANAGER_CODEXAPI/docker-compose.synology.yml` на `127.0.0.1:18190`; Synology reverse proxy должен вести `ops-agents.nodedc.ru` на этот порт, а не на `18090`.
## Синхронизация текущего состояния
С Mac, при смонтированном `/Volumes/docker`:
```bash
cd /Users/dcconstructions/Downloads/mnt/NODEDC/platform
NAS_ROOT=/Volumes/docker/nodedc-platform \
LAUNCHER_REPO=/Users/dcconstructions/Downloads/mnt/data/nodedc_launcher \
./infra/synology/deploy-current.sh
```
Скрипт не запускает Docker сам: на NAS `sudo` интерактивный, поэтому команды применения печатаются в конце.
## Backup текущего состояния
С Mac, при смонтированном `/Volumes/docker`:
```bash
cd /Users/dcconstructions/Downloads/mnt/NODEDC/platform
NAS_ROOT=/Volumes/docker/nodedc-platform ./infra/synology/backup-current.sh
```
Файловый backup создаётся в `/Volumes/docker/nodedc-platform/backups/platform-current-*`.
Для Authentik Postgres dump нужно выполнить напечатанную команду на Synology, потому что Docker доступен через интерактивный `sudo`:
```bash
bash /volume1/docker/nodedc-platform/backups/platform-current-YYYYMMDD-HHMMSS/run-authentik-db-dump-on-synology.sh
```
## Что нужно перед запуском
- Собрать или загрузить `linux/amd64` images:
- `nodedc/launcher:local`
- `nodedc/plane-frontend:ru`
- `nodedc/plane-admin:ru`
- `nodedc/plane-space:ru`
- `nodedc/plane-live:local`
- `nodedc/plane-backend:local`
- `nodedc/plane-proxy:ru`
- Для Ops Agents Gateway отдельный registry image пока не обязателен: deploy из source repo выполняется через `docker compose --env-file .env -f docker-compose.synology.yml up -d --build`.
- Создать `.env.synology` из `.env.synology.example` и заменить все `replace-with-*`.
- Создать `plane.env.synology` для Tasker из `plane.env.staging.example`, но с HTTP URL на `*.nas.nodedc:18080` и портами `18090/18490`.
## Обязательные runtime-права
Launcher пишет runtime snapshot и uploads под пользователем `node` (`uid=1000`). После создания NAS-папок:
```bash
cd /volume1/docker/nodedc-platform/platform
sudo mkdir -p ../launcher/server-storage ../launcher/uploads
sudo chown -R 1000:1000 ../launcher/server-storage ../launcher/uploads
sudo chmod -R u+rwX,g+rwX ../launcher/server-storage ../launcher/uploads
```
Проверка внутри контейнера:
```bash
sudo /usr/local/bin/docker exec nodedc-platform-launcher-1 sh -lc \
'touch /app/server/storage/.write-test && rm /app/server/storage/.write-test && echo storage-ok'
```
## Проверки после деплоя
```bash
curl -k -sS --compressed https://id.nodedc.ru/if/flow/default-authentication-flow/ \
| grep -aE 'hub.nodedc.ru|launcher.local|getLauncherBaseUrl|Запросить доступ'
```
В выводе должны быть `id.nodedc.ru -> hub.nodedc.ru` и не должно быть `launcher.local`.

112
infra/synology/backup-current.sh Executable file
View File

@ -0,0 +1,112 @@
#!/usr/bin/env bash
set -euo pipefail
NAS_ROOT="${NAS_ROOT:-/Volumes/docker/nodedc-platform}"
NAS_PLATFORM_DIR="${NAS_PLATFORM_DIR:-/volume1/docker/nodedc-platform/platform}"
BACKUP_ROOT="${BACKUP_ROOT:-${NAS_ROOT}/backups}"
TIMESTAMP="${TIMESTAMP:-$(date +%Y%m%d-%H%M%S)}"
BACKUP_DIR="${BACKUP_DIR:-${BACKUP_ROOT}/platform-current-${TIMESTAMP}}"
DOCKER_BIN="${DOCKER_BIN:-/usr/local/bin/docker}"
COMPOSE_FILE="${COMPOSE_FILE:-${NAS_PLATFORM_DIR}/docker-compose.platform-http.yml}"
ENV_FILE="${ENV_FILE:-${NAS_PLATFORM_DIR}/.env.synology}"
mkdir -p "${BACKUP_DIR}/files/platform" \
"${BACKUP_DIR}/files/launcher" \
"${BACKUP_DIR}/files/authentik"
rsync_dir() {
local source="$1"
local destination="$2"
if [[ -e "${source}" ]]; then
rsync -a --delete "${source}" "${destination}"
else
echo "skip missing: ${source}" | tee -a "${BACKUP_DIR}/warnings.log" >&2
fi
}
rsync_file() {
local source="$1"
local destination="$2"
if [[ -f "${source}" ]]; then
rsync -a "${source}" "${destination}"
else
echo "skip missing: ${source}" | tee -a "${BACKUP_DIR}/warnings.log" >&2
fi
}
rsync_dir "${NAS_ROOT}/launcher/server-storage/" "${BACKUP_DIR}/files/launcher/server-storage/"
rsync_dir "${NAS_ROOT}/launcher/uploads/" "${BACKUP_DIR}/files/launcher/uploads/"
rsync_dir "${NAS_ROOT}/authentik/custom-templates/" "${BACKUP_DIR}/files/authentik/custom-templates/"
rsync_dir "${NAS_ROOT}/platform/authentik/" "${BACKUP_DIR}/files/platform/authentik/"
rsync_file "${NAS_ROOT}/platform/.env.synology" "${BACKUP_DIR}/files/platform/"
rsync_file "${NAS_ROOT}/platform/.env.synology.example" "${BACKUP_DIR}/files/platform/"
rsync_file "${NAS_ROOT}/platform/docker-compose.platform-http.yml" "${BACKUP_DIR}/files/platform/"
rsync_file "${NAS_ROOT}/platform/Caddyfile.http" "${BACKUP_DIR}/files/platform/"
cat > "${BACKUP_DIR}/manifest.txt" <<EOF
NODE.DC platform current backup
timestamp=${TIMESTAMP}
nas_root=${NAS_ROOT}
backup_dir=${BACKUP_DIR}
Contains:
- Launcher runtime snapshot: launcher/server-storage
- Launcher uploads: launcher/uploads
- Authentik custom templates: authentik/custom-templates
- Platform runtime config: platform/.env.synology, compose, Caddyfile
Secrets:
- files/platform/.env.synology contains live secrets.
- Keep this backup private.
EOF
cat > "${BACKUP_DIR}/run-authentik-db-dump-on-synology.sh" <<EOF
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR="/volume1/docker/nodedc-platform/backups/$(basename "${BACKUP_DIR}")"
cd "${NAS_PLATFORM_DIR}"
sudo "${DOCKER_BIN}" compose \\
--env-file "${ENV_FILE}" \\
-f "${COMPOSE_FILE}" \\
exec -T postgresql-authentik \\
sh -lc 'pg_dump -U "\${POSTGRES_USER:-authentik}" -d "\${POSTGRES_DB:-authentik}" --format=custom --no-owner --no-acl' \\
> "\${BACKUP_DIR}/authentik-postgres.dump"
sudo "${DOCKER_BIN}" compose \\
--env-file "${ENV_FILE}" \\
-f "${COMPOSE_FILE}" \\
exec -T postgresql-authentik \\
sh -lc 'pg_restore --list /dev/stdin >/dev/null' \\
< "\${BACKUP_DIR}/authentik-postgres.dump"
if command -v sha256sum >/dev/null 2>&1; then
(cd "\${BACKUP_DIR}" && sha256sum authentik-postgres.dump > SHA256SUMS)
else
(cd "\${BACKUP_DIR}" && shasum -a 256 authentik-postgres.dump > SHA256SUMS)
fi
echo "authentik-db-dump-ok: \${BACKUP_DIR}/authentik-postgres.dump"
EOF
chmod +x "${BACKUP_DIR}/run-authentik-db-dump-on-synology.sh"
find "${BACKUP_DIR}" -name @eaDir -prune -o -type d -exec chmod 700 {} \;
find "${BACKUP_DIR}" -name @eaDir -prune -o -type f -exec chmod 600 {} \;
chmod 700 "${BACKUP_DIR}/run-authentik-db-dump-on-synology.sh"
if [[ -x "${DOCKER_BIN}" && "${NAS_ROOT}" == /volume1/* ]]; then
"${BACKUP_DIR}/run-authentik-db-dump-on-synology.sh"
else
cat <<EOF
file-backup-ok: ${BACKUP_DIR}
DB dump was not run from this host.
Run on Synology:
bash /volume1/docker/nodedc-platform/backups/$(basename "${BACKUP_DIR}")/run-authentik-db-dump-on-synology.sh
EOF
fi

View File

@ -0,0 +1,78 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
PLATFORM_REPO="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
NAS_ROOT="${NAS_ROOT:-/Volumes/docker/nodedc-platform}"
LAUNCHER_REPO="${LAUNCHER_REPO:-}"
if [[ ! -d "${NAS_ROOT}" ]]; then
echo "NAS_ROOT not found: ${NAS_ROOT}" >&2
echo "Set NAS_ROOT=/path/to/nodedc-platform when the Synology share is mounted elsewhere." >&2
exit 1
fi
mkdir -p "${NAS_ROOT}/platform" "${NAS_ROOT}/authentik/custom-templates"
rsync -av \
"${PLATFORM_REPO}/infra/synology/docker-compose.platform-http.yml" \
"${NAS_ROOT}/platform/docker-compose.platform-http.yml"
rsync -av \
"${PLATFORM_REPO}/infra/synology/Caddyfile.http" \
"${NAS_ROOT}/platform/Caddyfile.http"
rsync -av \
"${PLATFORM_REPO}/infra/synology/deploy-current.sh" \
"${PLATFORM_REPO}/infra/synology/backup-current.sh" \
"${NAS_ROOT}/platform/"
rsync -av --delete \
"${PLATFORM_REPO}/infra/authentik/custom-templates/" \
"${NAS_ROOT}/authentik/custom-templates/"
if [[ -n "${LAUNCHER_REPO}" ]]; then
if [[ ! -d "${LAUNCHER_REPO}" ]]; then
echo "LAUNCHER_REPO not found: ${LAUNCHER_REPO}" >&2
exit 1
fi
mkdir -p "${NAS_ROOT}/launcher/source"
rsync -av --delete \
--exclude='.git/' \
--exclude='node_modules/' \
--exclude='dist/' \
--exclude='server/storage/*' \
--exclude='public/storage/uploads/*' \
"${LAUNCHER_REPO}/" \
"${NAS_ROOT}/launcher/source/"
else
echo "LAUNCHER_REPO is not set; launcher source was not synced."
fi
cat <<'EOF'
Synced files to NAS mount. Run on Synology to apply runtime changes:
cd /volume1/docker/nodedc-platform/platform
sudo mkdir -p ../launcher/server-storage ../launcher/uploads
sudo chown -R 1000:1000 ../launcher/server-storage ../launcher/uploads
sudo chmod -R u+rwX,g+rwX ../launcher/server-storage ../launcher/uploads
cd /volume1/docker/nodedc-platform/launcher/source
sudo /usr/local/bin/docker build -t nodedc/launcher:local .
cd /volume1/docker/nodedc-platform/platform
sudo /usr/local/bin/docker compose \
--env-file /volume1/docker/nodedc-platform/platform/.env.synology \
-f /volume1/docker/nodedc-platform/platform/docker-compose.platform-http.yml \
up -d --force-recreate launcher reverse-proxy authentik-server authentik-worker
Verify:
sudo /usr/local/bin/docker exec nodedc-platform-launcher-1 sh -lc \
'touch /app/server/storage/.write-test && rm /app/server/storage/.write-test && echo storage-ok'
curl -k -sS --compressed https://id.nodedc.ru/if/flow/default-authentication-flow/ \
| grep -aE 'hub.nodedc.ru|launcher.local|getLauncherBaseUrl|Запросить доступ'
EOF

View File

@ -0,0 +1,136 @@
name: nodedc-platform
services:
reverse-proxy:
image: caddy:2-alpine
restart: unless-stopped
env_file:
- ${NODEDC_SYNOLOGY_ENV_FILE:-.env.synology}
ports:
- "${PLATFORM_HTTP_PORT:-18080}:80"
volumes:
- ./Caddyfile.http:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
depends_on:
authentik-server:
condition: service_started
launcher:
condition: service_started
extra_hosts:
- "id.nodedc.ru:host-gateway"
- "hub.nodedc.ru:host-gateway"
- "ops.nodedc.ru:host-gateway"
- "host.docker.internal:host-gateway"
networks:
- edge
- identity
launcher:
image: nodedc/launcher:local
restart: unless-stopped
env_file:
- ${NODEDC_SYNOLOGY_ENV_FILE:-.env.synology}
environment:
NODE_ENV: production
PORT: 5173
NODEDC_LAUNCHER_STORAGE_DIR: /app/server/storage
expose:
- "5173"
volumes:
- ../launcher/server-storage:/app/server/storage
- ../launcher/uploads:/app/dist/storage/uploads
- ../launcher/uploads:/app/public/storage/uploads
extra_hosts:
- "id.nodedc.ru:host-gateway"
- "hub.nodedc.ru:host-gateway"
- "ops.nodedc.ru:host-gateway"
- "${AUTH_DOMAIN:-auth.nas.nodedc}:host-gateway"
- "${LAUNCHER_DOMAIN:-launcher.nas.nodedc}:host-gateway"
- "${TASK_DOMAIN:-task.nas.nodedc}:host-gateway"
networks:
- edge
- identity
postgresql-authentik:
image: postgres:16-alpine
restart: unless-stopped
env_file:
- ${NODEDC_SYNOLOGY_ENV_FILE:-.env.synology}
environment:
POSTGRES_DB: ${PG_DB:-authentik}
POSTGRES_PASSWORD: ${PG_PASS:?database password required}
POSTGRES_USER: ${PG_USER:-authentik}
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
interval: 30s
timeout: 5s
retries: 5
start_period: 20s
volumes:
- authentik-database:/var/lib/postgresql/data
networks:
- identity
authentik-server:
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.2.2}
command: server
restart: unless-stopped
env_file:
- ${NODEDC_SYNOLOGY_ENV_FILE:-.env.synology}
environment:
AUTHENTIK_POSTGRESQL__HOST: postgresql-authentik
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS:?database password required}
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
AUTHENTIK_ERROR_REPORTING__ENABLED: ${AUTHENTIK_ERROR_REPORTING__ENABLED:-false}
AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS: ${AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS:-127.0.0.0/8,172.16.0.0/12}
depends_on:
postgresql-authentik:
condition: service_healthy
expose:
- "9000"
- "9443"
shm_size: 512mb
volumes:
- authentik-data:/data
- ../authentik/custom-templates:/templates:ro
networks:
- identity
authentik-worker:
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.2.2}
command: worker
restart: unless-stopped
env_file:
- ${NODEDC_SYNOLOGY_ENV_FILE:-.env.synology}
environment:
AUTHENTIK_POSTGRESQL__HOST: postgresql-authentik
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS:?database password required}
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
AUTHENTIK_ERROR_REPORTING__ENABLED: ${AUTHENTIK_ERROR_REPORTING__ENABLED:-false}
depends_on:
postgresql-authentik:
condition: service_healthy
shm_size: 512mb
volumes:
- authentik-data:/data
- authentik-certs:/certs
- ../authentik/custom-templates:/templates:ro
networks:
- identity
networks:
edge:
identity:
internal: true
volumes:
authentik-database:
authentik-data:
authentik-certs:
caddy-data:
caddy-config:

View File

@ -0,0 +1,30 @@
# Tasker overlay: editable NODE.DC logo link
This overlay captures the Synology Tasker source hotfix that routes NODE.DC logo clicks through the Hub public brand config:
- Hub source of truth: `https://hub.nodedc.ru/api/public/brand`
- Editable admin field: Hub admin → Platform → Misc → Logo link
- Fallback for `*.nodedc.ru`: `https://hub.nodedc.ru/`
## Apply to Synology source
From the platform repo root on a machine that has `/Volumes/docker` mounted:
```bash
rsync -av infra/synology/tasker-overlays/logo-link-brand-hotfix/files/ \
/Volumes/docker/nodedc-platform/tasker/plane-src/
```
Then deploy web only:
```bash
cd /volume1/docker/nodedc-platform/tasker/plane-src
BUILD_BACKEND=0 BUILD_WEB=1 BUILD_ADMIN=0 sh rebuild-nas-legacy.sh
```
Admin image can be rebuilt separately if needed:
```bash
cd /volume1/docker/nodedc-platform/tasker/plane-src
BUILD_BACKEND=0 BUILD_WEB=0 BUILD_ADMIN=1 sh rebuild-nas-legacy.sh
```

View File

@ -0,0 +1,115 @@
node_modules
.next
.yarn
.pnpm-store/
### NextJS ###
# Dependencies
/node_modules
/.pnp
.pnp.js
# Testing
/coverage
# Next.js
/.next/
/out/
# Production
dist/
out/
build/
.react-router/
**/build/
**/.react-router/
# Misc
.DS_Store
*.pem
.history
tsconfig.tsbuildinfo
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.pnpm-debug.log*
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Vercel
.vercel
# Turborepo
.turbo
## Django ##
venv
.venv
*.pyc
staticfiles
mediafiles
.env
.DS_Store
logs/
htmlcov/
.coverage
node_modules/
assets/dist/
npm-debug.log
yarn-error.log
pnpm-debug.log
# Editor directories and files
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
package-lock.json
.vscode
# Sentry
.sentryclirc
# lock files
package-lock.json
.secrets
tmp/
## packages
dist
.temp/
deploy/selfhost/plane-app/
## Storybook
*storybook.log
output.css
dev-editor
# Redis
*.rdb
*.rdb.gz
storybook-static
CLAUDE.md
build/
.react-router/
build/
.react-router/
temp/
scripts/

View File

@ -0,0 +1,91 @@
FROM node:22-alpine AS base
WORKDIR /app
ENV TURBO_TELEMETRY_DISABLED=1
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH"
ENV CI=1
RUN corepack enable pnpm
# =========================================================================== #
FROM base AS builder
RUN pnpm add -g turbo@2.9.4
COPY . .
# Create a pruned workspace for just the admin app
RUN turbo prune --scope=admin --docker
# =========================================================================== #
FROM base AS installer
# Build in production mode; we still install dev deps explicitly below
ENV NODE_ENV=production
# Public envs required at build time (pick up via process.env)
ARG VITE_API_BASE_URL=""
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
ARG VITE_API_BASE_PATH="/api"
ENV VITE_API_BASE_PATH=$VITE_API_BASE_PATH
ARG VITE_ADMIN_BASE_URL=""
ENV VITE_ADMIN_BASE_URL=$VITE_ADMIN_BASE_URL
ARG VITE_ADMIN_BASE_PATH="/nodedcsudo"
ENV VITE_ADMIN_BASE_PATH=$VITE_ADMIN_BASE_PATH
ARG VITE_SPACE_BASE_URL=""
ENV VITE_SPACE_BASE_URL=$VITE_SPACE_BASE_URL
ARG VITE_SPACE_BASE_PATH="/spaces"
ENV VITE_SPACE_BASE_PATH=$VITE_SPACE_BASE_PATH
ARG VITE_LIVE_BASE_URL=""
ENV VITE_LIVE_BASE_URL=$VITE_LIVE_BASE_URL
ARG VITE_LIVE_BASE_PATH="/live"
ENV VITE_LIVE_BASE_PATH=$VITE_LIVE_BASE_PATH
ARG VITE_WEB_BASE_URL=""
ENV VITE_WEB_BASE_URL=$VITE_WEB_BASE_URL
ARG VITE_WEB_BASE_PATH=""
ENV VITE_WEB_BASE_PATH=$VITE_WEB_BASE_PATH
ARG VITE_NODEDC_LAUNCHER_URL=""
ENV VITE_NODEDC_LAUNCHER_URL=$VITE_NODEDC_LAUNCHER_URL
ARG VITE_WEBSITE_URL="https://plane.so"
ENV VITE_WEBSITE_URL=$VITE_WEBSITE_URL
ARG VITE_SUPPORT_EMAIL="support@plane.so"
ENV VITE_SUPPORT_EMAIL=$VITE_SUPPORT_EMAIL
COPY .gitignore .gitignore
COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
# Copy full directory structure before fetch to ensure all package.json files are available
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
# Fetch dependencies to cache store, then install offline with dev deps
RUN pnpm fetch --store-dir=/pnpm/store
RUN CI=true pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store --prod=false
# Build only the admin package
RUN pnpm turbo run build --filter=admin
# =========================================================================== #
FROM nginx:1.29-alpine AS production
COPY apps/admin/nginx/nginx.conf /etc/nginx/nginx.conf
COPY --from=installer /app/apps/admin/build/client /usr/share/nginx/html/nodedcsudo
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -fsS http://127.0.0.1:3000/ >/dev/null || exit 1
CMD ["nginx", "-g", "daemon off;"]

View File

@ -0,0 +1,22 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useNodeDCBrandLinkUrl } from "@/hooks/use-nodedc-brand-link-url";
export function AuthHeader() {
const logoLinkUrl = useNodeDCBrandLinkUrl();
return (
<div className="sticky top-0 flex w-full flex-shrink-0 items-center justify-between gap-6">
<a href={logoLinkUrl}>
<span className="tracking-normal text-16 font-semibold text-primary">NODE.DC</span>
</a>
<span className="rounded-full bg-white/6 px-3 py-1 text-11 font-medium text-secondary">
Глобальное администрирование
</span>
</div>
);
}

View File

@ -0,0 +1,184 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Fragment, useEffect, useState } from "react";
import { observer } from "mobx-react";
import { useTheme as useNextTheme } from "next-themes";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { BrainCog, ChevronDown, ExternalLink, Image, LogOut, Mail, Palette, Settings, UserCog2 } from "lucide-react";
import { Menu, Transition } from "@headlessui/react";
// plane imports
import { API_BASE_URL } from "@plane/constants";
import { LockIcon, WorkspaceIcon } from "@plane/propel/icons";
import { AuthService } from "@plane/services";
import { Avatar } from "@plane/ui";
import { cn, getFileURL } from "@plane/utils";
// assets
import NodeDcLogo from "@/app/assets/logos/nodedc-logo.svg?url";
// hooks
import { useUser } from "@/hooks/store";
import { useNodeDCBrandLinkUrl } from "@/hooks/use-nodedc-brand-link-url";
// local imports
const authService = new AuthService();
const PRIMARY_NAVIGATION = [
{ label: "Основное", href: "/general/", Icon: Settings },
{ label: "Почта", href: "/email/", Icon: Mail },
{ label: "Аутентификация", href: "/authentication/", Icon: LockIcon },
{ label: "Воркспейсы", href: "/workspace/", Icon: WorkspaceIcon },
];
const FEATURE_NAVIGATION = [
{ label: "ИИ", href: "/ai/", Icon: BrainCog, description: "OpenAI модель и API-ключ" },
{ label: "Изображения", href: "/image/", Icon: Image, description: "Внешние библиотеки изображений" },
];
export const AdminHeader = observer(function AdminHeader() {
const pathName = usePathname();
const { currentUser, signOut } = useUser();
const { resolvedTheme, setTheme } = useNextTheme();
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
const logoLinkUrl = useNodeDCBrandLinkUrl();
const isFeatureRoute = FEATURE_NAVIGATION.some((item) => pathName?.startsWith(item.href));
const adminName = currentUser?.display_name || currentUser?.email || "Глобальный админ";
const avatarName = currentUser?.display_name || currentUser?.email || "DC";
const handleThemeSwitch = () => {
const newTheme = resolvedTheme === "dark" ? "light" : "dark";
setTheme(newTheme);
};
useEffect(() => {
if (csrfToken === undefined)
void authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));
}, [csrfToken]);
return (
<header className="nodedc-admin-header relative z-30 flex w-full flex-shrink-0 flex-col gap-4">
<div className="nodedc-admin-header-top grid w-full items-center gap-4">
<a href={logoLinkUrl} className="nodedc-admin-logo-link inline-flex w-fit items-center" aria-label="NODE.DC">
<img src={NodeDcLogo} alt="NODE.DC" className="nodedc-admin-logo" />
</a>
<nav className="nodedc-admin-top-nav justify-self-center" aria-label="Основная навигация God Mode">
{PRIMARY_NAVIGATION.map((item) => {
const isActive = item.href === pathName || Boolean(pathName?.startsWith(item.href));
return (
<Link key={item.href} href={item.href} className="nodedc-admin-top-nav-item" data-active={isActive}>
<item.Icon className="size-3.5 stroke-[1.7]" />
<span>{item.label}</span>
</Link>
);
})}
<Menu as="div" className="relative">
<Menu.Button className="nodedc-admin-top-nav-item" data-active={isFeatureRoute}>
<BrainCog className="size-3.5 stroke-[1.7]" />
<span>Возможности</span>
<ChevronDown className="size-3 stroke-[2]" />
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="nodedc-glass-popup-surface absolute top-full left-1/2 z-[120] mt-3 flex w-64 -translate-x-1/2 flex-col gap-1 p-2 outline-none">
{FEATURE_NAVIGATION.map((item) => {
const isActive = item.href === pathName || Boolean(pathName?.startsWith(item.href));
return (
<Menu.Item key={item.href}>
{({ active }) => (
<Link
href={item.href}
className={cn("nodedc-admin-feature-menu-item", {
"is-active": isActive,
"is-hovered": active,
})}
>
<span className="grid size-9 place-items-center rounded-full bg-white/6">
<item.Icon className="size-4 stroke-[1.7]" />
</span>
<span className="min-w-0">
<span className="block truncate text-13 font-medium text-primary">{item.label}</span>
<span className="block truncate text-11 text-tertiary">{item.description}</span>
</span>
</Link>
)}
</Menu.Item>
);
})}
</Menu.Items>
</Transition>
</Menu>
<a href="/" className="nodedc-admin-top-nav-item">
<ExternalLink className="size-3.5 stroke-[1.7]" />
<span>В приложение</span>
</a>
</nav>
<Menu as="div" className="relative justify-self-end">
<Menu.Button className="nodedc-admin-user-button">
<span className="min-w-0 text-right">
<span className="block max-w-40 truncate text-14 font-medium text-primary">{adminName}</span>
</span>
<span className="grid size-10 place-items-center rounded-full bg-white/7">
{currentUser ? (
<Avatar
name={avatarName}
src={getFileURL(currentUser.avatar_url)}
size={32}
shape="circle"
className="!text-body-sm-medium"
/>
) : (
<UserCog2 className="size-5 text-[rgb(var(--nodedc-card-active-rgb))]" />
)}
</span>
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="nodedc-glass-popup-surface absolute top-full right-0 z-[120] mt-3 flex w-60 flex-col divide-y divide-white/6 p-2 text-12 outline-none">
<div className="flex flex-col gap-1 px-2 pb-2">
<span className="truncate text-13 font-medium text-primary">{adminName}</span>
<span className="truncate text-11 text-tertiary">{currentUser?.email}</span>
</div>
<div className="py-2">
<Menu.Item as="button" type="button" className="nodedc-admin-menu-action" onClick={handleThemeSwitch}>
<Palette className="h-4 w-4 stroke-[1.5]" />
{resolvedTheme === "dark" ? "Светлая тема" : "Темная тема"}
</Menu.Item>
</div>
<div className="py-2">
<form method="POST" action={`${API_BASE_URL}/api/instances/admins/sign-out/`} onSubmit={signOut}>
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
<Menu.Item as="button" type="submit" className="nodedc-admin-menu-action">
<LogOut className="h-4 w-4 stroke-[1.5]" />
Выйти
</Menu.Item>
</form>
</div>
</Menu.Items>
</Transition>
</Menu>
</div>
</header>
);
});

View File

@ -0,0 +1,28 @@
export function buildNodeDCLauncherUrl(): string {
const configuredUrl = process.env.VITE_NODEDC_LAUNCHER_URL;
if (configuredUrl) {
return configuredUrl;
}
if (typeof window === "undefined") {
return "http://launcher.local.nodedc/";
}
const hostname = window.location.hostname.toLowerCase();
if (hostname.endsWith(".nodedc.ru")) {
return "https://hub.nodedc.ru/";
}
if (hostname.endsWith(".nas.nodedc")) {
const port = window.location.port ? `:${window.location.port}` : "";
return `${window.location.protocol}//launcher.nas.nodedc${port}/`;
}
return "http://launcher.local.nodedc/";
}
export function buildNodeDCBrandConfigUrl(): string {
return new URL("/api/public/brand", buildNodeDCLauncherUrl()).toString();
}

View File

@ -0,0 +1,31 @@
import { useEffect, useState } from "react";
import { buildNodeDCBrandConfigUrl, buildNodeDCLauncherUrl } from "@/helpers/nodedc-brand";
type TNodeDCBrandPayload = {
logoLinkUrl?: string | null;
};
export function useNodeDCBrandLinkUrl() {
const [logoLinkUrl, setLogoLinkUrl] = useState(buildNodeDCLauncherUrl);
useEffect(() => {
let isMounted = true;
fetch(buildNodeDCBrandConfigUrl(), { cache: "no-store" })
.then((response) => (response.ok ? response.json() : null))
.then((payload: TNodeDCBrandPayload | null) => {
const configuredUrl = typeof payload?.logoLinkUrl === "string" ? payload.logoLinkUrl.trim() : "";
if (isMounted && configuredUrl) setLogoLinkUrl(configuredUrl);
return undefined;
})
.catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : "Не удалось загрузить brand config NODE.DC");
});
return () => {
isMounted = false;
};
}, []);
return logoLinkUrl;
}

View File

@ -0,0 +1,91 @@
"use client";
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useTranslation } from "@plane/i18n";
import { Shapes } from "lucide-react";
import { cn } from "@plane/utils";
// components
import { TopNavPowerK } from "@/components/navigation";
import { useNodeDCBrandLinkUrl } from "@/hooks/use-nodedc-brand-link-url";
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root";
import { useHome } from "@/hooks/store/use-home";
import { ProjectsToolbarMenu } from "./projects-toolbar-menu";
import { ExpandedToolbarLink, ExpandedToolbarToolButton, ToolbarNotificationsButton } from "./toolbar-controls";
// types
import type { TProjectShellToolbarLayoutProps } from "./types";
export const ExpandedProjectShellToolbarLayout = ({
draftsItem,
homeItem,
isWorkspaceHome,
notificationsCount,
profileItem,
stickiesItem,
onOpenNotifications,
}: TProjectShellToolbarLayoutProps) => {
const { t } = useTranslation();
const { toggleWidgetSettings } = useHome();
const logoLinkUrl = useNodeDCBrandLinkUrl();
return (
<div className={cn("nodedc-expanded-toolbar-shell", { "nodedc-home-top-toolbar": isWorkspaceHome })}>
<div className="nodedc-expanded-toolbar">
<div className="nodedc-expanded-toolbar-top">
<div className="nodedc-expanded-toolbar-left">
<a href={logoLinkUrl} className="nodedc-expanded-brand-link" aria-label="NODE.DC">
<img src="/nodedc-logo.svg" alt="NODE DC" className="nodedc-expanded-brand-logo" />
</a>
</div>
<div className="nodedc-expanded-toolbar-center">
<WorkspaceMenuRoot variant="expanded-toolbar" />
<div className="nodedc-expanded-nav-group">
<ExpandedToolbarLink item={homeItem} label="Главная" />
<ProjectsToolbarMenu variant="expanded" />
<ExpandedToolbarLink item={stickiesItem} label="Стикеры" />
<ExpandedToolbarLink item={draftsItem} label="Черновики" />
</div>
</div>
<div className="nodedc-expanded-toolbar-right">
<div className="nodedc-expanded-user-group">
<ExpandedToolbarLink item={profileItem} label="Профиль" />
<ToolbarNotificationsButton
label={t("notification.label")}
notificationsCount={notificationsCount}
onClick={onOpenNotifications}
variant="expanded"
/>
<UserMenuRoot variant="expanded-toolbar" />
</div>
</div>
</div>
<div className="nodedc-expanded-toolbar-tools-row">
<div className="nodedc-expanded-breadcrumbs-slot" data-nodedc-expanded-breadcrumbs-slot />
{!isWorkspaceHome && (
<div className="nodedc-expanded-main-tool-cluster">
<TopNavPowerK variant="expanded-toolbar" />
<div className="nodedc-expanded-header-filters-slot" data-nodedc-expanded-header-filters-slot />
</div>
)}
<div className="nodedc-expanded-action-tool-cluster">
<div className="nodedc-expanded-tool-slot" data-nodedc-voice-task-toolbar-slot />
{isWorkspaceHome && (
<ExpandedToolbarToolButton label={t("home.manage_widgets")} onClick={() => toggleWidgetSettings(true)}>
<Shapes className="size-4" />
</ExpandedToolbarToolButton>
)}
<div className="nodedc-expanded-primary-action-slot" data-nodedc-expanded-primary-action-slot />
</div>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,89 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { AUTH_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { PlaneLockup } from "@plane/propel/icons";
import { PageHead } from "@/components/core/page-title";
import { EAuthModes } from "@/helpers/authentication.helper";
import { useNodeDCBrandLinkUrl } from "@/hooks/use-nodedc-brand-link-url";
import { useInstance } from "@/hooks/store/use-instance";
const authContentMap = {
[EAuthModes.SIGN_IN]: {
pageTitle: "auth_actions.sign_up",
text: "auth.common.new_to_plane",
linkText: "auth_actions.sign_up",
linkHref: "/sign-up",
},
[EAuthModes.SIGN_UP]: {
pageTitle: "auth_actions.sign_in",
text: "auth.common.already_have_an_account",
linkText: "auth_actions.sign_in",
linkHref: "/sign-in",
},
};
type AuthHeaderProps = {
type: EAuthModes;
};
export const AuthHeader = observer(function AuthHeader({ type }: AuthHeaderProps) {
const { t } = useTranslation();
// store
const { config } = useInstance();
// derived values
const enableSignUpConfig = config?.enable_signup ?? false;
return (
<AuthHeaderBase
pageTitle={t(authContentMap[type].pageTitle)}
additionalAction={
enableSignUpConfig && (
<div className="flex flex-col items-end text-center text-13 font-medium text-tertiary sm:flex-row sm:items-center sm:gap-2">
<span className="text-body-sm-regular text-tertiary">{t(authContentMap[type].text)}</span>
<Link
data-ph-element={AUTH_TRACKER_ELEMENTS.NAVIGATE_TO_SIGN_UP}
href={authContentMap[type].linkHref}
className="nodedc-auth-link text-body-sm-semibold"
>
{t(authContentMap[type].linkText)}
</Link>
</div>
)
}
/>
);
});
type TAuthHeaderBase = {
pageTitle: string;
additionalAction?: React.ReactNode;
};
export function AuthHeaderBase(props: TAuthHeaderBase) {
const { pageTitle, additionalAction } = props;
const logoLinkUrl = useNodeDCBrandLinkUrl();
return (
<>
<PageHead title={pageTitle + " - NODE.DC"} />
<div className="sticky top-0 flex w-full flex-shrink-0 items-center justify-between gap-6 px-2 py-1">
<a href={logoLinkUrl}>
<PlaneLockup
height={31}
width={148}
className="nodedc-auth-logo-lockup text-primary transition-opacity hover:opacity-90"
/>
</a>
{additionalAction}
</div>
</>
);
}

View File

@ -0,0 +1,74 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import Link from "next/link";
import { GOD_MODE_URL } from "@plane/constants";
// assets
import GradientLogo from "@/app/assets/auth/gradient-logo.webp?url";
import GradientBgLogo from "@/app/assets/auth/gradient-bg-logo.webp?url";
import DefaultLayout from "@/layouts/default-layout";
import { PlaneLockup } from "@plane/propel/icons";
import { Button } from "@plane/propel/button";
import { useNodeDCBrandLinkUrl } from "@/hooks/use-nodedc-brand-link-url";
export function InstanceNotReady() {
const logoLinkUrl = useNodeDCBrandLinkUrl();
return (
<DefaultLayout>
<div className="relative z-10 flex h-screen w-screen overflow-hidden">
<div className="flex h-full w-full flex-col items-center px-8 pt-6 pb-10">
<div className="sticky top-0 flex w-full shrink-0 items-center justify-between gap-6">
<a href={logoLinkUrl} aria-label="NODE.DC">
<PlaneLockup
height={40}
width={190}
className="nodedc-auth-logo-lockup text-primary transition-opacity hover:opacity-90"
/>
</a>
</div>
<div className="flex h-full w-full flex-col items-center justify-center gap-7">
<div className="nodedc-error-shell flex max-w-3xl flex-col items-center gap-11 text-center">
<img
src={GradientBgLogo}
className="pointer-events-none absolute -top-24 -left-32 h-56 w-96 opacity-12"
alt=""
aria-hidden="true"
/>
<img
src={GradientBgLogo}
className="pointer-events-none absolute -right-20 -bottom-16 h-56 w-96 opacity-12"
alt=""
aria-hidden="true"
/>
<img src={GradientLogo} className="h-24 w-40 object-contain" alt="NODE.DC Logo" />
<div className="flex max-w-124 flex-col items-center gap-3">
<h1 className="text-h2-semibold text-primary">NODE.DC готов к запуску</h1>
<p className="text-center text-body-md-regular text-secondary">
Завершите настройку инстанса и создайте первое рабочее пространство, чтобы начать работу с
проектами и рабочими элементами.
</p>
</div>
<a href={GOD_MODE_URL} className="w-80">
<Button variant="primary" className="nodedc-error-primary w-full" size="xl">
Перейти к настройке
</Button>
</a>
<a
href="https://nodedc.dctouch.ru/"
target="_blank"
rel="noopener noreferrer"
className="nodedc-error-link text-13 font-medium"
>
Служба поддержки
</a>
</div>
</div>
</div>
</div>
</DefaultLayout>
);
}

View File

@ -0,0 +1,77 @@
"use client";
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { ReactNode } from "react";
import { useTranslation } from "@plane/i18n";
import { InboxIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { useNodeDCBrandLinkUrl } from "@/hooks/use-nodedc-brand-link-url";
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
type TNodeDCStandaloneShellProps = {
children: ReactNode;
notificationsCount?: number;
onOpenNotifications?: () => void;
showUserControls?: boolean;
};
export const NodeDCStandaloneShell = (props: TNodeDCStandaloneShellProps) => {
const { children, notificationsCount = 0, onOpenNotifications, showUserControls = false } = props;
const { t } = useTranslation();
const logoLinkUrl = useNodeDCBrandLinkUrl();
return (
<div className="relative flex min-h-screen w-full overflow-hidden bg-[#050507] text-primary">
<div className="pointer-events-none absolute inset-0 opacity-80">
<div className="absolute top-[-18rem] left-[-12rem] h-[34rem] w-[34rem] rounded-full bg-[rgb(var(--nodedc-accent-rgb))]/10 blur-[120px]" />
<div className="absolute right-[-14rem] bottom-[-18rem] h-[38rem] w-[38rem] rounded-full bg-white/7 blur-[140px]" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_10%,rgba(255,255,255,0.06),transparent_38%),linear-gradient(180deg,rgba(255,255,255,0.035),rgba(255,255,255,0))]" />
</div>
<header className="nodedc-expanded-toolbar-shell absolute inset-x-0 top-0 z-[2]">
<div className="nodedc-expanded-toolbar-top">
<div className="nodedc-expanded-toolbar-left">
<a href={logoLinkUrl} className="nodedc-expanded-brand-link" aria-label="NODE.DC">
<img src="/nodedc-logo.svg" alt="NODE DC" className="nodedc-expanded-brand-logo" />
</a>
</div>
<div className="nodedc-expanded-toolbar-center" />
<div className="nodedc-expanded-toolbar-right">
{showUserControls && (
<div className="nodedc-expanded-user-group">
{onOpenNotifications && (
<Tooltip tooltipContent={t("notification.label")} position="bottom">
<button
type="button"
className="nodedc-toolbar-icon-button nodedc-expanded-notification-button relative flex items-center justify-center"
data-active={false}
aria-label={t("notification.label")}
onClick={onOpenNotifications}
>
<span className="nodedc-toolbar-icon-active-dot">
<InboxIcon className="size-5" />
</span>
{notificationsCount > 0 && (
<span className="nodedc-toolbar-notification-dot absolute top-1.5 right-1.5 size-2 rounded-full bg-danger-primary" />
)}
</button>
</Tooltip>
)}
<UserMenuRoot variant="expanded-toolbar" />
</div>
)}
</div>
</div>
</header>
<main className="relative z-[1] flex min-h-screen w-full items-center justify-center px-5 py-10 pt-[calc(var(--nodedc-shell-height)+2.25rem)]">
{children}
</main>
</div>
);
};

View File

@ -0,0 +1,39 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// plane imports
import { PlaneLockup } from "@plane/propel/icons";
// hooks
import { useNodeDCBrandLinkUrl } from "@/hooks/use-nodedc-brand-link-url";
import { useUser } from "@/hooks/store/user";
// local imports
import { SwitchAccountDropdown } from "./switch-account-dropdown";
export const OnboardingHeader = observer(function OnboardingHeader() {
const { data: user } = useUser();
const logoLinkUrl = useNodeDCBrandLinkUrl();
const userName = user?.display_name
? user.display_name
: user?.first_name
? `${user.first_name} ${user.last_name ?? ""}`.trim()
: user?.email;
return (
<div className="sticky top-0 z-10 flex flex-col gap-4">
<div className="h-1.5 w-full overflow-hidden rounded-t-lg bg-surface-1">
<div className="h-full w-full bg-accent-primary" />
</div>
<div className="flex w-full items-center justify-between gap-6 px-6">
<a href={logoLinkUrl} aria-label="NODE.DC">
<PlaneLockup height={20} width={95} className="text-primary transition-opacity hover:opacity-90" />
</a>
<SwitchAccountDropdown fullName={userName} />
</div>
</div>
);
});

View File

@ -0,0 +1,31 @@
import { useEffect, useState } from "react";
import { buildNodeDCBrandConfigUrl, buildNodeDCLauncherUrl } from "@/helpers/nodedc-auth";
type TNodeDCBrandPayload = {
logoLinkUrl?: string | null;
};
export function useNodeDCBrandLinkUrl() {
const [logoLinkUrl, setLogoLinkUrl] = useState(buildNodeDCLauncherUrl);
useEffect(() => {
let isMounted = true;
fetch(buildNodeDCBrandConfigUrl(), { cache: "no-store" })
.then((response) => (response.ok ? response.json() : null))
.then((payload: TNodeDCBrandPayload | null) => {
const configuredUrl = typeof payload?.logoLinkUrl === "string" ? payload.logoLinkUrl.trim() : "";
if (isMounted && configuredUrl) setLogoLinkUrl(configuredUrl);
return undefined;
})
.catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : "Не удалось загрузить brand config NODE.DC");
});
return () => {
isMounted = false;
};
}, []);
return logoLinkUrl;
}

View File

@ -0,0 +1,84 @@
#!/bin/sh
set -eu
DOCKER=/usr/local/bin/docker
SRC=/volume1/docker/nodedc-platform/tasker/plane-src
APP=/volume1/docker/nodedc-platform/tasker/plane-app
BUILD_BACKEND=${BUILD_BACKEND:-1}
BUILD_WEB=${BUILD_WEB:-1}
BUILD_ADMIN=${BUILD_ADMIN:-0}
RECREATE_SERVICES=""
printf "== sudo session ==\n"
sudo -v
if [ "$BUILD_BACKEND" = "1" ]; then
printf "== backend image: nodedc/plane-backend:local ==\n"
cd "$SRC/apps/api"
sudo DOCKER_BUILDKIT=0 "$DOCKER" build \
-t nodedc/plane-backend:local \
-f Dockerfile.api .
RECREATE_SERVICES="$RECREATE_SERVICES api worker beat-worker"
else
printf "== skip backend image ==\n"
fi
if [ "$BUILD_WEB" = "1" ]; then
printf "== frontend image: nodedc/plane-frontend:ru ==\n"
cd "$SRC"
sudo DOCKER_BUILDKIT=0 "$DOCKER" build \
--build-arg VITE_NODEDC_LAUNCHER_URL=https://hub.nodedc.ru \
--build-arg VITE_NODEDC_OIDC_LOGIN_ENABLED=1 \
-t nodedc/plane-frontend:ru \
-f apps/web/Dockerfile.web.nas-legacy .
RECREATE_SERVICES="$RECREATE_SERVICES web"
else
printf "== skip frontend image ==\n"
fi
if [ "$BUILD_ADMIN" = "1" ]; then
printf "== admin image: nodedc/plane-admin:ru ==\n"
cd "$SRC"
sudo DOCKER_BUILDKIT=1 "$DOCKER" build \
--build-arg VITE_NODEDC_LAUNCHER_URL=https://hub.nodedc.ru \
-t nodedc/plane-admin:ru \
-f apps/admin/Dockerfile.admin .
RECREATE_SERVICES="$RECREATE_SERVICES admin"
else
printf "== skip admin image ==\n"
fi
if [ -z "$RECREATE_SERVICES" ]; then
printf "== nothing to recreate ==\n"
exit 0
fi
printf "== recreate tasker services ==\n"
cd "$APP"
sudo "$DOCKER" compose -p nodedc-tasker \
--env-file .env.synology \
-f docker-compose.yaml \
-f docker-compose.synology.override.yml \
up -d --no-build --force-recreate $RECREATE_SERVICES
printf "== containers ==\n"
sudo "$DOCKER" ps \
--filter "name=nodedc-tasker" \
--format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
printf "== smoke: logo ==\n"
curl -k -sSI --resolve ops.nodedc.ru:443:127.0.0.1 \
https://ops.nodedc.ru/nodedc-logo.svg \
| grep -Ei "HTTP/|content-type" || true
printf "== smoke: websocket ==\n"
curl -k -i --http1.1 --resolve ops.nodedc.ru:443:127.0.0.1 \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Sec-WebSocket-Version: 13" \
https://ops.nodedc.ru/live/nodedc/stream \
--max-time 35 \
2>/dev/null | sed -n "1,25p" || true
printf "== done ==\n"