272 changed files with 18231 additions and 11399 deletions
+11 -37
View File
@@ -1,42 +1,16 @@
# ── База данных PostgreSQL ──
DB_HOST=localhost
# База данных
DB_HOST=your-db-host.svc.cluster.local
DB_PORT=5432
DB_NAME=ipwhitelist
DB_USER=ipwhitelist
DB_PASS=<openssl rand -base64 24>
DB_SSLMODE=disable
DB_NAME=postgres
DB_USER=your-user
DB_PASS=your-password
DB_SSLMODE=require
# ── Режим разработки ──
# true = mock-логин (без Keycloak, для тестов)
# false = OIDC SSO через Keycloak (ОБЯЗАТЕЛЬНО в production)
DEV_MODE=true
# APP_ENV=production (по умолчанию) или test (жёлтый баннер в UI)
APP_ENV=test
# Сервер
PORT=3000
# Лимиты
DEFAULT_LIMIT=15
# ── Сессия (сгенерировать: openssl rand -base64 32) ──
SESSION_SECRET=<openssl rand -base64 32>
CSRF_SECRET=<openssl rand -base64 32>
# ── Порт сервера ──
PORT=3001
# ── Администратор (clientId) ──
ADMIN_CLIENT_ID=WZ01112
# ── IAM API (сервис авторизации Nubes) ──
# Используется для получения списка компаний пользователя (GET /api/v1/auth/user)
# и переключения активной компании (POST /api/v1/user/switch-profile).
# ⚠️ ВЫБЕРИТЕ СТЕНД (раскомментируйте нужный):
# Dev: https://auth-api-dev.ngcloud.ru
# Test: https://auth-api-test.ngcloud.ru
# Prod: https://auth-api.ngcloud.ru
IAM_API_URL=https://auth-api-dev.ngcloud.ru
# ── Keycloak / OIDC (ЗАПОЛНИТЬ при DEV_MODE=false) ──
# KC_CLIENT_ID= # ID зарегистрированного OIDC-клиента в Keycloak
# KC_CLIENT_SECRET= # секрет клиента
# KC_BASE_URL=https://keycloak.nubes.ru/realms/cloud
# APP_URL=https://ваш-домен.ru
# Режим разработки (true = без Keycloak, читать X-Dev-User из заголовка)
DEV_MODE=true
-261
View File
@@ -1,261 +0,0 @@
# IP Whitelist App — AI Agent Guide
> For AI coding agents (Copilot, etc.) to understand and continue development.
> Last updated: 2026-06-04 | Version: 0.5.23
## 1. Quick Overview
**Purpose:** Self-service portal for cloud provider customers to manage trusted IPv4 whitelist entries (CIDRs) that are excluded from DDoS filtering.
**Stack:** Node.js + Express + EJS + PostgreSQL
**Auth:** Mock RS256 JWT (dev) / Keycloak OIDC (production)
**Deployment:** PM2, behind nginx on a VM
## 2. Architecture
```
Browser → nginx:443 → Express:3001
├── /export (public, no auth)
├── /api/v1/* (Bearer JWT, REST API)
└── /* (UI layer, SSR EJS, session-based)
```
### Two-layer design:
- **API layer** (`src/api/routes/`): REST endpoints, Bearer JWT auth, direct DB access
- **UI layer** (`ui/routes/`): SSR EJS pages, session-based auth, proxies to API via HTTP (`ui/api-client.js`)
### Why two layers?
- API can be used directly by nginx/firewall for whitelist export
- UI provides human-friendly management interface
- Clear separation of concerns
### Key flow:
```
Browser → UI route → api-client.js (HTTP) → API route → PostgreSQL
```
## 3. Project Structure
```
ipwhitelist-app/
├── server.js # Entry point — mounts all routes
├── package.json
├── .env # Config (not in git)
├── .env.example # Config template
├── sql/schema.sql # Database schema
├── src/
│ ├── auth.js # Auth: mock JWT + OIDC/Keycloak
│ ├── config.js # MOCK_USERS + helpers
│ ├── db.js # PostgreSQL pool
│ ├── queries.js # All DB queries (CRUD, export, audit)
│ ├── validators.js # CIDR validation + blocked ranges + aggregation
│ │
│ ├── api/
│ │ ├── index.js # API router mount
│ │ └── routes/
│ │ ├── entries.js # GET/POST/PATCH/DELETE /entries + /entries/export
│ │ └── admin.js # GET /companies, PATCH /limit, GET /audit
│ │
│ ├── middleware/
│ │ ├── csrf.js # CSRF protection (NOT ACTIVE — tokens are empty)
│ │ ├── rateLimit.js # Rate limiters (auth, mutation, export)
│ │ └── session.js # express-session with pg store
│ │
│ └── routes/
│ ├── oidc.js # OIDC/Keycloak routes (inactive in mock mode)
│ └── (old monolithic routes deleted in v0.5.11)
├── ui/
│ ├── index.js # UI middleware: requireToken + resolveUser + mount routers
│ ├── api-client.js # HTTP client to /api/v1/* (from UI layer)
│ └── routes/
│ ├── auth.js # GET/POST /login, POST /login-token, GET /logout
│ ├── entries.js # GET /, POST /add, POST /edit/:id, POST /delete/:id
│ ├── admin.js # GET /admin, /audit (admin-only)
│ └── export.js # GET /export (proxies to API)
├── views/
│ ├── index.ejs # Main page: entries table + add/edit/delete forms
│ ├── ui-login.ejs # Login page (mock + manual clientId entry)
│ ├── admin.ejs # Admin: companies list + limits
│ ├── audit.ejs # Audit log
│ └── error.ejs # 404/500 error page
├── tests/
│ ├── api.js # 121 API integration tests (Bearer JWT)
│ ├── integration.js # 68 UI integration tests (session, EJS)
│ ├── tz-compliance.js # 47 ТЗ compliance tests
│ ├── tz-full-compliance.js # 111 comprehensive ТЗ tests (20 companies × 100 CIDRs)
│ ├── run-tests.js # 68 unit/smoke tests (no DB)
│ └── stress.js # 191 concurrency/stress tests
└── docs/
├── ТЗ.md # Tech spec (Russian)
├── ТЗ-плюс.md # Client clarifications
├── ТЗ-реализация.md # Implementation notes
├── keycloak-auth-reference.md # Keycloak/OIDC reference
├── devops-deploy.md # DevOps deployment guide
└── deploy-keycloak.md # Keycloak setup guide
```
## 4. Auth System
### Mock Mode (default, DEV_MODE=true)
- Local RSA keypair generated at startup
- `POST /login-token` with `clientId` field → mock JWT → session
- Mock users defined in `src/config.js`:
- `admin` (WZ01112) — admin@nubes.ru
- `alfa` (WZ88888) — alfa@example.com
- `multi` (WZ88888, WZ77777) — multi@example.com
- `beta` (WZ77777) — beta@example.com
### OIDC Mode (production, when KC_CLIENT_ID set)
- Authorization Code flow via Keycloak
- Routes in `src/routes/oidc.js` (inactive in mock mode)
- JWKS certificate verification
### UI resolveUser (ui/index.js)
- Decodes JWT from session (no signature check — API validates on each call)
- Extracts `ClientID`, `allClientIds`, `activeClientId`, `companyId`, `companyName`, `email`
- Sets `req.user` for all downstream handlers
### API bearerMiddleware (src/auth.js)
- Verifies Bearer JWT signature (RS256 via JWKS or mock keypair)
- Sets `req.user` via `userFromPayload()`
## 5. Multi-Company
Users can belong to multiple companies via IAM API profiles:
- User's companies come from IAM API (`profiles[]`) with `is_active_profile` flag
- Active company selector in UI (dropdown, `?switchTo=X` or JS navigation)
- API supports `?client_id=<id>` parameter to specify active company
- Default: first company in list (`allClientIds[0]`)
**IMPORTANT:** `req.user.activeClientId` must be synced BOTH in `session.user.activeClientId` AND `req.user.activeClientId` — see `ui/routes/entries.js` switch handler.
## 6. Company Isolation
- Each company is a row in `companies` table (unique by `client_id`)
- Entries are isolated by `company_id` foreign key
- Admin can view/edit any company via `?company=<db_id>`
- User sees only their company's entries
- Multi-company user switches via `?client_id=<clientId>`
## 7. Database
Three tables (see `sql/schema.sql`):
- `companies` — (id, client_id, name, custom_limit, created_at, updated_at)
- `whitelist_entries` — (id, company_id FK, value_cidr, comment, created_by, created_at, updated_at, deleted_at, deleted_by)
- `audit_log` — (id, user_email, company_id, action, old_value, new_value, entry_id, created_at)
Default limit: 15 entries per company (configurable via `DEFAULT_LIMIT` env).
## 8. Key Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/export` | None (public) | Aggregated CIDR list, text/plain |
| GET | `/api/v1/entries` | Bearer JWT | List entries for company |
| POST | `/api/v1/entries` | Bearer JWT | Create entry |
| PATCH | `/api/v1/entries/:id` | Bearer JWT | Update entry |
| DELETE | `/api/v1/entries/:id` | Bearer JWT | Soft-delete entry |
| GET | `/api/v1/entries/export` | Bearer JWT | Aggregated CIDR export |
| GET | `/api/v1/companies` | Bearer JWT (admin) | List companies |
| PATCH | `/api/v1/companies/:id/limit` | Bearer JWT (admin) | Set company limit |
| GET | `/api/v1/audit` | Bearer JWT (admin) | Audit log |
| GET | `/login` | None | Login page (UI) |
| POST | `/login` | None | Mock login (form) |
| POST | `/login-token` | None | Manual clientId login |
| GET | `/logout` | Session | Logout |
### Export query params:
- `?view=1` — inline (browser display)
- `?filename=X` — custom filename (default: white-list.txt)
## 9. CIDR Validation Rules
- Only IPv4, no IPv6
- Mask must be /22 to /32 (inclusive)
- Single IP without mask = /32
- 14 blocked ranges (RFC1918, CGNAT, loopback, etc.)
- No duplicates within a company
- No overlaps within a company
- Normalization: host bits zeroed (e.g., 9.9.9.5/24 → 9.9.9.0/24)
- Comment max 255 chars, optional
## 10. Running Tests
```bash
# Unit tests (no DB needed)
node tests/run-tests.js
# API tests (requires DB, DEV_MODE=true)
node tests/api.js
# UI integration tests
node tests/integration.js
# ТЗ compliance
node tests/tz-compliance.js
# Full ТЗ compliance (20 companies × 100 CIDRs)
node tests/tz-full-compliance.js
# Stress/concurrency tests
node tests/stress.js
```
All tests: 347+ PASS, 0 FAIL
## 11. Deployment
Deploy the app on a VM with nginx as reverse proxy. See `docs/devops-deploy.md` for details.
## 12. Common Pitfalls
### 🚨 Rate Limiter
`authLimiter`: 10 requests per 5 minutes. Hits quickly during testing. Reset after 5 min or use different IPs.
### 🚨 Mock Keypair Mismatch
Each `initAuth()` generates a NEW RSA keypair. When testing with supertest, the UI api-client makes HTTP requests to `localhost:3001` (PM2 instance), which has a DIFFERENT keypair → "invalid signature". Fix: test against the running PM2 instance directly, or stop PM2 and use the supertest port.
### 🚨 Company Switch Bug (FIXED v0.5.12)
`resolveUser` runs BEFORE the route handler. When switching companies via `?switchTo=X`, the session was updated but `req.user.activeClientId` still had the old value. Fixed by adding `req.user.activeClientId = switchTo` after session update.
### 🚨 CSRF Not Active
`csrfToken` is always `""` in templates. CSRF middleware is not mounted. Forms work, but there's no CSRF protection. To enable: mount `doubleCsrfProtection` middleware and call `generateCsrfToken()` in GET handlers.
### 🚨 `.env` on VM gets overwritten
Rsync excludes `.env` now, but double-check. If `.env` contains DB credentials, they MUST NOT be overwritten.
### 🚨 Version Must Always Increment
After ANY code change: bump `"version"` in `package.json`. PM2 shows version — user expects it to match.
## 13. OIDC/Keycloak Transition
To enable production auth:
1. DevOps creates client in Keycloak with redirect URI `/callback`
2. Set `.env`: `KC_CLIENT_ID`, `KC_CLIENT_SECRET`, `KC_BASE_URL`, `APP_URL`
3. Set `DEV_MODE=false`
4. `pm2 restart ipwhitelist --update-env`
5. Routes in `src/routes/oidc.js` activate automatically when `KC_CLIENT_ID` is set
See `docs/deploy-keycloak.md` for details.
## 14. Version History
| Version | Date | Key Changes |
|---------|------|-------------|
| 0.5.23 | 2026-06-04 | 0.0.0.0/8 blocked, security+edge test suites |
| 0.5.22 | 2026-06-04 | 4-audit cleanup, gitignore hardening |
| 0.5.21 | 2026-06-04 | Header injection fix, API timeout, nginx duplicate |
| 0.5.20 | 2026-06-04 | IAM fallback warning, fail-fast IAM_API_URL, X-Forwarded-Proto |
| 0.5.19 | 2026-06-04 | safeReturn in all redirects (open redirect fix) |
| 0.5.18 | 2026-06-04 | IAM API integration: fetchIamUser, switchProfile, profiles[] |
| 0.5.17 | 2026-06-04 | Previous stable |
| 0.5.14 | 2026-06-03 | Token login, JWKS in mock mode, production deploy docs |
| 0.5.13 | 2026-06-03 | OIDC routes prepared, .env.example, devops-deploy guide, repo cleanup |
| 0.5.12 | 2026-06-02 | Fixed company switch bug, aggregation, validation, TZ compliance |
| 0.5.11 | 2026-06-02 | Dead code cleanup, new integration tests, /export public |
| 0.5.10 | 2026-06-02 | UI/API split, mock users fixed, 121 API tests passing |
-29
View File
@@ -1,29 +0,0 @@
# IP WhiteList — Copilot Instructions
## Ключевые правила
1. Отвечать кратко. Вопрос = СТОП.
2. Не трогать рабочий код без «делай».
3. Не лезть в БД / kubectl / сервер без прямого указания.
4. После любой правки — VERSION++ в package.json.
5. Git только локально: add + commit + push.
6. ⛔ ПЕРЕД ЛЮБЫМ ИЗМЕНЕНИЕМ КОДА — описать что именно будет сделано, получить «делай».
## Репа
- https://gitea.services.ngcloud.ru/Nail/ipwhitelist-app.git
## Стек
- Node.js + Express + EJS
- PostgreSQL
- REST API /api/v1/* (Bearer JWT)
- UI-слой (сессии, SSR через EJS)
## Тесты
- `node tests/api.js` — API (Bearer JWT, 121 тест)
- `node tests/integration.js` — интеграционные (сессии, HTML-формы)
## Важные файлы
- `docs/ТЗ.md` — техническое задание
- `docs/ТЗ-плюс.md` — уточнения от заказчика
- `docs/keycloak-auth-reference.md` — справка по Keycloak/OIDC
- `.env.example` — шаблон конфигурации
- `.github/AGENT-GUIDE.md` — полный гайд для AI-агентов (англ.)
+1 -50
View File
@@ -1,56 +1,7 @@
# Система
node_modules/
.env
node_modules/
__pycache__/
*.pyc
.DS_Store
.vscode/
.idea/
*.har
# Креды, токены, конфиги
token*.*
Files/
kubeconfig-*
# Внутренняя аналитика (не для публичного доступа)
AGENT.md
AGENT-DIAGNOSIS.md
PROJECT-AUDIT.md
CONTEXT.md
SESSION-SUMMARY.md
prompt-*.md
docs/PLAN-*.md
docs/STATE-*.md
docs/cluster-cleanup-*.md
docs/tests-*.md
docs/test-api.sh
docs/nubes-design-system.md
docs/deploy-vm.md
docs/questions-devops.md
# Локальный Keycloak (бинарники не коммитим)
keycloak/keycloak-*/
keycloak/data/
keycloak/*.tar.gz
# Промпты для внешних агентов
prompt-*.txt
# Результаты код-ревью агентов
research/
audits/
docs/agent-opinions.md
# Логи тестов
test-results/
# Тестовые окружения (только для внутреннего использования)
.env.test
.env.production
DEPLOY-TESTING.md
tests/comprehensive.js
# Промпты для внешних агентов
prompt-*.txt
docs/sonnet-*.md
-93
View File
@@ -1,93 +0,0 @@
# IP WhiteList v0.5.23
Self-service портал для управления доверенными IPv4-адресами клиентов облачного провайдера.
Записи исключаются из блокировки системами фильтрации во время DDoS-атак.
**[[ТЗ](docs/ТЗ.md)]** · **[[DevOps — production deploy](docs/devops-deploy.md)]** · **[[Кратко (без воды)](docs/wowater.md)]**
---
## Возможности
- **Управление белым списком:** добавление, редактирование, удаление (soft delete) IPv4/CIDR
- **Валидация** на сервере: маски `/22``/32`, запрет 14 приватных/служебных диапазонов, запрет дубликатов и пересечений, нормализация host-битов
- **Лимиты:** глобальный (по умолчанию 15) и индивидуальный на компанию (настраивает админ)
- **Агрегированный экспорт** CIDR-списка для систем фильтрации (`GET /export`)
- **Мульти-компания:** через IAM API — переключение между компаниями пользователя
- **Аудит:** все изменения фиксируются — кто, когда, что было и что стало
- **Администрирование:** просмотр всех компаний, управление лимитами, журнал аудита
## Аутентификация
| Режим | Описание |
|---|---|
| **DEV_MODE=true** (тесты/разработка) | Mock JWT, локальная RSA-пара. Вход через форму выбора пользователя или вставку Bearer-токена |
| **DEV_MODE=false** (production) | OIDC SSO через Keycloak Nubes. Редирект на Keycloak login → `/callback` → сессия |
В production требуются `KC_CLIENT_ID` + `KC_CLIENT_SECRET` от DevOps. Подробнее — [deploy guide](docs/devops-deploy.md).
## Стек
```
Node.js 20+ → Express 4 → EJS (SSR) → PostgreSQL 16+
jsonwebtoken RS256 / JWKS (Keycloak Nubes)
express-session + connect-pg-simple
```
## Архитектура
```
Browser → nginx:443 → Express:3001
├── /export (публичный CIDR)
├── /api/v1/* (REST API, Bearer JWT)
└── /* (UI, SSR EJS, сессия → api-client.js → API)
```
Два слоя: SSR UI для браузера, JSON API для внешних систем.
## Multi-company
Пользователь может принадлежать нескольким компаниям. Список компаний и активная определяются через IAM API (`GET /api/v1/auth/user``profiles[]`). Переключение — через UI или `POST /api/v1/user/switch-profile`.
## Тесты
```bash
npm test # 121 API-тест (Bearer JWT, CRUD, изоляция, admin)
node tests/integration.js # 68 UI-тестов (сессии, EJS)
node tests/tz-compliance.js # 47 тестов ТЗ
node tests/tz-full-compliance.js # 111 тестов (20 компаний × 100 CIDR)
node tests/stress.js # 191 стресс-тест (concurrency, auth attacks, edge cases)
```
Все тесты требуют реального PostgreSQL.
## Быстрый старт (dev)
```bash
git clone https://gitea.services.ngcloud.ru/Nail/ipwhitelist-app.git
cd ipwhitelist-app
cp .env.example .env # заполнить DB_* и SESSION_SECRET
npm install
psql -U postgres -d ipwhitelist -f sql/schema.sql
npm start # http://localhost:3000 + DEV_MODE=true
```
## Production deploy
См. [`docs/devops-deploy.md`](docs/devops-deploy.md) — nginx, HTTPS, PM2, Keycloak SSO.
## Документация
- [Техническое задание](docs/ТЗ.md)
- [Уточнения ТЗ](docs/ТЗ-плюс.md)
- [DevOps — production deploy](docs/devops-deploy.md)
- [AI Agent Guide](.github/AGENT-GUIDE.md) (англ., для Copilot)
- [Copilot Instructions](.github/copilot-instructions.md) (правила для AI)
- [Keycloak/OIDC reference](docs/keycloak-auth-reference.md)
- [Deploy Keycloak](docs/deploy-keycloak.md)
## Текущий деплой
- **URL:** `https://italo.kube5s.ru`
- **Режим:** DEV_MODE=true (mock)
- **БД:** PostgreSQL на ВМ, localhost only
-130
View File
@@ -1,130 +0,0 @@
# Техническое задание
## Микросервис управления доверенными адресами клиентов (Белые списки IP)
---
# 1. Назначение
Клиенты облачного провайдера через личный кабинет управляют своими доверенными IPv4-адресами. Эти адреса исключаются из блокировки при DDoS-атаках.
Администраторы Нубеса могут просматривать и редактировать списки всех клиентов.
---
# 2. Авторизация
## 2.1. Вход
Пользователь входит через Keycloak (OIDC). После входа приложение запрашивает IAM API — сервис авторизации экосистемы Nubes.
IAM возвращает:
- email пользователя
- список компаний, к которым он принадлежит
- активную компанию
- флаг администратора (isAdmin)
## 2.2. Роли
| Роль | Возможности |
|---|---|
| Клиент | Видит и редактирует только записи своих компаний |
| Администратор Нубеса | Видит и редактирует записи всех компаний, меняет лимиты, смотрит аудит, выгружает списки |
Администратором считается пользователь с `isAdmin = true` в IAM и `clientId = WZ01112`. Если у пользователя несколько компаний, он переключается между ними.
## 2.3. Режим администратора
Пользователь Нубеса видит переключатель «Администратор». В обычном режиме интерфейс как у клиента. При включении — выпадающий список всех компаний, ссылки на аудит и управление лимитами.
---
# 3. Управление записями
## 3.1. Просмотр
Клиент видит таблицу записей своей компании. Администратор выбирает компанию из списка.
Для каждой записи показывается: адрес/подсеть, комментарий, кто добавил, даты создания и изменения. Удалённые записи отображаются зачёркнутыми и полупрозрачными, без кнопок редактирования. Кнопки «Изменить» и «Удалить» — в строке таблицы.
Счётчик: «X из N записей».
## 3.2. Добавление
Форма: IPv4-адрес или CIDR, комментарий (необязательно, до 255 символов).
Проверки:
- Формат: только IPv4, одиночный адрес или подсеть /22–/32
- Нормализация: хост-биты обнуляются, пользователь уведомляется
- Запрещённые диапазоны: 14 служебных подсетей (RFC1918, CGNAT, loopback и др.)
- Нет дубликатов и пересечений внутри компании
- Лимит компании (по умолчанию 15)
При превышении лимита — блокировка с сообщением.
## 3.3. Редактирование
Изменение адреса и комментария. Все проверки — как при добавлении.
## 3.4. Удаление
Логическое (soft delete): запись помечается удалённой, но физически сохраняется. Освобождает место в лимите. Исключается из выгрузки.
---
# 4. Лимиты
- По умолчанию: 15 записей на компанию
- Администратор задаёт индивидуальный лимит компании (больше или меньше)
- Снижение лимита не удаляет существующие записи, но блокирует новые
---
# 5. Экспорт
Администратор выгружает агрегированный список всех активных CIDR.
- По умолчанию — JSON: `{ cidrs: [...], count: N }`
- `?format=txt` — текстовый файл, одна подсеть на строку
- `?company=<id>` — выгрузка по конкретной компании
---
# 6. Аудит
Все изменения (создание, редактирование, удаление) пишутся в журнал. Записи неизменяемы.
Состав записи: кто сделал, когда, компания, действие, старое и новое значение.
При имперсонации добавляется поле «имперсонировал» — кто на самом деле совершил действие.
Журнал доступен только администратору. Фильтр по компании.
---
# 7. Имперсонация
Администратор IAM может «притворяться» другим пользователем или компанией. Приложение получает флаг `is_impersonated` из IAM.
При активной имперсонации:
- Жёлтый баннер: «Режим имперсонации — от имени Компания (вы: originalUserEmail)»
- CRUD идёт в контексте имперсонируемой компании
- Аудит: `created_by = имперсонируемый`, `impersonated_by = реальный админ`
Без имперсонации `impersonated_by = NULL`.
---
# 8. Интерфейс
- Главная страница: таблица записей, форма добавления, переключатель компаний
- Аудит: `/audit` — журнал с фильтром по компании
- Лимиты: `/admin` — список компаний с лимитами
- Экспорт: `/export` — выгрузка списка
Шапка: логотип, версия приложения, email пользователя, переключатель «Администратор», кнопка «Выйти».
---
# 9. Технологии
Node.js, Express, EJS, PostgreSQL, Keycloak OIDC, IAM API.
-15
View File
@@ -1,15 +0,0 @@
Доверенные адреса – защита вашего трафика во время DDoS-атак
Когда на инфраструктуру провайдера идёт DDoS-атака, системы защиты автоматически фильтруют подозрительный трафик. Иногда под фильтрацию попадает и легитимный трафик, например, запросы из вашего офиса или от ваших партнёров.
Чтобы этого не происходило, вы можете заранее указать адреса, которые провайдер гарантированно не заблокирует. Это называется списком доверенных адресов.
Как пользоваться
<тут можно вставить ссылку на сервис>
Зайти в сервис необходимо с учетной записью keycloak.
Добавить адрес. Нажмите «Добавить», введите IPv4-адрес или подсеть в формате CIDR и при желании — комментарий, чтобы не забыть, что это за адрес. Например: 203.0.113.10 или 203.0.113.0/24. Нажмите «Сохранить». Если вы вводите адрес хоста с маской подсети (например 203.0.113.5/24 вместо 203.0.113.0/24), сервис автоматически нормализует его до адреса подсети и сообщит вам об этом.
Добавляйте комментарии к записям — через несколько месяцев вы скажете себе спасибо: будет сразу понятно, чей это адрес и зачем он добавлен.
Редактировать или удалить. Рядом с каждой записью есть кнопки редактирования и удаления. Удалённые записи перестают действовать через 5 минут.
Ограничения
• Только публичные IPv4-адреса. Серые адреса (192.168.x.x, 10.x.x.x, 172.1631.x.x и другие служебные диапазоны) добавить нельзя — они не маршрутизируются в интернете и смысла в их добавлении нет.
• Только IPv4. IPv6-адреса и доменные имена не поддерживаются.
• Максимальный размер подсети — /22. Добавить префикс шире /22 (то есть /21, /20 и т.д.) нельзя.
• До 15 записей на компанию. Если вам нужно больше — обратитесь в поддержку.
• Нет дублей и пересечений. Если вы попытаетесь добавить адрес, который уже покрывается существующей записью (или наоборот), сервис предупредит об этом.
-65
View File
@@ -1,65 +0,0 @@
# IAM — переменные окружения
> Дата: 2026-06-17
> Путь: `src/config.js`, `v2/src/config/index.js`
## IAM_API_URL (основная, приоритет выше)
**Где используется:** `src/config.js``src/auth.js` (fetchIamUser, switchProfile)
**Формат:** `https://auth-api.ngcloud.ru` (без /api/v1)
Код добавляет `/api/v1/auth/user` автоматически.
**Назначение:** получение профилей пользователя, переключение компании.
**Дефолт:** `https://auth-api.ngcloud.ru`
**Пример:**
- IAM_API_URL=https://auth-api.ngcloud.ru
- Фактический путь: https://auth-api.ngcloud.ru/api/v1/auth/user
## IAM_API_BASE (запасная, приоритет ниже)
**Где используется:** `v2/src/config/index.js``v2/server.js` (тестовые страницы)
**Формат:** `https://lk-api-gateway.ngcloud.ru/api/v1/iam`
Код НЕ добавляет /api/v1, путь должен быть полный.
**Назначение:** тестовые страницы (/v2/iam-gateway-test), заглушка пока IAM не починят.
**Дефолт:** берётся из IAM_API_URL
**Пример:**
- IAM_API_BASE=https://lk-api-gateway.ngcloud.ru/api/v1/iam
- Фактический путь: https://lk-api-gateway.ngcloud.ru/api/v1/iam/auth/user
## Приоритет в src/config.js
```
process.env.IAM_API_URL || process.env.IAM_API_BASE || 'https://auth-api.ngcloud.ru'
```
1. IAM_API_URL — явно задан (auth-api)
2. IAM_API_BASE — запасной (lk-api-gateway)
3. Дефолт — auth-api.ngcloud.ru
## Когда что использовать
| Ситуация | Какая переменная | Какой API |
|----------|-----------------|-----------|
| Обычная работа (логин, CRUD, компании) | IAM_API_URL | auth-api.ngcloud.ru |
| Имперсонация (админ работает от имени юзера) | IAM_API_BASE | lk-api-gateway.ngcloud.ru |
| Когда IAM починят | IAM_API_URL | auth-api.ngcloud.ru (везде) |
## Текущий статус
- auth-api.ngcloud.ru — не отдаёт profiles таргета при имперсонации (баг разработчиков IAM)
- lk-api-gateway.ngcloud.ru — работает, отдаёт profiles таргета, НО не принимает токен от Keycloak whitelist (только от ЛК)
- Когда IAM починят: удалить IAM_API_BASE из кода и Nubes UI, оставить только IAM_API_URL
## Nubes UI (текущие переменные)
- IAM_API_URL=https://auth-api.ngcloud.ru
- IAM_API_BASE=https://lk-api-gateway.ngcloud.ru/api/v1/iam
-156
View File
@@ -1,156 +0,0 @@
Техническое задание
Микросервис управления доверенными адресами клиентов
Self-service портал для указания клиентами доверенных IPv4-адресов и подсетей,
исключаемых из блокировки на стороне облачного провайдера во время DDoS-атак
───────────────────────────────────────────────────────────────
КРАТКОЕ ОПИСАНИЕ (пояснение к реализации)
───────────────────────────────────────────────────────────────
Сервис даёт клиентам облачного провайдера личный кабинет, где они сами указывают
свои доверенные IPv4-адреса и подсети. Эти адреса провайдер не блокирует во время
DDoS-атак — так легитимный трафик клиента не попадает под ложные срабатывания
фильтрации.
Что реализуется:
Личный кабинет клиента. Клиент входит через привычную авторизацию (Keycloak),
видит свой список доверенных адресов и управляет им сам: добавляет, редактирует,
удаляет записи с комментариями. Если пользователь работает с несколькими
компаниями — переключается между ними.
Проверка вводимых данных. Форма принимает только корректные IPv4-адреса и подсети,
отклоняет «серые» и служебные диапазоны, не допускает дубликатов и пересечений
внутри одной компании.
Ограничение по количеству. На компанию по умолчанию 15 записей. Лимит
настраивается глобально, а для отдельной компании администратор может поднять или
опустить его индивидуально.
Режим администратора (сетевые инженеры провайдера). Единое окно, где видны записи
всех компаний, с фильтрами и доступом к истории изменений. Администратор управляет
лимитами и при необходимости любыми записями.
История изменений (аудит). Каждое создание, изменение и удаление фиксируется: кто,
когда, что именно изменил. Удаление — логическое, данные физически сохраняются.
Выдача для систем фильтрации. Отдельный адрес, по которому системы защиты
автоматически забирают итоговый сводный список всех доверенных адресов (одним
txt-файлом, по строке на запись). Адреса при этом схлопываются в компактный общий
перечень.
───────────────────────────────────────────────────────────────
1. Назначение и цели
1.1. Назначение
Микросервис предоставляет клиентам облачного провайдера web-интерфейс для самостоятельного управления списком доверенных IPv4-адресов и подсетей. Записи из этого списка исключаются из автоматической блокировки сетевого взаимодействия системами фильтрации и митигации провайдера, что снижает количество ложноположительных срабатываний для легитимного трафика клиента.
1.2. Цели
Дать клиентам возможность самостоятельно поддерживать актуальный список доверенных IPv4-адресов, которые будут исключаться из фильтрации во время DDoS-атак.
Предоставить сетевым инженерам единую точку просмотра и управления списками доверенных клиентских белых IPv4-адресов.
Обеспечить машиночитаемую выдачу агрегированного (суммаризированного) списка для систем фильтрации трафика.
2. Объем работ
Web-страница / закладка в личном кабинете для управления whitelist-записями.
Авторизация через существующий экземпляр Keycloak (OIDC).
Валидация формы на стороне клиента и сервера.
Внешний endpoint выдачи агрегированного списка. Выдача txt-файлом с переносом строки. Одна строка – один объект.
Хранение записей, журнал аудита.
Административное управление лимитами по компаниям.
3. Роли и права доступа
Роли определяются на основании claims в OIDC-токене Keycloak. Соответствие claim → роль настраивается на этапе развёртывания.
Роль
Идентификация
Видимость записей
Права на изменение
Клиент (client)
clientId
Только записи компаний, к которым принадлежит пользователь.
Создание, редактирование и удаление записей своих компаний (в пределах лимита).
Администратор (admin)
clientId = WZ01112 (Нубес) и отдельный чек-бокс
Записи всех компаний.
Создание, редактирование, удаление всех записей. Изменение лимита для отдельных компаний.
3.1. Принадлежность к компании
Принадлежность пользователя к компании определяется из claim токена. Поддерживается сценарий, когда пользователь принадлежит нескольким компаниям: в этом случае в интерфейсе предусматривается переключатель активной компании, а все операции выполняются в контексте выбранной компании.
Ожидаемые claims (имена согласуются с командой Keycloak):
clientID — идентификатор компании
email — идентификация пользователя для аудита
4. Функциональные требования
4.1. Просмотр списка записей
Клиент видит таблицу записей активной компании; Администратор – записи всех компаний с фильтром по компании.
Для каждой записи отображаются: значение (адрес/подсеть), комментарий (если есть), автор(email), дата создания, дата последнего изменения.
Soft-deleted записи по умолчанию скрыты; для администратора предусмотрен фильтр для их отображения.
Отображается текущее использование лимита: «использовано X из N».
4.2. Создание записи
Форма содержит поля: значение (IPv4-адрес или подсеть CIDR) и необязательный комментарий (до 255 символов).
Значение проходит валидацию (см. раздел 5) на клиенте и обязательно повторно на сервере.
Перед сохранением проверяется: соблюдение лимита компании, отсутствие пересечений и дубликатов внутри компании, отсутствие принадлежности к запрещённым диапазонам.
При успешном сохранении создаётся запись аудита.
4.3. Редактирование записи
Редактирование значения и комментария доступно компании в рамках своих прав.
При изменении значения повторно выполняется полный набор проверок валидации и пересечений.
Изменение фиксируется в журнале аудита с сохранением прежнего и нового значения.
4.4. Удаление записи (soft delete)
Удаление выполняется как логическое (soft delete): запись помечается удалённой (deleted_at, deleted_by), но физически сохраняется.
Удалённая запись освобождает место в лимите компании и исключается из внешней агрегированной выдачи.
Действие фиксируется в журнале аудита.
4.5. Лимит записей на компанию
Действует глобальный лимит по умолчанию: 15 активных записей на компанию.
Значение глобального лимита по умолчанию задаётся конфигурацией сервиса и может быть изменено без пересборки.
Для отдельной компании администратор может задать индивидуальный лимит, переопределяющий глобальный (как в большую, так и в меньшую сторону).
При попытке превысить лимит создание блокируется с понятным сообщением; в подсчёт идут только активные записи.
Снижение лимита ниже текущего числа записей не удаляет существующие записи, но блокирует создание новых до приведения в соответствие.
4.6. Журнал аудита
Все изменяющие операции фиксируются неизменяемыми записями аудита.
Каждая запись аудита содержит: кто (пользователь), когда (timestamp), компания, тип действия, прежнее и новое состояние.
Журнал доступен для просмотра только администратору.
4.7. Внешняя выдача агрегированного списка
Подсети суммаризируются (агрегируются в минимальный набор CIDR) по всем компаниям совместно. Пересечения между разными компаниями допустимы.
Предоставляется отдельный HTTP GET endpoint, отдающий полный суммаризированный список активных записей всех компаний файлом в формате txt.
Авторизация: на старте endpoint может работать без авторизации (по сетевому ограничению / разрешенный список потребителей по ip).
5. Требования к валидации
Валидация выполняется на клиенте и обязательно дублируется на сервере. Серверная валидация является авторитетной.
Правило
Описание
Формат IPv4
Допускается одиночный адрес (например 203.0.113.10) или подсеть в нотации CIDR (например 203.0.113.0/24). Допускается использование масок /32 - /22. Маска /21 и больше не допускается.
Только IPv4
IPv6-значения или доменные имена отклоняются.
Корректность подсети
Введенный адрес с маской подсети должен нормализоваться к адресу подсети, все host-биты должны быть обнулены.
Пользователь должен быть уведомлен, что ввел адрес из хостовой части, а не адрес подсети и произошла нормализация.
Запрет серых адресов
Адреса и подсети из частных диапазонов (Приложение А) запрещены к добавлению.
Отсутствие дубликатов
В пределах одной компании запрещены полностью совпадающие записи.
Отсутствие пересечений
В пределах одной компании запрещено добавление записи, пересекающейся с уже существующей (включая вложенность подсетей). Между разными компаниями пересечения допускаются.
Длина комментария
Не более 255 символов; поле необязательное.
Приложение А – Список запрещенных к созданию подсетей.
Назначение
Префикс
Private (RFC1918)
10.0.0.0/8
Private (RFC1918)
172.16.0.0/12
Private (RFC1918)
192.168.0.0/16
CGNAT (RFC6598)
100.64.0.0/10
Loopback
127.0.0.0/8
Link-local (APIPA)
169.254.0.0/16
IANA special block
192.0.0.0/24
TEST-NET-1 (docs)
192.0.2.0/24
TEST-NET-2 (docs)
198.51.100.0/24
TEST-NET-3 (docs)
203.0.113.0/24
Benchmarking
198.18.0.0/15
Multicast
224.0.0.0/4
Reserved (Class E)
240.0.0.0/4
Limited broadcast
255.255.255.255/32
-76
View File
@@ -1,76 +0,0 @@
# Endpoint SQL-запросов к БД (`/v2/db/query`)
> Создан: 2026-07-08
## Назначение
Дать DevOps-инженеру (одному человеку, заданному через `ADMIN_EMAIL`) возможность выполнять SQL-запросы к БД напрямую из браузера — без psql, без kubectl, без внешнего IP.
## Доступ
- Только один пользователь: `req.email === ADMIN_EMAIL`
- Обычный юзер сервиса (проходит SSO как все)
- Пример: `ADMIN_EMAIL=atazet@narod.ru`
## Адрес
```
GET /v2/db/query?sql=<часть запроса после SELECT>
```
## Как работает
1. Пользователь заходит в сервис через SSO (любой URL, например `/v2/app`)
2. Меняет в адресной строке путь на `/v2/db/query?sql=...`
3. Сервер:
- Проверяет сессию (resolveContext)
- Проверяет что `req.email === ADMIN_EMAIL`
- Добавляет префикс `SELECT` перед вводом пользователя
- Выполняет: `pool.query('SELECT ' + userInput)`
- Возвращает JSON
## Примеры
| Ввод в браузере | Что выполняется |
|---|---|
| `/v2/db/query?sql=* FROM _migrations` | `SELECT * FROM _migrations` |
| `/v2/db/query?sql=count(*) FROM companies` | `SELECT count(*) FROM companies` |
| `/v2/db/query?sql=* FROM whitelist_entries LIMIT 10` | `SELECT * FROM whitelist_entries LIMIT 10` |
| `/v2/db/query?sql=* FROM audit_log ORDER BY created_at DESC LIMIT 5` | `SELECT * FROM audit_log ORDER BY created_at DESC LIMIT 5` |
## Безопасность
1. **SELECT префикс** — жёстко добавляется `SELECT` перед вводом пользователя. DROP/INSERT/UPDATE/DELETE не работают:
```
/v2/db/query?sql=DROP TABLE companies
→ SELECT DROP TABLE companies (ошибка SQL, не выполнится)
```
2. **Одиночные запросы** — драйвер `pg` не поддерживает множественные запросы в одном `query()`. Попытка `1; DROP TABLE` не сработает.
3. **Доступ по email** — только один человек, заданный в `ADMIN_EMAIL`.
4. **Rate limiter** — endpoint защищён лимитером (как mutationLimiter).
## Ответ
```json
{
"rows": [ ... ],
"rowCount": 5,
"fields": ["id", "filename", "applied_at"]
}
```
## Реализация
- Файл: `v2/src/db/query.js`
- Middleware: `resolveContext` + проверка `req.canQueryDB`
- Роутер монтируется в `v2/server.js` на `/db`
- Флаг `req.canQueryDB` выставляется в `v2/src/router/index.js` (applyContext)
## Ограничения
- Только SELECT (через префикс)
- Нет GUI — только JSON в браузере
- SQL в URL (логируется в access_log nginx/ingress — это нормально для devops-инструмента)
-68
View File
@@ -1,68 +0,0 @@
# Переход на Keycloak SSO (Nubes)
## Как сейчас
- `DEV_MODE=true` — mock-аутентификация
- Любой clientId можно ввести вручную на `/login`
- JWT подписывается локальным RSA-ключом
## Что нужно от DevOps (v0.5.23)
### 1. Параметры для .env (на ВМ)
```bash
# ── Отключить mock-режим (убрать DEV_MODE или поставить false) ──
# DEV_MODE=true
# ── Keycloak realm (прямой или через API Gateway) ──
KC_BASE_URL=https://keycloak.nubes.ru/realms/cloud
# ИЛИ через API Gateway:
# KC_BASE_URL=<url API Gateway>
# ── OAuth client (зарегистрированное приложение) ──
KC_CLIENT_ID=<ID клиента из Keycloak>
KC_CLIENT_SECRET=<секрет клиента>
# ── Публичный URL приложения (для редиректа из Keycloak) ──
APP_URL=https://<ваш-домен>
# ── JWKS URL для верификации токенов (если не через KC_BASE_URL) ──
# JWKS_URL=https://keycloak.nubes.ru/realms/cloud/protocol/openid-connect/certs
```
### 2. Уточнить
| Вопрос | Описание |
|---|---|
| **API Gateway?** | Токен получать напрямую из Keycloak или через API Gateway? |
| **ClientID в токене** | Какой claim содержит ID компании? Сейчас ждём `ClientID` или `client_id`. |
| **Admin-роль** | Как определяется admin? `clientId === WZ01112` достаточно или нужен отдельный claim? |
| **Redirect URI** | Какой зарегистрирован? `https://<домен>/callback`? |
| **Несколько компаний** | Компании через IAM API (GET /auth/user → profiles[]) |
### 3. Что сделано в коде (готово)
| Компонент | Файл |
|---|---|
| JWKS-загрузка + кеширование | `src/auth.js` |
| Верификация RS256 JWT | `src/auth.js` |
| `buildAuthUrl()` — редирект на Keycloak | `src/auth.js` |
| `exchangeCode()` — обмен code→token | `src/auth.js` |
| `buildLogoutUrl()` — logout | `src/auth.js` |
| OIDC-роуты /login, /callback, /logout | `src/routes/oidc.js` |
| `userFromPayload()` — парсинг claims | `src/auth.js` |
### 4. Порядок включения
```bash
# 1. Настроить .env на ВМ (параметры выше)
# 2. Перезапустить
pm2 restart ipwhitelist --update-env
# 3. Проверить что включился OIDC-режим
pm2 logs ipwhitelist --lines 3
# Должно быть: "[auth] OIDC — Keycloak"
# Если видно "[auth] MOCK" — значит KC_CLIENT_ID не задан
# 4. Открыть https://<домен>/login
# Должен редиректить на Keycloak вместо формы входа
```
-285
View File
@@ -1,285 +0,0 @@
# Деплой ipwhitelist — PRODUCTION
Репозиторий: `https://gitea.services.ngcloud.ru/Nail/ipwhitelist-app.git` (публичный)
ОС: Ubuntu 22.04 / 24.04 (для других ОС команды могут отличаться)
---
## 0. Что должно быть готово до начала
| Что | Откуда берётся |
|---|---|
| Сервер (ВМ) с Ubuntu | Выделяет DevOps |
| Домен (например `white.nubes.ru`) | DNS запись на сервер |
| Доступ к Keycloak Nubes (realm `cloud`) | Существующий Keycloak в инфраструктуре Nubes |
| Зарегистрированный OIDC-клиент в Keycloak | Создаётся в Keycloak (шаг 6) |
| `KC_CLIENT_ID` и `KC_CLIENT_SECRET` | Выдаются после регистрации клиента |
**Что будет установлено на сервер:** Node.js 20, PostgreSQL 16, nginx, certbot, PM2.
---
## 1. PostgreSQL — установка
**Где:** на этой же ВМ, рядом с приложением.
**Версия:** 16 (на Ubuntu 22.04 — 14, на 24.04 — 16).
**Безопасность:** слушает только localhost (наружу не торчит).
```bash
# 1.1 Установить PostgreSQL
sudo apt update
sudo apt install -y postgresql
# 1.2 Проверить что запустился и слушает localhost
sudo systemctl status postgresql --no-pager
ss -tlnp | grep 5432
# Должно быть: 127.0.0.1:5432 (НЕ 0.0.0.0:5432)
# 1.3 Сгенерировать пароль для БД — он понадобится в .env
DB_PASS=$(openssl rand -base64 24)
echo "Пароль БД: $DB_PASS" # сохранить — вписать в .env
# 1.4 Создать пользователя и базу данных
sudo -u postgres psql -c "CREATE USER ipwhitelist WITH PASSWORD '${DB_PASS}';"
sudo -u postgres psql -c "CREATE DATABASE ipwhitelist OWNER ipwhitelist;"
```
**Таблицы создадим позже (шаг 2.3), после клонирования кода — схема лежит в репозитории.**
---
## 2. Node.js + код приложения + таблицы БД
```bash
# 2.1 Установить Node.js 20
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo bash -
sudo apt install -y nodejs
# 2.2 Скачать код и установить зависимости
sudo git clone https://gitea.services.ngcloud.ru/Nail/ipwhitelist-app.git /opt/ipwhitelist
sudo chown -R $(whoami):$(whoami) /opt/ipwhitelist # чтобы npm install не просил sudo
cd /opt/ipwhitelist
npm install --production
# 2.3 Теперь код есть — можно создать таблицы (schema.sql в репозитории)
sudo -u postgres psql -d ipwhitelist -f /opt/ipwhitelist/sql/schema.sql
# 2.4 Проверить что таблицы создались
sudo -u postgres psql -d ipwhitelist -c "\dt"
# Результат: companies, whitelist_entries, audit_log, session
```
---
## 3. .env — настройка приложения
```bash
# 3.1 Скопировать шаблон
cd /opt/ipwhitelist
sudo cp .env.example .env
sudo chmod 600 .env # чтобы никто не прочитал пароли
```
**3.2 Открыть .env и заполнить значения:**
```bash
sudo nano /opt/ipwhitelist/.env
```
**Что и куда писать:**
| Строка в .env | Что вписать |
|---|---|
| `DB_PASS=` | Пароль, сгенерированный на шаге 1.3 |
| `DEV_MODE=false` | **ОБЯЗАТЕЛЬНО false** иначе будет mock-логин |
| `ADMIN_CLIENT_ID=WZ01112` | clientId администратора (получить у заказчика) |
| `KC_CLIENT_ID=` | ID клиента из Keycloak (шаг 6) |
| `KC_CLIENT_SECRET=` | Secret клиента из Keycloak (шаг 6) |
| `APP_URL=https://white.nubes.ru` | **Реальный домен** без слеша на конце |
| `SESSION_SECRET=` | Сгенерировать: `openssl rand -base64 32` |
| `CSRF_SECRET=` | Сгенерировать: `openssl rand -base64 32` |
| `IAM_API_URL=` | **Выбрать стенд**: `auth-api-dev` (dev), `auth-api-test` (test), `auth-api` (prod). По умолчанию dev — сменить для production! |
**Остальные строки не трогать** — значения по умолчанию подходят.
---
## 4. nginx — HTTPS и прокси
**Зачем:** браузер не пускает на HTTPS-сайты с localhost. Нужен nginx как прослойка.
```bash
# 4.1 Установить nginx и certbot
sudo apt install -y nginx certbot python3-certbot-nginx
```
**4.2 Создать конфиг nginx.** Заменить `white.nubes.ru` на свой домен:
```bash
sudo nano /etc/nginx/sites-available/ipwhitelist
```
Вставить:
```nginx
server {
listen 80;
server_name white.nubes.ru; # ← ЗАМЕНИТЬ на свой домен
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name white.nubes.ru; # ← ЗАМЕНИТЬ на свой домен
ssl_certificate /etc/letsencrypt/live/white.nubes.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/white.nubes.ru/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
```
```bash
# 4.3 Включить сайт
sudo ln -s /etc/nginx/sites-available/ipwhitelist /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
# 4.4 Получить SSL-сертификат (certbot сам подправит конфиг)
sudo certbot --nginx -d white.nubes.ru # ← ЗАМЕНИТЬ на свой домен
# 4.5 Проверить что nginx работает
sudo nginx -t
sudo systemctl reload nginx
```
---
## 5. PM2 — автозапуск приложения
**Зачем:** чтобы приложение само стартовало после перезагрузки сервера.
```bash
# 5.1 Установить PM2
sudo npm i -g pm2
# 5.2 Запустить приложение
cd /opt/ipwhitelist
sudo pm2 start server.js --name ipwhitelist --update-env
# 5.3 Настроить автозапуск при перезагрузке
sudo pm2 startup systemd
sudo pm2 save
# 5.4 Проверить что запустилось
sudo pm2 status
# Должен быть статус: online
```
---
## 6. Keycloak — регистрация OIDC-клиента
**Где:** в админке Keycloak Nubes (`https://keycloak.nubes.ru/admin`, realm `cloud`).
**Зачем:** чтобы Keycloak знал наше приложение и пускал пользователей обратно после входа.
**6.1 Создать клиента:**
| Поле | Значение |
|---|---|
| Client ID | `white.nubes.ru` (или свой домен) |
| Client authentication | ON (включено) |
| Standard flow | ON (включено) |
| Valid redirect URIs | `https://white.nubes.ru/callback` |
| Web origins | `https://white.nubes.ru` |
**6.2 Забрать секрет:** после создания клиента открыть вкладка `Credentials``Client secret` → скопировать.
**6.3 Создать mapper'ы** (вкладка `Client scopes``<ClientID>-dedicated``Add mapper``By configuration``User Attribute`):
Создать три mapper'а:
| Имя | User Attribute | Claim name | Тип |
|---|---|---|---|
| `ClientID` | `client_id` | `ClientID` | String |
| `company_id` | `company_id` | `company_id` | String |
| `company_name` | `company_name` | `company_name` | String |
**6.4 Создать пользователей** (вкладка `Users``Add user`):
| Логин | Пароль | Атрибуты |
|---|---|---|
| `admin` | задать вкладка Credentials | `client_id=WZ01112`, `company_id=1`, `company_name=Nubes Admin` |
| `client` | задать | `client_id=WZ88888`, `company_id=2`, `company_name=Альфа` |
**Как задать атрибуты:** вкладка `Attributes` → добавить по одному → `Save`.
**Multi-company:** список компаний пользователя — через IAM API (`GET /api/v1/auth/user → profiles[]`). Активная компания определяется по `is_active_profile`. Дополнительных атрибутов в Keycloak не требуется.
---
## 7. Финальная проверка
```bash
# 7.1 Убедиться что в .env всё заполнено
grep -E "DEV_MODE|KC_CLIENT_ID|KC_CLIENT_SECRET|APP_URL|DB_PASS" /opt/ipwhitelist/.env
# Проверить: DEV_MODE=false, KC_CLIENT_ID и KC_CLIENT_SECRET не пустые
# 7.2 Перезапустить приложение
cd /opt/ipwhitelist
sudo pm2 restart ipwhitelist --update-env
# 7.3 Проверить что включился OIDC-режим
sudo pm2 logs ipwhitelist --lines 3 --nostream
# В логе должно быть: [auth] OIDC — Keycloak
# Если [auth] MOCK — значит KC_CLIENT_ID/KC_CLIENT_SECRET не заданы
```
**Открыть в браузере:**
| URL | Что должно произойти |
|---|---|
| `https://white.nubes.ru/healthz` | Покажет `OK` |
| `https://white.nubes.ru/` | Редирект на страницу логина Keycloak |
| Ввести логин/пароль пользователя из Keycloak | Редирект обратно на главную с белым списком |
| `https://white.nubes.ru/export` | Список CIDR (текст) |
| `https://white.nubes.ru/logout` | Выход из системы |
---
## 8. Обновление приложения
```bash
cd /opt/ipwhitelist
sudo git pull
sudo npm install --production
sudo pm2 restart ipwhitelist --update-env
```
---
## 9. Переменные .env — полный список
| Переменная | Когда нужна | По умолчанию | Что писать |
|---|---|---|---|
| `DB_HOST` | всегда | — | `localhost` |
| `DB_PORT` | всегда | 5432 | — |
| `DB_NAME` | всегда | — | `ipwhitelist` |
| `DB_USER` | всегда | — | `ipwhitelist` |
| `DB_PASS` | всегда | — | пароль из шага 1.3 |
| `DB_SSLMODE` | никогда | disable | не трогать |
| `DEV_MODE` | всегда | true | **false** в production |
| `ADMIN_CLIENT_ID` | всегда | WZ01112 | clientId админа |
| `KC_CLIENT_ID` | для SSO | — | из Keycloak (шаг 6) |
| `KC_CLIENT_SECRET` | для SSO | — | из Keycloak (шаг 6) |
| `KC_BASE_URL` | для SSO | `https://keycloak.nubes.ru/realms/cloud` | не трогать |
| `APP_URL` | для SSO | — | `https://white.nubes.ru` |
| `SESSION_SECRET` | всегда | — | `openssl rand -base64 32` |
| `CSRF_SECRET` | опционально | — | `openssl rand -base64 32` |
| `PORT` | всегда | 3000 | `3001` |
| `DEFAULT_LIMIT` | опционально | 15 | не трогать |
| `API_BASE` | всегда | `http://localhost:PORT` | не трогать |
-128
View File
@@ -1,128 +0,0 @@
# Сессия 2026-06-11 — Имперсонация, toggle, аудит
## Хронология
### 1. Починка toggle «Администратор»
**Проблема:** чекбокс не показывался.
**Причина:** `canAdminMode` вычислялся в `resolveUser`, но не передавался в `res.render`.
**Исправление:** добавлен `canAdminMode: req.user.canAdminMode` в `entries.js`.
### 2. Дубликат catch → крэш
При правке `ui/index.js` остался дубликат `} catch {`. Приложение падало при старте.
**Исправление:** удалён лишний catch.
### 3. JS onclick → form POST
Toggle не реагировал — сложный inline JS с `getComputedStyle` и `fetch`.
**Исправление:** заменён на `<form method="POST" action="/toggle-admin">`. Ноль JS.
### 4. Чужой email в header
Админ видел `nstasyuk@nubes.ru` вместо своего — шаблон показывал `selectedCompany.owner_email`.
**Исправление:** всегда показывать `user.email`.
### 5. Хардкод isNail
IAM не давал `isAdmin: true` для `ntazetdinov@nubes.ru`.
**Исправление:** временный хардкод `isNail` в `ui/index.js`.
### 6. canWrite из IAM
IAM возвращает `can_write: false` для всех — это IAM-шная RBAC, не наш флаг.
**Исправление:** `canWrite` убран полностью.
### 7. Имперсонация: флаги из IAM
Добавлены поля из IAM: `isImpersonated`, `originalUserEmail`, `originalUserFullName`, `impersonationType`.
**Файлы:** `src/auth.js:fetchIamUser`, `src/routes/oidc.js`, `ui/index.js:resolveUser`.
### 8. Баннер имперсонации
В `views/index.ejs` добавлен жёлтый баннер при `isImpersonated`.
### 9. Двойной аудит
Колонка `impersonated_by` в `audit_log`.
**Файлы:** `src/db.js` (миграция), `src/queries.js:logAudit`, `src/api/routes/entries.js`.
### 10. X-Imp-* заголовки — мост UI→API
UI-слой знал про имперсонацию, API-слой — нет.
**Исправление:** `api-client.js:impHeaders()`, `bearerMiddleware` читает `X-Imp-Email`, `X-Imp-OriginalEmail`, `X-Imp-ClientId`.
### 11. Безопасность: OIDC state check
`|| true` в `/callback` отключал проверку state во всех окружениях.
**Исправление:** убрано `|| true`.
### 12. Безопасность: open redirect
`POST /login-token` редиректил без `safeReturn`.
**Исправление:** добавлен `safeReturn(returnTo)`.
### 13. Безопасность: client_id без проверки
При пустом `allowedIds` можно было подставить любой `?client_id=`.
**Исправление:** `requestedId` принимается только если `allowedIds.length > 0`.
### 14. Тестовая имперсонация
Три env-переменные: `IMPERSONATION_ORIGINAL`, `IMPERSONATION_TARGET`, `IMPERSONATION_COMPANY`.
**Файл:** `ui/index.js` (блок `TEST IMPERSONATION`).
### 15. Часовой пояс GMT+4
`Europe/Moscow``Etc/GMT-4` в `views/index.ejs` и `views/audit.ejs`.
### 16. Аудит: колонка «Имперсонировал»
В `views/audit.ejs` добавлена колонка `impersonated_by`. Фильтр по компании — кнопка вместо JS.
### 17. Удалённые записи: визуал
Зачёркивание + полупрозрачность. Кнопки скрыты для удалённых.
**Файл:** `views/index.ejs`.
### 18. Чекбокс «Показать удалённые»
Возвращён в админ-режим.
**Файл:** `views/index.ejs`.
### 19. ТЗ-факт
Составлен документ `TZs/ТЗ-факт.md` — техзадание по фактически реализованному функционалу.
### 20. Сверка с требованиями Сергея
Прочитан `Files/wsm.txt`. Подтверждено соответствие. Не сделано: ссылка на запрещённые префиксы, текст-мануал от Стасюка.
## Основные файлы, изменённые в сессии
| Файл | Что |
|---|---|
| `ui/index.js` | canAdminMode, isNail, TEST IMPERSONATION, canWrite |
| `ui/routes/entries.js` | canAdminMode в render, impHeaders, упрощение ownCo |
| `ui/routes/auth.js` | toggle-admin redirect, open redirect fix |
| `ui/api-client.js` | impHeaders(), X-Imp-* заголовки |
| `src/auth.js` | fetchIamUser (все поля), bearerMiddleware (X-Imp-*), safeReturn |
| `src/routes/oidc.js` | isImpersonated в сессию, fix || true |
| `src/api/routes/entries.js` | impersonatedBy в queries, client_id fix, export JSON |
| `src/db.js` | ALTER TABLE impersonated_by |
| `src/queries.js` | logAudit с impersonatedBy |
| `views/index.ejs` | toggle (form POST), баннеры, удалённые записи, includeDeleted |
| `views/audit.ejs` | колонка impersonated_by, кнопка фильтра, GMT+4 |
| `server.js` | debug-session с lastAudit |
## Ветки
- `save-2026-06-11` — снапшот перед критичными фиксами
- `save-2026-06-12` — финальное состояние
---
## Что осталось (для следующего чата)
### Баги
1. **Toggle виден при имперсонации** — по требованию Сергея: «Когда я имперсонирую — вижу то же что пользователь». При имперсонации `canAdminMode` должен быть false (сейчас true, потому что вычисляется из оригинального JWT, а не из `activeClientId`). `ui/index.js` строка 65-67.
2. **Email в header берётся из `payload.email`**, а не из `req.session?.user?.email`. Иногда показывает чужой email. `ui/index.js` строка 75.
### Не сделано по ТЗ/требованиям Сергея
3. Ссылка на список зарезервированных префиксов (14 диапазонов)
4. Краткий мануал на главной для пользователей (ждём от Стасюка)
### Временные решения (убрать при настройке IAM)
5. `isNail` — хардкод `ntazetdinov@nubes.ru` как админ. Заменить на `isAdmin` из IAM. `ui/index.js` строка 63.
6. `TEST IMPERSONATION` — env-переменные. Убрать для прода. `ui/index.js` строки 70-78.
### Архитектурный долг
7. Дублирование `buildSessionUser` в `oidc.js` и `auth.js`
8. Флаги `isAdmin`/`adminMode`/`canAdminMode`/`isNail` — упростить до двух
9. Циклическая зависимость `ui → src/auth` через `require('../../src/auth')`
## Как работать дальше
1. Прочитать ТЗ: `TZs/ТЗ-факт.md` (фактическое), `docs/ТЗ.md` (оригинал)
2. Прочитать требования Сергея: `Files/wsm.txt` (особенно строки 378-470)
3. Начать с бага №1 (toggle + имперсонация) — это противоречит требованию заказчика
-61
View File
@@ -1,61 +0,0 @@
# Сессия 2026-06-12
## Архитектурный анализ
**Вердикт: модульный монолит (modular monolith)**
Не чистый монолит, не микросервисы. Осмысленная архитектура с DI и разделением на слои.
### Слои
```
UI (EJS) → HTTP → API (JSON) → Queries → PostgreSQL
DI через фабрики:
createApiRouter({ auth, q })
```
UI не ходит в БД напрямую — всегда через `ui/api-client.js` → HTTP → `/api/v1/*`. Разделение по протоколу.
### Степень связанности
| Модуль | Связанность | Почему |
|--------|-------------|--------|
| `validators.js` | Ноль | Только `net`, чистые функции |
| `config.js` | Ноль | Чистые данные |
| `api-client.js` | Ноль | Только `http`/`https` |
| `bearerAuth.js` | Ноль | Standalone middleware |
| `queries.js``db.js` | Tight | Data-access — иначе быть не может |
| `server.js` → всё | Tight | Оркестратор — нормально |
### Что хорошо
- DI-паттерн: роутеры получают зависимости через фабрики
- Нет циклических зависимостей — граф чистый DAG
- Большинство модулей тестируемы изолированно
- Лёгкий вынос API в микросервис: поменять `API_BASE` в `api-client.js`
### Что не очень
- `server.js` знает про все модули (15 импортов) — но для монолита норма
- `src/auth.js` делает и JWT, и IAM, и bearer — можно разделить
- Дублирование `buildSessionUser` в `oidc.js` и `auth.js`
### Граф зависимостей
```
server.js
├── src/db.js → pg
├── src/middleware/session.js → express-session, connect-pg-simple
├── src/middleware/csp.js
├── src/middleware/rateLimit.js
├── src/auth.js → src/config.js
├── src/queries.js → src/db.js, src/validators.js
├── src/validators.js → net
├── src/config.js (ноль зависимостей)
├── src/api/index.js → src/api/routes/* → src/validators.js
│ └── src/api/middleware/bearerAuth.js (ноль зависимостей)
├── ui/index.js → ui/routes/* → ui/api-client.js (только http/https)
│ └── ui/routes/auth.js → src/auth.js (только safeReturn)
└── src/routes/oidc.js
```
@@ -1,135 +0,0 @@
# Сессия 2026-06-22 — export view=2, фикс имперсонации, TZ аудит
## Версии
- **0.1.1** — сброс версии с 0.5.180
- **0.1.2** — `white-space: pre` на `<code>` (пробелы схлопывались)
- **0.1.3** — таблица без точек, октеты в своих `<td>`
- **0.1.4** — `text-align: right` в ячейках
- **0.1.5** — `padStart(2)` плотнее (один пробел, не два)
- **0.1.6** — фикс детекции имперсонации
- **0.1.7** — аудит: время Москвы вместо UTC
---
## 1. Export view=2 — HTML-таблица с выровненными IP
### Устройство
Файл: `v2/src/export/index.js`
Три режима:
| Параметр | Content-Type | Поведение |
|---|---|---|
| (нет) | `text/plain` | Скачивание `.txt` |
| `?view=1` | `text/plain` | Inline в браузере |
| `?view=2` | `text/html` | Таблица: 4 ячейки октетов + маска |
### Структура view=2
```html
<table>
<tr>
<td> 62</td><td> 76</td><td> 39</td><td> 5</td><td> / 32</td>
</tr>
</table>
```
- Каждый октет в своей `<td>`, без точек
- `padStart(2, ' ')` — минимум 2 символа на октет
- `text-align: right` — выравнивание по правой границе
- `white-space: pre` — пробелы не схлопываются
- Границы не видны (`border-collapse: collapse`, без `border` на td)
---
## 2. Фикс имперсонации — БАГ
### Симптом
На странице `/v2/app` не показывался баннер «Режим имперсонации», хотя IAM API возвращал `is_impersonated: true`.
### Причина
В двух местах `v2/src/router/index.js` проверка имперсонации использовала `u.impersonatedCompanyId`:
```js
// checkImpersonation — строка 78 (было)
const oldIsImp = !!(u.originalUserEmail && u.impersonatedCompanyId);
// applyContext — строка 133 (было)
const isImpersonated = !!(u.originalUserEmail && u.impersonatedCompanyId);
```
**Поле `impersonatedCompanyId` отсутствует в ответе IAM.** IAM `/auth/user` возвращает:
```json
{
"impersonation": {
"is_impersonated": true,
"impersonatedUserId": "...",
"originalUserEmail": "ntazetdinov@nubes.ru",
"originalUserFullName": "...",
"originalUserCompany": "...",
"sessionId": 613,
...
// ⚠️ impersonatedCompanyId — НЕТ
}
}
```
Из-за этого:
- `u.impersonatedCompanyId` всегда `''` (пустая строка = falsy)
- `!!(truthy && falsy)` → всегда `false`
- Баннер не показывался
- `checkImpersonation` на каждый запрос заново дёргал IAM (думал что статус изменился)
### Исправление
Заменить проверку на `u.isImpersonated` (которое корректно выставляется в `checkImpersonation`):
```js
// checkImpersonation (исправлено)
const oldIsImp = !!u.isImpersonated;
// applyContext (исправлено)
const isImpersonated = !!(u.isImpersonated || u.originalUserEmail);
```
---
## 3. Время в аудите — UTC → MSK
Файл: `views/v2/admin-audit.ejs`, строка 100.
```js
// Было (возвращает серверное время = UTC):
new Date(a.created_at).toLocaleString('ru')
// Стало (Москва, UTC+3):
new Date(a.created_at).toLocaleString('ru-RU', { timeZone: 'Europe/Moscow' })
```
---
## 4. Проверка IAM API
Три эндпоинта, проверены через Node.js с IAM-токеном (`iss: auth-api`):
| Эндпоинт | Статус | Результат |
|---|---|---|
| `auth-api /auth/user` | 200 | Полные данные + имперсонация |
| `lk-api-gateway /iam/auth/user` | 200 | **Идентично** auth-api |
| `auth-api /impersonation/status` | 200 | `is_impersonating`, `session_id`, etc. |
Данные между `auth-api` и `lk-api-gateway` совпадают поле-в-поле.
### Важно: токены
- **Keycloak-токен** (iss: `auth.k8s.ngcloud.ru`) → IAM возвращает 401 (не может проверить подпись)
- **IAM-токен** (iss: `auth-api`) → работает
- **Tech-токен** (8541 символ) → nginx 400 (превышен лимит заголовка 8KB)
- Для проверки API нужен IAM-токен из активной сессии
---
## 5. Админ при имперсонации
`tazet@narod.ru` показывается как админ, потому что:
1. IAM честно говорит `isAdmin: false` для `tazet@narod.ru`
2. Но `applyContext` проверяет `email === ADMIN_EMAIL``true`
3. `ADMIN_EMAIL=tazet@narod.ru` в ENV
Это ожидаемое поведение — `ADMIN_EMAIL` даёт админские права независимо от IAM.
-83
View File
@@ -1,83 +0,0 @@
# IAM Impersonation API
> Источник: Swagger `https://auth-api-dev.ngcloud.ru/api/v1/documentation/`
> Дата: 2026-06-16
## Все эндпоинты имперсонации
| Метод | Путь | Описание |
|-------|------|----------|
| `POST` | `/api/v1/impersonation/start` | Начать имперсонацию |
| `POST` | `/api/v1/impersonation/end` | Завершить имперсонацию |
| `POST` | `/api/v1/impersonation/extend` | Продлить сессию имперсонации |
| `GET` | `/api/v1/impersonation/status` | Статус текущей имперсонации |
| `GET` | `/api/v1/impersonation/history` | История своих имперсонаций |
| `GET` | `/api/v1/impersonation/history/all` | История имперсонаций всех админов |
## Формат ImpersonationStartRequest
```json
{
"type": "user",
"entity_id": "UUID контакта (contactId из userInfo)",
"reason": "описание причины"
}
```
- `entity_id`**UUID таргета** (кого имперсонируем), не свой
- `type` — всегда `"user"`
## curl-команды
### Начать имперсонацию
```bash
curl --http2 -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
"https://auth-api.ngcloud.ru/api/v1/impersonation/start" \
-d '{"type":"user","entity_id":"UUID_таргета","reason":"тест"}'
```
### Статус
```bash
curl --http2 \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json" \
"https://auth-api.ngcloud.ru/api/v1/impersonation/status"
```
### История
```bash
curl --http2 \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json" \
"https://auth-api.ngcloud.ru/api/v1/impersonation/history"
```
### Продлить
```bash
curl --http2 -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json" \
"https://auth-api.ngcloud.ru/api/v1/impersonation/extend"
```
### Завершить
```bash
curl --http2 -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json" \
"https://auth-api.ngcloud.ru/api/v1/impersonation/end"
```
## Примечания
- Требуется роль IAM-админа или право на объект `impersonation`
- `--http2` обязателен для обхода ddos-guard
- Стенды: `auth-api.ngcloud.ru` (prod), `auth-api-test.ngcloud.ru` (test), `auth-api-dev.ngcloud.ru` (dev)
-480
View File
@@ -1,480 +0,0 @@
# IAM API — интеграция с ipwhitelist-app
> Дата: 2026-06-04
> Исследование: проверка текущей интеграции и план доработок
---
## 1. Что такое IAM API
IAM (Identity & Access Management) — единый сервис авторизации экосистемы Nubes. Через него работают Личный Кабинет (ЛК) и все смежные сервисы.
**Три стенда:**
| Стенд | URL |
|---|---|
| Dev | `https://auth-api-dev.ngcloud.ru` |
| Test | `https://auth-api-test.ngcloud.ru` |
| Prod | `https://auth-api.ngcloud.ru` |
**Swagger-документация:** `https://auth-api-dev.ngcloud.ru/api/v1/documentation/`
---
## 2. Исходные данные (Elma CRM)
Изначальный источник — CRM Elma. Две сущности:
- **Контакт** (contact) — человек, пользователь
- **Компания** (company) — организация
Отношение **не 1:1**. Контакт может представлять несколько компаний. Это нормальная ситуация.
В токене IAM, который пользователь получает в ЛК, зашита **основная** компания. Но через API IAM можно получить **все** доступные пользователю компании и узнать, какая из них **активна в данный момент**.
Смежные сервисы ходят в IAM API для проверки, что услуга выпускается для правильной компании.
---
## 3. Ключевые эндпоинты IAM API
### 3.1. `GET /api/v1/auth/user` — информация о пользователе
**Назначение:** получение полной информации о текущем пользователе, включая список всех доступных профилей (компаний) и флаг активного профиля.
**Авторизация:** `Authorization: Bearer <token>`
**Реальный ответ** (тестовый пользователь `tazetdinovn@gmail.com`, компания `naeel_test`, ClientID `WZ03709`):
```json
{
"impersonation": {
"is_impersonated": false
},
"isPortal": false,
"needChangePassword": false,
"owner": false,
"permissions": {
"can_write": false,
"has_write_permissions": false,
"is_impersonating": false,
"read_only_mode_enabled": false,
"reason": "insufficient_permissions"
},
"privileges": null,
"profiles": [
{
"id": 2833,
"client_id": "WZ03709",
"company_id": "019cc24a-727e-740f-b407-bec79dab4162",
"company_name": "naeel_test",
"company_numeric_id": 2645,
"is_active_profile": true
}
],
"roles": [
{
"created_at": "0001-01-01T00:00:00Z",
"role_id": 1,
"role_name": "Пользователь",
"user_uuid": "019cc268-6c6a-781e-8613-4bed4ec7cd20"
}
],
"sessionId": "",
"userId": "019cc268-6c6a-781e-8613-4bed4ec7cd20",
"userInfo": {
"accounts": [],
"avatar": [""],
"clientID": "WZ03709",
"company": "naeel_test",
"companyId": "019cc24a-727e-740f-b407-bec79dab4162",
"companyManager": "",
"companyNumericId": 2645,
"contactId": "019cc268-6c6a-781e-8613-4bed4ec7cd20",
"currentSupportLevel": "",
"elmaUserId": 17193,
"email": "tazetdinovn@gmail.com",
"externalUser": true,
"fio": {
"fullName": "Тазетдинов Наиль Фаритович",
"name": "Наиль",
"secondName": "Фаритович",
"surname": "Тазетдинов"
},
"groupIds": null,
"integration": { "serviceId": "" },
"isAdmin": false,
"login": "",
"mobilePhone": [],
"position": "",
"userId": "019cc268-6c6a-781e-8613-4bed4ec7cd20"
}
}
```
#### Ключевые поля для интеграции:
| Поле | Тип | Описание |
|---|---|---|
| `profiles[]` | array | **Все** доступные пользователю профили (компании) |
| `profiles[].id` | integer | ID профиля для `POST /switch-profile` |
| `profiles[].client_id` | string | WZ-код компании |
| `profiles[].company_id` | string | UUID компании |
| `profiles[].company_name` | string | Название компании |
| `profiles[].company_numeric_id` | integer | Числовой ID компании |
| `profiles[].is_active_profile` | boolean | **Какой профиль активен в данный момент** |
| `userInfo.clientID` | string | WZ-код **активной** компании |
| `userInfo.company` | string | Название активной компании |
| `userInfo.companyId` | string | UUID активной компании |
| `userInfo.companyNumericId` | integer | Числовой ID активной компании |
| `userInfo.email` | string | Email пользователя |
| `userInfo.isAdmin` | boolean | Флаг администратора |
| `userInfo.fio` | object | ФИО пользователя |
| `userInfo.contactId` | string | UUID контакта в Elma |
| `userId` | string | UUID пользователя |
#### Пример для пользователя с несколькими компаниями:
```json
"profiles": [
{
"id": 2833,
"client_id": "WZ03709",
"company_name": "naeel_test",
"is_active_profile": true // ← активная
},
{
"id": 3120,
"client_id": "WZ12345",
"company_name": "Другая Компания",
"is_active_profile": false // ← неактивная
}
]
```
### 3.2. `POST /api/v1/user/switch-profile` — переключение компании
**Назначение:** переключить активный профиль пользователя на другую компанию.
**Тело запроса:**
```json
{
"profile_id": 3120
}
```
или
```json
{
"company_id": "uuid-компании"
}
```
**Ответ:**
```json
{
"success": true,
"active_profile": {
"id": 3120,
"company_id": "uuid",
"company_name": "Другая Компания",
"email": "user@example.com",
"full_name": "ФИО",
"contact_id": "uuid"
}
}
```
---
## 4. Текущее состояние ipwhitelist-app
### 4.1. Как работает сейчас
Приложение **НИ РАЗУ не вызывает IAM API**. Вся информация о пользователе и компаниях извлекается из JWT-токена в `src/auth.js`:
```javascript
// src/auth.js — userFromPayload()
const rawClientId = payload.ClientID || payload.client_id || '';
const allClientIds = rawClientId.split(',').map(s => s.trim()).filter(Boolean);
// + поиск WZ* в claims массиве
return {
clientId: activeClientId, // первый из allClientIds
allClientIds, // comma-separated из ClientID
activeClientId, // то же что и clientId
companyId: payload.company_id,
companyName: payload.company_name,
isAdmin: activeClientId === 'WZ01112',
};
```
### 4.2. Проблемы
1. **`allClientIds` из токена — ненадёжно.** JWT содержит только `ClientID` активной компании (одно значение). Comma-separated значения — хак/предположение, не гарантированное IAM.
2. **Нет запроса к IAM API.** Приложение должно делать `GET /api/v1/auth/user` для получения актуального списка профилей.
3. **`is_active_profile` не используется.** Приложение понятия не имеет, какой профиль активен с точки зрения IAM. Активная компания определяется как «первая в списке».
4. **Переключение компаний — только локальное.** В `ui/routes/entries.js` параметр `?switchTo=` меняет `activeClientId` в сессии, но не вызывает `POST /switch-profile` на IAM. Если пользователь переключит компанию через ЛК, приложение об этом не узнает.
5. **Проверка `isAdmin` — хардкод.** `activeClientId === 'WZ01112'` вместо использования `userInfo.isAdmin` из IAM.
6. **Избыточный парсинг JWT.** Поля `company_id`, `company_name`, `ClientID` парсятся из токена, хотя правильный источник — ответ IAM API.
### 4.3. Что должно быть
| Данные | Сейчас (источник) | Должно быть (источник) |
|---|---|---|
| Список компаний | `ClientID` из JWT | `profiles[].client_id` из IAM |
| Активная компания | Первая из списка | `profiles[].is_active_profile === true` |
| WZ-код | `payload.ClientID` | `userInfo.clientID` |
| Название компании | `payload.company_name` | `userInfo.company` или `profiles` |
| UUID компании | `payload.company_id` | `userInfo.companyId` |
| isAdmin | `clientId === 'WZ01112'` | `userInfo.isAdmin` |
| Переключение | Сессия (`req.session.user.activeClientId`) | `POST /switch-profile` + сессия |
---
## 5. План доработок
### 5.1. `src/config.js` — добавить URL IAM API
```javascript
const IAM_API_URL = process.env.IAM_API_URL || 'https://auth-api-test.ngcloud.ru';
```
### 5.2. `src/auth.js` — добавить `fetchIamUser(token)`
Новая функция, которая делает HTTP-запрос к `GET {IAM_API_URL}/api/v1/auth/user` и возвращает объект пользователя на основе ответа IAM:
```javascript
async function fetchIamUser(token) {
// GET {IAM_API_URL}/api/v1/auth/user
// Authorization: Bearer <token>
// Возвращает:
// {
// email: userInfo.email,
// clientId: userInfo.clientID, // активная компания
// allClientIds: profiles.map(p => p.client_id),
// activeProfileId: profiles.find(p => p.is_active_profile)?.id,
// companyId: userInfo.companyId,
// companyName: userInfo.company,
// isAdmin: userInfo.isAdmin,
// userId: userId,
// contactId: userInfo.contactId,
// fio: userInfo.fio,
// profiles: profiles, // полный список для UI
// }
}
```
### 5.3. `src/auth.js` — изменить `userFromPayload()`
После получения токена (в OIDC-режиме) вызывать `fetchIamUser()` и использовать его результат вместо парсинга JWT.
В mock-режиме — оставить текущую логику (IAM API недоступен локально).
### 5.4. `src/auth.js` — добавить `switchProfile(token, profileId)`
```javascript
async function switchProfile(token, profileId) {
// POST {IAM_API_URL}/api/v1/user/switch-profile
// Body: { profile_id: profileId }
// Возвращает новый active_profile
}
```
### 5.5. `ui/routes/entries.js` — изменить логику `?switchTo=`
При переключении компании:
1. Вызвать `auth.switchProfile(token, profileId)` на IAM
2. Обновить `req.session.user` с новым `activeClientId`
3. Сохранить актуальный список `profiles`
### 5.6. `ui/routes/entries.js` — передавать `profiles` в шаблон
Для отображения списка доступных компаний в UI (выпадающий список с отметкой активной).
---
## 6. Сетевая доступность — ddos-guard и TLS fingerprint
### 6.1. Проблема
Все стенды `*.ngcloud.ru` защищены ddos-guard. При попытке доступа через `curl` без флага `--http2` с сервера (IP `81.200.23.210`) — **403 Forbidden** на любых эндпоинтах.
При этом **браузер с того же самого IP** проходит нормально. То есть ddos-guard блокирует не по IP, а по сочетанию протокола (HTTP/1.1 vs HTTP/2) и TLS-отпечатка клиента.
### 6.2. Матрица доступности (проверено 2026-06-04)
| Клиент | auth-api | deck-api-test |
|---|---|---|
| `curl` (HTTP/1.1, по умолчанию) | ❌ 403 | ❌ 403 |
| `curl --http2` | ✅ 200 | ❌ 403 |
| `curl --http2` + браузерные заголовки | ✅ 200 | ❌ 403 |
| `Node.js https` | ✅ 200 | ✅ 200 |
| `Python urllib` | ✅ 200 | Не проверялся |
| Браузер (Chrome/Firefox) | ✅ 200 | ✅ 200 |
**Выводы:**
- `auth-api`: проблема curl решается флагом `--http2`
- `deck-api-test`: ddos-guard проверяет TLS fingerprint, curl не проходит даже с HTTP/2. Node.js — проходит
- **Приложение на Node.js работает везде**
### 6.3. Как использовать curl (правильно!)
```bash
# ❌ НЕ РАБОТАЕТ — HTTP/1.1 режется ddos-guard
curl -H "Authorization: Bearer $TOKEN" https://auth-api.ngcloud.ru/api/v1/auth/user
# ✅ РАБОТАЕТ — HTTP/2 + браузерные заголовки
curl --http2 \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json" \
-H "Accept-Language: ru-RU,ru;q=0.9" \
"https://auth-api.ngcloud.ru/api/v1/auth/user"
# ✅ РАБОТАЕТ — Node.js (всегда)
node -e "
const https = require('https');
https.get({
hostname: 'auth-api.ngcloud.ru',
path: '/api/v1/auth/user',
headers: { Authorization: 'Bearer $TOKEN', Accept: 'application/json' }
}, res => { let d=''; res.on('data',c=>d+=c); res.on('end',()=>console.log(d)); });
"
# ✅ РАБОТАЕТ — Python
python3 -c "
import urllib.request
req = urllib.request.Request('https://auth-api.ngcloud.ru/api/v1/auth/user',
headers={'Authorization': 'Bearer $TOKEN', 'Accept': 'application/json'})
print(urllib.request.urlopen(req, timeout=10).read().decode())
"
```
### 6.4. Значение для приложения
Приложение **ipwhitelist-app** написано на Node.js — оно **может** ходить в IAM API с этого сервера без проблем. `curl` без `--http2` врал про отсутствие доступа.
---
## 7. Проверка токенов (подтверждено)
### 7.1. Типы токенов
| Тип | `token_type` | TTL | Где взять |
|---|---|---|---|
| **access** | `access` | 12 часов | Ответ IAM после SSO через Keycloak (`POST /token`) |
| **refresh** | `refresh` | 7 дней | Там же, для обновления access |
| **tech** | `tech` | 6 месяцев | ЛК → вкладка «Токены» → «Выпустить тех-токен» |
### 7.2. Все токены подходят для `GET /api/v1/auth/user`
Не важно, access или tech — любой валидный IAM-токен (`iss: "auth-api"`) принимается. Источник токена (ЛК, SSO через Keycloak, другое приложение) значения не имеет.
### 7.3. Проверка access_token из ЛК (2026-06-04)
```bash
# Токен, полученный через F12 → Network → /token response → access_token
node -e "const https=require('https');
https.get({hostname:'auth-api.ngcloud.ru', path:'/api/v1/auth/user',
headers:{Authorization:'Bearer $ACCESS_TOKEN', Accept:'application/json'}},
res=>{let d=''; res.on('data',c=>d+=c); res.on('end',()=>console.log(d))});"
# → HTTP 200 + JSON с profiles[] и userInfo
```
### 7.4. Тестовый пользователь
| Поле | Test | Prod |
|---|---|---|
| `email` | `tazetdinovn@gmail.com` | `tazetdinovn@gmail.com` |
| `clientID` | `WZ03709` | `WZ03709` |
| `company_name` | `naeel_test` | `naeel_test` |
| `company_numeric_id` | 2645 | 2645 |
| `profile.id` | 2833 | 4357 |
| `elmaUserId` | 17193 | 39715 |
| `isAdmin` | false | false |
| `is_active_profile` | true | true |
| Количество профилей | 1 | 1 |
**Внимание:** у этого пользователя один профиль. Реальная картина с несколькими компаниями будет содержать несколько объектов в `profiles[]`.
### 7.5. Поток аутентификации (подтверждён)
```
Пользователь → Keycloak (SSO) → IAM (обмен code) → access_token
┌───────────────────────────────┘
GET /api/v1/auth/user (Bearer access_token)
{ profiles[], userInfo }
Строим session.user
```
**Никакого дополнительного обмена токенами не требуется.** `access_token` от IAM сразу годится для вызова `GET /api/v1/auth/user`.
### 6.3. Токен
Использованный для тестирования токен:
- **Тип:** tech-токен (долгоживущий, 6 месяцев)
- **Issuer:** `auth-api`
- **Пользователь:** `tazetdinovn@gmail.com`
- **ClientID:** `WZ03709`
- **Компания:** `naeel_test` (numeric_id: 2645)
- **Выпущен:** 31 мая 2026
- **Истекает:** 30 ноября 2026
- **Роль:** Пользователь (не админ)
- **Стенд:** test (`auth-api-test.ngcloud.ru`)
---
## 7. Файлы, которые нужно изменить
| Файл | Что изменить |
|---|---|
| `.env.example` | Добавить `IAM_API_URL` |
| `src/config.js` | Добавить константу `IAM_API_URL` из env |
| `src/auth.js` | Добавить `fetchIamUser()`, `switchProfile()`, изменить `userFromPayload()` |
| `ui/routes/auth.js` | После логина вызывать `fetchIamUser` для обогащения user |
| `src/routes/oidc.js` | После callback вызывать `fetchIamUser` |
| `ui/routes/entries.js` | `?switchTo=` → вызывать IAM `switchProfile`, передавать `profiles` в шаблон |
| `views/index.ejs` | Отображать список компаний с активной |
| `src/api/routes/entries.js` | `resolveCompany()` — использовать `profiles` вместо `allClientIds` из токена |
---
## 8. Диаграмма целевого потока
```mermaid
sequenceDiagram
participant B as Браузер
participant A as ipwhitelist-app
participant KC as Keycloak
participant IAM as IAM API
B->>A: Заход на /
A->>KC: Редирект на /auth
B->>KC: Вход (SSO если есть сессия)
KC->>B: Редирект с code
B->>A: /callback?code=xxx
A->>KC: POST /token (обмен code)
KC-->>A: access_token
A->>IAM: GET /auth/user (Bearer access_token)
IAM-->>A: { profiles[], userInfo }
A->>A: Строим user из IAM ответа
A->>B: Сессия создана, добро пожаловать
Note over B,A: Пользователь переключает компанию
B->>A: /?switchTo=WZ12345
A->>IAM: POST /user/switch-profile { profile_id: 3120 }
IAM-->>A: { success: true, active_profile }
A->>A: Обновляем сессию
A->>B: Страница с новой активной компанией
```
-184
View File
@@ -1,184 +0,0 @@
# Keycloak Auth Reference — консолидированная справка
> Обновлено: 2026-06-02
> Файл-ориентир. Править по мере уточнения у DevOps и продвижения проекта.
---
## 1. Keycloak-эндпоинты
Realm: **`cloud`** на `keycloak.nubes.ru`
| Назначение | URL |
|---|---|
| Authorization | `https://keycloak.nubes.ru/realms/cloud/protocol/openid-connect/auth` |
| Token exchange | `https://keycloak.nubes.ru/realms/cloud/protocol/openid-connect/token` |
| JWKS (сертификаты) | `https://keycloak.nubes.ru/realms/cloud/protocol/openid-connect/certs` |
| Logout | `https://keycloak.nubes.ru/realms/cloud/protocol/openid-connect/logout` |
---
## 2. Зарегистрированное приложение (OAuth client)
`client_id` — выдаётся DevOps при регистрации приложения в Keycloak.
---
## 3. Схема аутентификации: Authorization Code Flow
```
1. Браузер → /login (приложение)
2. Приложение → 302 → Keycloak /auth?client_id=...&scope=openid&redirect_uri=...&state=...
3. Пользователь вводит логин/пароль в Keycloak
4. Пользователь вводит OTP (MFA)
5. Keycloak → 302 → /callback?code=...&state=...
6. Приложение → POST /token (code → access_token + refresh_token + id_token)
7. Валидация JWT через JWKS (RS256)
8. Извлечение claims → req.user → сессия
```
---
## 4. Получение токена через API Gateway (альтернативный путь)
Если в инфраструктуре есть API Gateway, код обменивается не напрямую в Keycloak, а через него:
```
POST <url API Gateway>/token
Content-Type: application/json
Body: {"code": "<authorization_code>"}
```
---
## 5. JWT-токены
### 5.1. Пример структуры JWT (deprecated)
```
iss: https://<issuer>/...
aud: <audience>
sub: <email>
name: <ФИО пользователя>
email: <email пользователя>
exp: <timestamp>
scope: openid
token_type: Bearer
```
> ⚠️ `ClientID` может отсутствовать — зависит от issuer'а.
### 5.2. Пример токена
| Поле | Значение |
|---|---|
| `iss` | `auth-api` |
| `ClientID` | `WZ01325` |
| `company_id` | `<uuid>` |
| `company_name` | Название компании |
| `login` | `user@example.com` |
| `email` | `user@example.com` |
### 5.3. Целевой формат токена (из ТЗ)
| Claim | Назначение |
|---|---|
| `ClientID` | Идентификатор компании (WZ-номер) |
| `company_id` | UUID компании |
| `company_name` | Название компании |
| `email` | Email пользователя (для аудита) |
| `groups` | ? (пока null) |
| `roles` | ? (пока null) |
---
## 6. Роли
| Роль | ClientID | Права |
|---|---|---|
| **Администратор** | `WZ01112` | Все записи, просмотр аудита, экспорт |
| **Клиент** | любой другой WZ-номер | Только свои записи |
> Как именно ClientID попадает в токен — через группы Keycloak или маппер — **требует уточнения у DevOps**.
---
## 7. Реализация в коде
### `src/auth.js` — два режима
| Режим | Условие | Как работает |
|---|---|---|
| **OIDC** | `KC_CLIENT_ID` + `KC_CLIENT_SECRET` заданы | Authorization Code Flow, JWKS-валидация |
| **Mock** | `KC_CLIENT_ID` пусто | Локальная RSA-пара, JWT из `MOCK_USERS` |
### `src/config.js` — Mock-пользователи
```javascript
MOCK_USERS = [
{ clientId: 'WZ01325', companyName: 'Тест', email: 'test@example.com' },
{ clientId: 'WZ01112', companyName: 'Нубес', email: 'admin@nubes.ru' }
]
```
### Два слоя аутентификации
| Слой | Механизм | Где |
|---|---|---|
| **UI (EJS)** | Session cookie + PostgreSQL session store | `/`, `/admin`, `/entries` |
| **REST API** | Bearer JWT (всегда проверяется явно) | `/api/v1/*` |
### Dev-backdoor
- `DEV_MODE=true` или `DEV_SECRET``/dev-login` — выбор любого пользователя в обход OIDC/mock
---
## 8. Переменные окружения
```env
# Auth
DEV_MODE=true
JWT_ISSUER=mock-auth-api
ADMIN_CLIENT_ID=WZ01112
# OIDC (production)
KC_BASE_URL=https://keycloak.nubes.ru/realms/cloud
KC_CLIENT_ID=
KC_CLIENT_SECRET=
APP_URL=http://localhost:3000
# Session
SESSION_SECRET=<обязателен в prod>
```
---
## 9. Открытые вопросы (к DevOps)
| # | Вопрос | Статус |
|---|---|---|
| 1 | Где брать `client_id` и `client_secret` для регистрации приложения в Keycloak? | 🔴 |
| 2 | Какой URL JWKS? `auth-api` или `keycloak.nubes.ru`? | 🔴 |
| 3 | Когда заполняются `claims.groups` и `claims.roles`? (пока null) | 🔴 |
| 4 | Как выглядит токен, если у пользователя несколько компаний? Массив `ClientID`? | 🔴 |
| 5 | Есть ли в Keycloak понятие admin-роли? В каком claim? | 🔴 |
| 6 | Redirect URI: `https://white.nodejsk8s.dev.nubes.ru/callback` — OK? | 🔴 |
| 7 | Кто потребитель `/export`? Какие IP/подсети для allowlist? | 🔴 |
| 8 | Формат `jsonEnv` — пример правильного? | 🔴 |
---
## 10. Исходные файлы
| Файл | Содержание |
|---|---|
| `docs/ТЗ.md` | Техническое задание |
| `docs/ТЗ-реализация.md` | Карта реализации требований |
| `docs/questions-devops.md` | Вопросы к DevOps |
| `research/auth-flow.md` | Детальное исследование OIDC-flow |
| `research/payg-sso/` | CF-эталон (OAuth2 + JWT RS256) |
| `Files/token.txt` | Реальный токен из test-окружения |
| `Files/nubes_login.har` | HAR трассировка login-сессии deck-test |
| `src/auth.js` | Основной модуль аутентификации |
| `src/config.js` | Конфигурация и mock-пользователи |
-161
View File
@@ -1,161 +0,0 @@
# ТЗ-плюс — уточнения и дополнения от заказчика
> Основа: `docs/ТЗ.md`
> Файл для фиксации уточнений, дополнений и решений по мере обсуждения с заказчиком/DevOps.
> Каждая запись = дата + источник + формулировка + статус.
---
## 1. Аутентификация и Keycloak
### 1.1. client_id приложения в Keycloak
- **Дата**: 2026-06-02
- **Источник**: HAR-файл
- **Факт**: `client_id` присутствует в URL auth-запроса
- **Статус**: 🔴 требует подтверждения — какой client_id будет для ipwhitelist?
### 1.2. ClientID в JWT — УТОЧНЕНО
- **ТЗ**: claim `ClientID` — идентификатор компании
- **Факт (HAR)**: `ClientID: "WZ01325"`
- **Факт (другой источник)**: `ClientID` может отсутствовать
- **Уточнение заказчика (01.06.2026)**: в claims приходит `clientid`. Формат может быть разный.
- Одно значение: `WZ04228`, `1700`, `asokolov-test`
- Несколько через запятую: `WZ11125, WZ03816`, `WZ52235, WZ62587, WZ02315`
- **Логика**: первое «слово» до запятой определяет общий ЛК.
Пользователи с одинаковым первым словом → общий whitelist.
- **Что делать в коде**: брать первый `clientid` до запятой как активную компанию.
Остальные (если есть) — дополнительные компании пользователя для переключателя.
- **Статус**: 🟡 уточнено, требует реализации в коде
### 1.3. Несколько компаний на пользователя — УТОЧНЕНО
- **ТЗ, п.3.1**: «поддерживается сценарий, когда пользователь принадлежит нескольким компаниям»
- **Уточнение заказчика (01.06.2026)**: поле `clientid` в claims содержит список через запятую.
Примеры: `WZ11125, WZ03816`, `WZ52235, WZ62587, WZ02315`.
- **Логика ЛК**: первое значение до запятой — активная компания.
Пользователи с одинаковым первым `clientid` видят общий whitelist.
Переключатель между компаниями — все значения из списка.
- **Открытый вопрос**: какое именно поле в Keycloak за это отвечает? Заказчик уточнит.
- **Статус**: 🟡 уточнено, требует реализации в коде
### 1.4. Admin-роль в Keycloak
- **ТЗ**: admin = `ClientID = WZ01112` + отдельный чек-бокс
- **Вопрос**: чек-бокс — это claim? Какой? `is_admin`, `realm_access.roles`, `groups`?
- **Статус**: 🔴
### 1.5. JWKS URL
- **Вариант A**: `https://keycloak.nubes.ru/realms/cloud/protocol/openid-connect/certs`
- **Вариант B**: `https://auth-api...` (через API Gateway)
- **Статус**: 🔴
### 1.6. Redirect URI
- **Предложение**: `https://<домен>/callback`
- **Статус**: 🔴 требует подтверждения DevOps
---
## 2. Инфраструктура и деплой
### 2.1. Платформа
- **Статус**: 🔴 уточнить — Node.js на k8s? Какой кластер?
### 2.2. Деплой
- **Статус**: 🔴 уточнить — CI/CD? Как поставлять `jsonEnv`?
### 2.3. Сетевое ограничение для /export
- **Вопрос**: какие IP/подсети будут потребителями внешней выдачи?
- **Статус**: 🔴
---
## 3. Функциональные уточнения
### 3.1. Мульти-компания в UI
- **ТЗ, п.3.1**: «переключатель активной компании»
- **Вопрос**: дизайн переключателя? Dropdown в шапке? Отдельная страница?
- **Статус**: 🔴
### 3.2. Soft-delete и фильтр «показать удалённые»
- **ТЗ, п.4.1**: «для администратора предусмотрен фильтр»
- **Код**: `includeDeleted` всегда `false`
- **Статус**: 🟡 не реализовано в коде
### 3.3. Индивидуальные лимиты компаний
- **ТЗ, п.4.5**: админ может задать индивидуальный лимит
- **Код**: ?
- **Статус**: 🔴 проверить реализацию
### 3.4. Уведомление о нормализации
- **ТЗ, п.5**: «пользователь должен быть уведомлен, что ввел адрес из хостовой части»
- **Статус**: 🔴 проверить реализацию в UI
---
## 4. Тестовые данные
### 4.1. Тестовые пользователи
| Email | ClientID | Компания | Роль |
|---|---|---|---|
| `client@example.com` | `WZ01325` | Тест | Клиент |
| `admin@nubes.ru` | `WZ01112` | Нубес | Админ |
| `tazetdinovn@gmail.com` | ? | ? | ? (из token.txt) |
### 4.2. HAR-сессия
- Файл: `Files/nubes_login.har`
- Пользователь: `tazet@narod.ru`
- Дата: 2026-05-30
- Окружение: deck-test
---
## 6. Уточнения от заказчика (01.06.2026)
### 6.1. Формат clientid в токене
- Поле: `clientid` в claims JWT
- Формат: строка, значения через запятую если несколько
- Примеры:
- `"WZ04228"` — одна компания
- `"1700"` — числовой ID
- `"asokolov-test"` — текстовый ID
- `"WZ11125, WZ03816"` — две компании
- `"WZ52235, WZ62587, WZ02315"` — три компании
- **Правило**: первое «слово» до запятой = активная компания (определяет ЛК)
- **Общий доступ**: пользователи с одинаковым первым словом имеют общий whitelist
### 6.2. Экспорт — подтверждение формата
- GET-endpoint отдаёт txt файл
- Одна строка = один объект (адрес/подсеть)
- Без разделителей, без заголовков
### 6.3. Текущее демо — одобрено
- `https://white.nodejsk8s.dev.nubes.ru/login` — ✓
- `https://white.nodejsk8s.dev.nubes.ru/exp` — ✓ (вывод без токена для тестирования)
### 6.4. Что ещё уточняется
- Какое именно поле Keycloak маппится в `clientid` claim? (Заказчик уточнит)
- Как определяется admin-роль? (отдельный чек-бокс в Keycloak?)
- `client_id` и `client_secret` для регистрации приложения
### 6.5. Права внутри компании — уточнено (02.06.2026)
- **Вопрос**: может ли любой юзер компании редактировать whitelist всей компании?
- **Ответ заказчика**: «Да, любой юзер, который может войти может редактировать»
- **Вывод**: внутри компании роли не разграничиваются. Любой сотрудник компании имеет полный доступ к whitelist своей компании (создание, редактирование, удаление). Аудит фиксирует кто именно сделал изменение.
---
## 5. Решения и договорённости
> Сюда записывать принятые решения с датой и контекстом.
(пока пусто)
---
## Легенда статусов
| Статус | Значение |
|---|---|
| 🔴 | Требует уточнения |
| 🟡 | Уточнено, не реализовано |
| 🟢 | Реализовано / подтверждено |
| ⚫ | Отменено / не актуально |
-242
View File
@@ -1,242 +0,0 @@
Техническое задание
Микросервис управления доверенными адресами клиентов
Self-service портал для указания клиентами доверенных IPv4-адресов и подсетей,
исключаемых из блокировки на стороне облачного провайдера во время DDoS-атак
# 1. Назначение и цели
## 1.1. Назначение
Микросервис предоставляет клиентам облачного провайдера web-интерфейс для самостоятельного управления списком доверенных IPv4-адресов и подсетей. Записи из этого списка исключаются из автоматической блокировки сетевого взаимодействия системами фильтрации и митигации провайдера, что снижает количество ложноположительных срабатываний для легитимного трафика клиента.
> **Реализация:** `server.js` — точка входа, подключает все роуты. `ui/index.js` — UI-слой.
## 1.2. Цели
- Дать клиентам возможность самостоятельно поддерживать актуальный список доверенных IPv4-адресов, которые будут исключаться из фильтрации во время DDoS-атак.
> **Реализация:** `ui/routes/entries.js` — UI CRUD. `src/api/routes/entries.js` — REST API.
- Предоставить сетевым инженерам единую точку просмотра и управления списками доверенных клиентских белых IPv4-адресов.
> **Реализация:** `ui/routes/admin.js` — /admin и /audit UI. `src/api/routes/admin.js` — API /companies, /audit.
- Обеспечить машиночитаемую выдачу агрегированного (суммаризированного) списка для систем фильтрации трафика.
> **Реализация:** `src/routes/export.js` — GET /export. `src/validators.js` строки 125181 — функция `aggregateCIDRs`.
# 2. Объем работ
- Web-страница / закладка в личном кабинете для управления whitelist-записями.
> **Реализация:** `views/index.ejs` — главная страница. `ui/routes/entries.js` — роуты GET /, POST /add, POST /edit/:id, POST /delete/:id.
- Авторизация через существующий экземпляр Keycloak (OIDC).
> **Реализация:** `src/auth.js` строки 220–260 — верификация JWT RS256 токена Keycloak. Строка 245: чтение claim `ClientID`. Строка 251: определение роли `isAdmin`.
- Валидация формы на стороне клиента и сервера.
> **Реализация:** `src/validators.js` строки 26–79 — серверная валидация `validate()`. `views/index.ejs` — атрибут `pattern` в поле ввода (клиентская).
- Внешний endpoint выдачи агрегированного списка. Выдача txt-файлом с переносом строки. Одна строка – один объект.
> **Реализация:** `src/routes/export.js` строки 2764 — GET /export, Content-Type: text/plain, aggregateCIDRs → join('\n').
- Хранение записей, журнал аудита.
> **Реализация:** `sql/schema.sql` — таблицы `whitelist_entries`, `audit_log`. `src/queries.js` строки 211230 — функция `logAudit`.
- Административное управление лимитами по компаниям.
> **Реализация:** `src/api/routes/admin.js` строки 2735 — PATCH /companies/:id/limit. `src/queries.js` — функция `setLimit`. `views/admin.ejs` — UI формы лимитов.
# 3. Роли и права доступа
Роли определяются на основании claims в OIDC-токене Keycloak. Соответствие claim → роль настраивается на этапе развёртывания.
> **Реализация:** `src/auth.js` строка 31: `ADMIN_CLIENT_ID = process.env.ADMIN_CLIENT_ID || 'WZ01112'`. Строка 251: `isAdmin: clientId === ADMIN_CLIENT_ID`.
| Роль | Идентификация | Видимость записей | Права на изменение |
| --- | --- | --- | --- |
| Клиент (client) | clientId | Только записи компаний, к которым принадлежит пользователь. | Создание, редактирование и удаление записей своих компаний (в пределах лимита). |
| Администратор (admin) | clientId = WZ01112 (Нубес) и отдельный чек-бокс | Записи всех компаний. | Создание, редактирование, удаление всех записей. Изменение лимита для отдельных компаний. |
> **Реализация ролей в API:** `src/api/routes/entries.js` — функция `resolveCompany()`: если `req.user.isAdmin && req.query.company` → берёт чужую компанию, иначе `getOrCreateCompany(clientId)`. `src/auth.js` строки 260265 — middleware `requireAdmin`.
## 3.1. Принадлежность к компании
Принадлежность пользователя к компании определяется из claim токена. Поддерживается сценарий, когда пользователь принадлежит нескольким компаниям: в этом случае в интерфейсе предусматривается переключатель активной компании, а все операции выполняются в контексте выбранной компании.
> **Реализация:** `src/auth.js` строка 245: `clientId = payload.ClientID`. `src/queries.js` строки 615: `getOrCreateCompany(clientId, companyName)` — атомарный upsert.
>
> ⚠️ **Сценарий нескольких компаний не реализован** — в реальном токене Nubes `ClientID` = одна строка, массива нет. Ждём уточнения от devops.
Ожидаемые claims (имена согласуются с командой Keycloak):
- `ClientID` — идентификатор компании → `src/auth.js` строка 245
- `email` — идентификация пользователя для аудита → `src/auth.js` строка 248
# 4. Функциональные требования
## 4.1. Просмотр списка записей
- Клиент видит таблицу записей активной компании; Администратор – записи всех компаний с фильтром по компании.
> **Реализация:** `ui/routes/entries.js` — GET /: для admin грузит `getAllCompanies()` + фильтр по `?company=<id>`. `views/index.ejs` — dropdown компаний для admin, таблица записей.
- Для каждой записи отображаются: значение (адрес/подсеть), комментарий (если есть), автор(email), дата создания, дата последнего изменения.
> **Реализация:** `views/index.ejs` строки 280–295 — колонки таблицы: `value_cidr`, `comment`, `created_by`, `created_at`, `updated_at`. Даты в timezone `Europe/Moscow`.
- Soft-deleted записи по умолчанию скрыты; для администратора предусмотрен фильтр для их отображения.
> **Реализация:** `src/queries.js` строки 2531 — `listEntries(companyId, includeDeleted)`. SQL: `AND deleted_at IS NULL` когда `includeDeleted=false`.
>
> ⚠️ **Фильтр "показать удалённые" в UI не реализован** — `includeDeleted` всегда `false`.
- Отображается текущее использование лимита: «использовано X из N».
> **Реализация:** `src/api/routes/entries.js` строка 62: `res.json({ entries, limit, used: entries.length })`. `views/index.ejs` — блок статистики `<%= used %> / <%= limit %>`.
## 4.2. Создание записи
- Форма содержит поля: значение (IPv4-адрес или подсеть CIDR) и необязательный комментарий (до 255 символов).
> **Реализация:** `views/index.ejs` — форма POST /add с полями `value` и `comment`. Атрибут `maxlength="18"` на поле адреса.
- Значение проходит валидацию (см. раздел 5) на клиенте и обязательно повторно на сервере.
> **Реализация:** `src/validators.js` строки 2679 — `validate(input)`. Вызывается в `src/queries.js` строка 33: `const { cidr, wasNormalized } = validate(rawValue)`.
- Перед сохранением проверяется: соблюдение лимита компании, отсутствие пересечений и дубликатов внутри компании, отсутствие принадлежности к запрещённым диапазонам.
> **Реализация:** `src/queries.js` строки 3270 — `createEntry()`: блокировка строки компании (FOR UPDATE), проверка лимита (строки 43–46), проверка дубликатов и пересечений (строки 49–57), проверка запрещённых диапазонов в `validate()` (строки 7375 validators.js).
- При успешном сохранении создаётся запись аудита.
> **Реализация:** `src/queries.js` строка 66: `logAudit(userEmail, companyId, 'CREATE', null, cidr, ...)`.
## 4.3. Редактирование записи
- Редактирование значения и комментария доступно компании в рамках своих прав.
> **Реализация:** `ui/routes/entries.js` — POST /edit/:id. `src/api/routes/entries.js` — PATCH /entries/:id. Modal в `views/index.ejs` — кнопка `.btn-edit`, event delegation в `<script>`.
- При изменении значения повторно выполняется полный набор проверок валидации и пересечений.
> **Реализация:** `src/queries.js` строки 77115 — `updateEntry()`: валидация через `validate()`, проверка пересечений (исключая саму запись: `AND id <> $2`).
- Изменение фиксируется в журнале аудита с сохранением прежнего и нового значения.
> **Реализация:** `src/queries.js` строка 107: `logAudit(userEmail, companyId, 'UPDATE', old.value_cidr, cidr, entryId, ...)`.
## 4.4. Удаление записи (soft delete)
- Удаление выполняется как логическое (soft delete): запись помечается удалённой (deleted_at, deleted_by), но физически сохраняется.
> **Реализация:** `src/queries.js` строки 118145 — `deleteEntry()`: UPDATE SET `deleted_at = NOW(), deleted_by = userEmail`. `sql/schema.sql` — колонки `deleted_at`, `deleted_by` в таблице `whitelist_entries`.
- Удалённая запись освобождает место в лимите компании и исключается из внешней агрегированной выдачи.
> **Реализация:** `src/queries.js` строка 50: COUNT считает только `WHERE deleted_at IS NULL`. `src/routes/export.js` — запрос только активных записей.
- Действие фиксируется в журнале аудита.
> **Реализация:** `src/queries.js` строка 134: `logAudit(userEmail, companyId, 'DELETE', old.value_cidr, null, ...)`.
## 4.5. Лимит записей на компанию
- Действует глобальный лимит по умолчанию: 15 активных записей на компанию.
> **Реализация:** `src/queries.js` строки 1720 — `getLimit()`: `parseInt(process.env.DEFAULT_LIMIT) || 15`.
- Значение глобального лимита по умолчанию задаётся конфигурацией сервиса и может быть изменено без пересборки.
> **Реализация:** `src/queries.js` строка 18: `process.env.DEFAULT_LIMIT` — переменная окружения, не хардкод.
- Для отдельной компании администратор может задать индивидуальный лимит, переопределяющий глобальный (как в большую, так и в меньшую сторону).
> **Реализация:** `src/api/routes/admin.js` строки 2735 — PATCH /companies/:id/limit. `src/queries.js` строка 20: `company.custom_limit != null ? company.custom_limit : defaultLimit`. `sql/schema.sql` — колонка `custom_limit` в таблице `companies`.
- При попытке превысить лимит создание блокируется с понятным сообщением; в подсчёт идут только активные записи.
> **Реализация:** `src/queries.js` строки 4346: `if (cnt >= limit) throw new Error('Лимит исчерпан: N из N')`. API возвращает 409.
- Снижение лимита ниже текущего числа записей не удаляет существующие записи, но блокирует создание новых до приведения в соответствие.
> **Реализация:** `src/api/routes/admin.js` строки 2835 — setLimit просто записывает значение без удаления записей. Блокировка создания — через проверку `cnt >= limit` в `createEntry`.
## 4.6. Журнал аудита
- Все изменяющие операции фиксируются неизменяемыми записями аудита.
> **Реализация:** `src/queries.js` строки 211218 — `logAudit()`: INSERT в `audit_log` без UPDATE/DELETE операций над ней.
- Каждая запись аудита содержит: кто (пользователь), когда (timestamp), компания, тип действия, прежнее и новое состояние.
> **Реализация:** `sql/schema.sql` — таблица `audit_log`: колонки `user_email`, `created_at`, `company_id`, `action`, `old_value`, `new_value`. `views/audit.ejs` строки 131–160 — отображение.
- Журнал доступен для просмотра только администратору.
> **Реализация:** `src/api/routes/admin.js` — GET /audit защищён `apiRequireAdmin`. `ui/routes/admin.js` — GET /audit проверяет `req.session.user.isAdmin`.
## 4.7. Внешняя выдача агрегированного списка
- Подсети суммаризируются (агрегируются в минимальный набор CIDR) по всем компаниям совместно. Пересечения между разными компаниями допустимы.
> **Реализация:** `src/validators.js` строки 125181 — `aggregateCIDRs()`: сортировка, слияние перекрывающихся диапазонов, преобразование обратно в CIDR.
- Предоставляется отдельный HTTP GET endpoint, отдающий полный суммаризированный список активных записей всех компаний файлом в формате txt.
> **Реализация:** `src/routes/export.js` строки 2764 — GET /export. Content-Type: `text/plain`, ответ: `aggregated.join('\n')`.
- Авторизация: на старте endpoint может работать без авторизации (по сетевому ограничению / разрешенный список потребителей по ip).
> ⚠️ **Не реализовано** — /export требует Bearer-токен (авторизован). По ТЗ должен быть доступен без авторизации по IP-списку.
# 5. Требования к валидации
Валидация выполняется на клиенте и обязательно дублируется на сервере. Серверная валидация является авторитетной.
> **Реализация:** `src/validators.js` — вся серверная валидация. `views/index.ejs` — атрибут `pattern` (клиент).
| Правило | Описание | Реализация |
| --- | --- | --- |
| Формат IPv4 | Допускается одиночный адрес или подсеть CIDR /22/32. | `src/validators.js` строки 41–53: добавление /32 если нет маски, проверка `mask < 22 \|\| mask > 32`. |
| Только IPv4 | IPv6-значения или доменные имена отклоняются. | `src/validators.js` строки 3136: `if (raw.includes(':'))` → отклонить, проверка букв. |
| Корректность подсети | Host-биты обнуляются, пользователь уведомляется о нормализации. | `src/validators.js` строки 55–69: битовая арифметика, `wasNormalized = addr !== networkAddr`. Флаш-сообщение в `ui/routes/entries.js`. |
| Запрет серых адресов | Диапазоны из Приложения А запрещены. | `src/validators.js` строки 420: `BLOCKED_RANGES[]`. Строки 73–75: проверка `overlaps()`. |
| Отсутствие дубликатов | Совпадающие записи в компании запрещены. | `src/queries.js` строка 53: `if (row.value_cidr === cidr) throw`. Уникальный индекс в `sql/schema.sql`. |
| Отсутствие пересечений | Пересечение с существующей записью в компании запрещено. | `src/queries.js` строки 49–57: перебор активных записей + `overlaps()`. Между компаниями — допускается. |
| Длина комментария | Не более 255 символов. | `src/validators.js` строка 183 (или в api/routes/entries.js): проверка `comment.length > 255` → 400. |
# Приложение А – Список запрещённых к созданию подсетей
> **Реализация:** `src/validators.js` строки 420 — массив `BLOCKED_RANGES`.
| Назначение | Префикс |
| --- | --- |
| Private (RFC1918) | 10.0.0.0/8 |
| Private (RFC1918) | 172.16.0.0/12 |
| Private (RFC1918) | 192.168.0.0/16 |
| CGNAT (RFC6598) | 100.64.0.0/10 |
| Loopback | 127.0.0.0/8 |
| Link-local (APIPA) | 169.254.0.0/16 |
| IANA special block | 192.0.0.0/24 |
| TEST-NET-1 (docs) | 192.0.2.0/24 |
| TEST-NET-2 (docs) | 198.51.100.0/24 |
| TEST-NET-3 (docs) | 203.0.113.0/24 |
| Benchmarking | 198.18.0.0/15 |
| Multicast | 224.0.0.0/4 |
| Reserved (Class E) | 240.0.0.0/4 |
| Limited broadcast | 255.255.255.255/32 |
---
## ⚠️ Расхождения с ТЗ (что не реализовано)
| Пункт ТЗ | Статус |
| --- | --- |
| 3.1 Несколько компаний для одного пользователя | Не реализовано — ждём формат claim от devops |
| 4.1 Фильтр soft-deleted записей для admin | Не реализовано — `includeDeleted` всегда false |
| 4.7 /export без авторизации (по IP) | Не реализовано — требует Bearer-токен |
-85
View File
@@ -1,85 +0,0 @@
# План упрощения — 2026-06-11
## Проблема
AI усложнил код `entries.js` фоллбеками `getOrCreateCompany`, запутанной логикой `ownCo`,
ненужными редиректами `?company=`. Пользователь указал что логика простая и не требует усложнений.
## Исходные данные (что юзер знает после входа по Keycloak)
1. **email** — из JWT (Keycloak claims)
2. **session.token** — access_token от Keycloak
3. **IAM API** (`fetchIamUser`) даёт:
- `email`
- `profiles[]` — список компаний юзера: `[{id, client_id, company_name, is_active_profile}, ...]`
- `activeProfile` — текущая активная компания
- `isAdmin` — флаг администратора
## Базовые правила
- **Компания** = общий список IP. Все юзеры компании видят одни и те же записи и могут CRUD.
- **email** пишется в `audit_log.created_by` — кто сделал CRUD.
- **client_id** (WZ01112, WZ03709…) — идентификатор компании в БД (`companies.client_id`).
## Переключение компаний (мульти-компания)
Если у юзера `profiles.length > 1` — он выбирает активную компанию через переключатель.
CRUD идёт в активную компанию.
## Админ-режим (чекбокс → toggle)
### Правило (финальное):
> Toggle «Администратор» показывается **только** когда:
> `isAdmin === true` **И** `activeClientId === 'WZ01112'`
### Временное исключение (тесты):
Пока я (WZ01112 + ntazetdinov@nubes.ru) — тоже вижу toggle, даже без `isAdmin`.
Убрать когда перейдём на чистый IAM `isAdmin`.
### Поведение toggle:
- **ВЫКЛ** — обычный режим: юзер видит свою активную компанию, CRUD только в неё
- **ВКЛ** — admin-режим: выпадающий список ВСЕХ компаний, можно выбрать любую, ссылки «Аудит» и «Лимиты»
### Реализация toggle:
- Не `<input type="checkbox">` с `onchange="this.form.submit()"` (старый вариант, не работал)
- Замена на **toggle-переключатель** (как ползунок ВКЛ/ВЫКЛ)
- `onchange``fetch('/toggle-admin', {method:'POST'})``location.reload()`
- Никакой формы, никакого CSRF — просто JS fetch и перезагрузка страницы
## Что меняем (4 файла)
### 1. `ui/index.js` — resolveUser middleware
- Добавить `canAdminMode`:
```js
canAdminMode: (iamAdmin && activeClientId === 'WZ01112') || activeClientId === 'WZ01112' // временно
```
- Убрать `isAdmin: iamAdmin || activeClientId === ADMIN_CLIENT_ID` — заменить на чистое `isAdmin: iamAdmin`
- `adminMode` остаётся из сессии
### 2. `views/index.ejs` — header
- Условие показа toggle: `<% if (canAdminMode) { %>` вместо `<% if (isAdmin) { %>`
- Заменить checkbox на toggle
- Ссылки «Аудит»/«Лимиты» — по `adminMode` (уже так)
### 3. `ui/routes/entries.js` — GET /
- **Убрать** `getOrCreateCompany`-фоллбек (строки 89-96)
- **Упростить** admin-ветку: если нет `?company=` — показать свою компанию
- Обычный пользователь: без изменений (уже работает)
### 4. `ui/routes/auth.js` — POST /toggle-admin
- Без изменений (уже работает — переключает `req.session.adminMode`)
## Почему именно так
1. **AND а не OR**: админ не должен видеть toggle в чужих компаниях — только в WZ01112
2. **Toggle а не checkbox**: чекбокс = «прочитано/не прочитано», toggle = «режим ВКЛ/ВЫКЛ» — семантически верно
3. **JS fetch а не form submit**: быстрее, без моргания страницы, без CSRF-проблем
4. **Никаких фоллбеков**: компания либо есть в списке (из IAM), либо юзер к ней не относится — не надо «создавать если нет»
-76
View File
@@ -1,76 +0,0 @@
# Баг: переключение компании — таблица IP не обновляется
## Симптом
GET /v2/app?switchTo=WZ30001 → таблица показывает записи старой компании.
## Найденная причина №1 (основная)
Порядок кода в `v2/src/user/index.js`:
```js
// 1. clId = req.clientId ← resolveContext выставил СТАРЫЙ activeClientId
const clId = req.clientId;
// 2. crud.list() по СТАРОМУ clId — ещё до switchTo
const { entries, used, limit } = await crud.list(clId, includeDeleted);
// 3. Потом switchTo обновляет сессию и делает redirect
if (req.query.switchTo) {
req.session.user.activeClientId = targetId;
return req.session.save(() => res.redirect('/v2/app'));
}
```
Итог: первый запрос с ?switchTo загружает записи СТАРОЙ компании.
Второй запрос (редирект /v2/app) должен уже использовать новый activeClientId — если ничто его не затирает.
## Найденная причина №2 (если ENV заданы)
`v2/src/impersonation/index.js` — при тестовом режиме (IMPERSONATION_TARGET задан):
```js
u.activeClientId = MAIN_COMPANY; // ← затирает то что записал switchTo
```
Middleware запускается при КАЖДОМ запросе (включая редирект после switchTo)
и сбрасывает activeClientId обратно в IMPERSONATION_COMPANY.
## Что проверить
```bash
echo $IMPERSONATION_COMPANY
echo $IMPERSONATION_TARGET
echo $IMPERSONATION_ORIGINAL
```
Если заданы → причина №2 (middleware затирает сессию).
Если не заданы → причина №1 (но тогда второй запрос после редиректа должен работать).
## Фикс причины №1
В `v2/src/user/index.js` — переставить проверку switchTo ПЕРЕД crud.list():
```js
// Сначала switchTo
if (req.query.switchTo) {
const targetId = req.query.switchTo;
const allowed = isAdmin || (req.profiles || []).find(p => p.client_id === targetId);
if (allowed) {
req.session.user.activeClientId = targetId;
return req.session.save(err => {
if (err) console.error('[switchTo] save error:', err.message);
res.redirect('/v2/app');
});
}
}
// Потом загружать записи
const { entries, used, limit } = await crud.list(clId, includeDeleted);
```
## Фикс причины №2
В `v2/src/impersonation/index.js` — не перезаписывать activeClientId если он уже есть:
```js
// Вместо:
u.activeClientId = MAIN_COMPANY;
// Сделать:
u.activeClientId = u.activeClientId || MAIN_COMPANY;
```
-69
View File
@@ -1,69 +0,0 @@
# WOWATER — развёртывание ipwhitelist-app (без воды)
> Для опытных DevOps. Минимум текста, только команды.
## 1. PostgreSQL
```bash
sudo apt install -y postgresql
DB_PASS=$(openssl rand -base64 24)
sudo -u postgres psql -c "CREATE USER ipwhitelist WITH PASSWORD '${DB_PASS}';"
sudo -u postgres psql -c "CREATE DATABASE ipwhitelist OWNER ipwhitelist;"
```
## 2. Node.js + код
```bash
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo bash -
sudo apt install -y nodejs
git clone https://gitea.services.ngcloud.ru/Nail/ipwhitelist-app.git /opt/ipwhitelist
cd /opt/ipwhitelist
npm install --production
sudo -u postgres psql -d ipwhitelist -f sql/schema.sql
```
## 3. .env
```bash
cp .env.example .env && chmod 600 .env
```
Заполнить: `DB_PASS`, `SESSION_SECRET`, `KC_CLIENT_ID`, `KC_CLIENT_SECRET`, `APP_URL`, `IAM_API_URL`.
`DEV_MODE``false`.
## 4. nginx + Let's Encrypt
```nginx
server {
listen 443 ssl;
server_name ваш-домен.ru;
ssl_certificate /etc/letsencrypt/live/.../fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/.../privkey.pem;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
```
## 5. PM2
```bash
npm i -g pm2
pm2 start server.js --name ipwhitelist
pm2 save && pm2 startup
```
## 6. Keycloak OIDC
Создать Confidential client в Keycloak (realm `cloud`):
- `Valid Redirect URIs`: `https://ваш-домен.ru/callback`
- `Client Authentication`: On
- Записать `KC_CLIENT_ID` и `KC_CLIENT_SECRET` в `.env`
Готово. `pm2 restart ipwhitelist`.
-209
View File
@@ -1,209 +0,0 @@
# ТЗ-плюс — уточнения и дополнения от заказчика
> Основа: `docs/ТЗ.md`
> Дата последнего обновления: 2026-06-04
> Файл для фиксации уточнений, дополнений и решений.
---
## 1. Аутентификация — интеграция с IAM API
### 1.1. IAM API — единый сервис авторизации (УТОЧНЕНО 04.06.2026)
IAM (Identity & Access Management) — сервис авторизации экосистемы Nubes.
Все смежные сервисы ходят в него для проверки компании пользователя.
**Три стенда:**
| Стенд | URL |
|---|---|
| Dev | `https://auth-api-dev.ngcloud.ru` |
| Test | `https://auth-api-test.ngcloud.ru` |
| Prod | `https://auth-api.ngcloud.ru` |
**Swagger:** `https://auth-api-dev.ngcloud.ru/api/v1/documentation/`
**Исходный источник:** CRM Elma (контакт, компания, отношение N:M).
**Статус:** 🟢 подтверждено
### 1.2. Поток аутентификации (УТОЧНЕНО 04.06.2026)
```
Пользователь → Keycloak (SSO) → IAM (обмен code) → access_token + refresh_token
┌───────────────────────────────┘
GET /api/v1/auth/user (Bearer access_token)
{ profiles[], userInfo }
```
`access_token` от IAM (token_type: "access", TTL 12 часов) сразу годится для
вызова `GET /api/v1/auth/user`. Никакого дополнительного обмена не требуется.
**Статус:** 🟢 подтверждено (Node.js HTTP 200, получен реальный ответ)
### 1.3. client_id приложения в Keycloak
- **Источник:** HAR-файл
- **Факт:** `client_id` в URL auth-запроса
- **Статус:** 🔴 требует подтверждения
### 1.4. JWKS URL и Redirect URI
- **Статус:** 🔴 требует подтверждения DevOps
---
## 2. Компании пользователя — IAM /auth/user
### 2.1. `GET /api/v1/auth/user` (УТОЧНЕНО 04.06.2026)
**Реальный ответ** (пользователь `tazetdinovn@gmail.com`, prod):
```json
{
"profiles": [
{
"id": 4357,
"client_id": "WZ03709",
"company_id": "019cc24a-727e-740f-b407-bec79dab4162",
"company_name": "naeel_test",
"company_numeric_id": 2645,
"is_active_profile": true
}
],
"userInfo": {
"clientID": "WZ03709",
"company": "naeel_test",
"companyId": "019cc24a-727e-740f-b407-bec79dab4162",
"companyNumericId": 2645,
"email": "tazetdinovn@gmail.com",
"isAdmin": false,
"fio": {"fullName": "Тазетдинов Наиль Фаритович", "name": "Наиль", "secondName": "Фаритович", "surname": "Тазетдинов"},
"contactId": "019cc268-6c6a-781e-8613-4bed4ec7cd20",
"elmaUserId": 39715
}
}
```
**Маппинг полей для использования в коде:**
| Назначение | Откуда |
|---|---|
| Список WZ-кодов компаний | `profiles[].client_id` |
| Активная компания | `profiles[].is_active_profile === true` |
| ID для switch-profile | `profiles[].id` |
| WZ активной компании | `userInfo.clientID` |
| Название компании | `userInfo.company` |
| UUID компании | `userInfo.companyId` |
| Флаг админа | `userInfo.isAdmin` |
| Email | `userInfo.email` |
**Статус:** 🟢 подтверждено
### 2.2. `POST /api/v1/user/switch-profile` (УТОЧНЕНО 04.06.2026)
Тело: `{"profile_id": 3120}` или `{"company_id": "uuid"}`.
**Статус:** 🟡 уточнено по Swagger, не тестировалось на живом multi-company юзере
### 2.3. ⚫ ClientID через запятую в JWT — УСТАРЕЛО
- **Было (01.06):** `ClientID: "WZ11125, WZ03816"` в claims JWT
- **Стало (04.06):** JWT содержит только ОДНУ компанию (активную).
Правильный источник списка — `profiles[]` из IAM API.
- **Статус:** ⚫ отменено
### 2.4. ⚫ Admin = WZ01112 — УСТАРЕЛО
- **Было:** `clientId === 'WZ01112'` → админ
- **Стало:** `userInfo.isAdmin` из IAM API
- **Статус:** ⚫ отменено
---
## 3. Сетевая доступность
### 3.1. ddos-guard (УТОЧНЕНО 04.06.2026)
Все `*.ngcloud.ru` за ddos-guard. IP `81.200.23.210` не блокируется —
проблема в HTTP-клиенте:
| Клиент | auth-api | deck-api-test |
|---|---|---|
| `curl` (без --http2) | ❌ 403 | ❌ 403 |
| `curl --http2` | ✅ 200 | ❌ 403 |
| `Node.js https` | ✅ 200 | ✅ 200 |
| Браузер | ✅ 200 | ✅ 200 |
**Правило для curl:** `--http2` + `Accept: application/json`.
**Node.js (приложение):** работает без проблем.
**Статус:** 🟢 подтверждено
---
## 4. Инфраструктура и деплой
- **Платформа:** 🔴 уточнить
- **CI/CD:** 🔴 уточнить
- **Сетевое ограничение /export:** 🔴 уточнить
---
## 5. Функциональные уточнения
### 5.1. Мульти-компания в UI
- Список компаний из `profiles[]`, активная по `is_active_profile`
- Переключение: UI → `POST /switch-profile` на IAM → обновить сессию
- **Статус:** 🟡 требует реализации
### 5.2. Soft-delete фильтр
- `includeDeleted` всегда `false`
- **Статус:** 🟡 не реализовано
### 5.3. Индивидуальные лимиты
- **Статус:** 🔴 проверить
### 5.4. Уведомление о нормализации
- **Статус:** 🔴 проверить
---
## 6. Тестовые данные
| Email | ClientID | Компания | Роль |
|---|---|---|---|
| `tazetdinovn@gmail.com` | WZ03709 | naeel_test | Пользователь |
| `client@example.com` | WZ01325 | Тест | Клиент |
| `admin@nubes.ru` | WZ01112 | Нубес | Админ |
### HAR-сессия
- Файл: `Files/nubes_login.har`, пользователь `tazet@narod.ru`, deck-test
---
## 7. Принятые решения
| Дата | Решение |
|---|---|
| 01.06.2026 | Экспорт: txt, одна строка = один CIDR |
| 01.06.2026 | Демо одобрено |
| 02.06.2026 | Любой юзер компании редактирует whitelist всей компании |
| 04.06.2026 | **IAM API — источник компаний.** `GET /auth/user``profiles[]`. `isAdmin` из `userInfo.isAdmin`. Переключение через `POST /switch-profile`. |
| 04.06.2026 | ddos-guard: curl требует `--http2`, Node.js работает |
| 16.06.2026 | **UI: кнопка Показать/Скрыть удалённые.** Вместо чекбокса — кнопка-переключатель. |
| 16.06.2026 | **UI: восстановление удалённых записей.** Кнопка ↺ у удалённых, `POST /restore/:id`. Лимит проверяется. Аудит: RESTORE. |
| 16.06.2026 | **UI: время МСК.** `toLocaleString('ru', {timeZone:'Europe/Moscow'})` + метка «МСК». |
---
## Легенда статусов
| 🔴 | Требует уточнения |
| 🟡 | Уточнено, не реализовано |
| 🟢 | Реализовано / подтверждено |
| ⚫ | Отменено / не актуально |
-248
View File
@@ -1,248 +0,0 @@
Техническое задание
Микросервис управления доверенными адресами клиентов
Self-service портал для указания клиентами доверенных IPv4-адресов и подсетей,
исключаемых из блокировки на стороне облачного провайдера во время DDoS-атак
# 1. Назначение и цели
## 1.1. Назначение
Микросервис предоставляет клиентам облачного провайдера web-интерфейс для самостоятельного управления списком доверенных IPv4-адресов и подсетей. Записи из этого списка исключаются из автоматической блокировки сетевого взаимодействия системами фильтрации и митигации провайдера, что снижает количество ложноположительных срабатываний для легитимного трафика клиента.
> **Реализация:** `server.js` — точка входа, подключает все роуты. `ui/index.js` — UI-слой.
## 1.2. Цели
- Дать клиентам возможность самостоятельно поддерживать актуальный список доверенных IPv4-адресов, которые будут исключаться из фильтрации во время DDoS-атак.
> **Реализация:** `ui/routes/entries.js` — UI CRUD. `src/api/routes/entries.js` — REST API.
- Предоставить сетевым инженерам единую точку просмотра и управления списками доверенных клиентских белых IPv4-адресов.
> **Реализация:** `ui/routes/admin.js` — /admin и /audit UI. `src/api/routes/admin.js` — API /companies, /audit.
- Обеспечить машиночитаемую выдачу агрегированного (суммаризированного) списка для систем фильтрации трафика.
> **Реализация:** `src/routes/export.js` — GET /export. `src/validators.js` строки 125181 — функция `aggregateCIDRs`.
# 2. Объем работ
- Web-страница / закладка в личном кабинете для управления whitelist-записями.
> **Реализация:** `views/index.ejs` — главная страница. `ui/routes/entries.js` — роуты GET /, POST /add, POST /edit/:id, POST /delete/:id.
- Авторизация через существующий экземпляр Keycloak (OIDC).
> **Реализация:** `src/auth.js` строки 220–260 — верификация JWT RS256 токена Keycloak. Строка 245: чтение claim `ClientID`. Строка 251: определение роли `isAdmin`.
- Валидация формы на стороне клиента и сервера.
> **Реализация:** `src/validators.js` строки 26–79 — серверная валидация `validate()`. `views/index.ejs` — атрибут `pattern` в поле ввода (клиентская).
- Внешний endpoint выдачи агрегированного списка. Выдача txt-файлом с переносом строки. Одна строка – один объект.
> **Реализация:** `src/routes/export.js` строки 2764 — GET /export, Content-Type: text/plain, aggregateCIDRs → join('\n').
- Хранение записей, журнал аудита.
> **Реализация:** `sql/schema.sql` — таблицы `whitelist_entries`, `audit_log`. `src/queries.js` строки 211230 — функция `logAudit`.
- Административное управление лимитами по компаниям.
> **Реализация:** `src/api/routes/admin.js` строки 2735 — PATCH /companies/:id/limit. `src/queries.js` — функция `setLimit`. `views/admin.ejs` — UI формы лимитов.
# 3. Роли и права доступа
Роли определяются на основании claims в OIDC-токене Keycloak. Соответствие claim → роль настраивается на этапе развёртывания.
> **Реализация:** `src/auth.js` строка 31: `ADMIN_CLIENT_ID = process.env.ADMIN_CLIENT_ID || 'WZ01112'`. Строка 251: `isAdmin: clientId === ADMIN_CLIENT_ID`.
| Роль | Идентификация | Видимость записей | Права на изменение |
| --- | --- | --- | --- |
| Клиент (client) | clientId | Только записи компаний, к которым принадлежит пользователь. | Создание, редактирование и удаление записей своих компаний (в пределах лимита). |
| Администратор (admin) | clientId = WZ01112 (Нубес) и отдельный чек-бокс | Записи всех компаний. | Создание, редактирование, удаление всех записей. Изменение лимита для отдельных компаний. |
> **Реализация ролей в API:** `src/api/routes/entries.js` — функция `resolveCompany()`: если `req.user.isAdmin && req.query.company` → берёт чужую компанию, иначе `getOrCreateCompany(clientId)`. `src/auth.js` строки 260265 — middleware `requireAdmin`.
## 3.1. Принадлежность к компании
Принадлежность пользователя к компании определяется через IAM API (`GET /api/v1/auth/user`). Поддерживается сценарий, когда пользователь принадлежит нескольким компаниям: в этом случае в интерфейсе предусматривается переключатель активной компании, а все операции выполняются в контексте выбранной компании.
> **Реализация:** `src/auth.js` — функция `fetchIamUser(token)` вызывает `GET {IAM_API_URL}/api/v1/auth/user`, получает `profiles[]` со всеми компаниями и `userInfo` с данными пользователя. `userInfo.isAdmin` — флаг админа (вместо хардкода `WZ01112`).
>
> 🔧 **Требует доработки:** `fetchIamUser` ещё не реализована. Сейчас данные берутся из JWT (`payload.ClientID`). Правильный источник — IAM API.
Ожидаемые данные из IAM API:
- `profiles[].client_id` — все компании пользователя
- `profiles[].is_active_profile` — активная компания
- `profiles[].id` — ID профиля для `POST /switch-profile`
- `userInfo.clientID` — WZ-код активной компании
- `userInfo.company` — название компании
- `userInfo.companyId` — UUID компании
- `userInfo.isAdmin` — флаг администратора
- `userInfo.email` — идентификация пользователя для аудита
# 4. Функциональные требования
## 4.1. Просмотр списка записей
- Клиент видит таблицу записей активной компании; Администратор – записи всех компаний с фильтром по компании.
> **Реализация:** `ui/routes/entries.js` — GET /: для admin грузит `getAllCompanies()` + фильтр по `?company=<id>`. `views/index.ejs` — dropdown компаний для admin, таблица записей.
- Для каждой записи отображаются: значение (адрес/подсеть), комментарий (если есть), автор(email), дата создания, дата последнего изменения.
> **Реализация:** `views/index.ejs` строки 280–295 — колонки таблицы: `value_cidr`, `comment`, `created_by`, `created_at`, `updated_at`. Даты в timezone `Europe/Moscow`.
- Soft-deleted записи по умолчанию скрыты; для администратора предусмотрен фильтр для их отображения.
> **Реализация:** `src/queries.js` строки 2531 — `listEntries(companyId, includeDeleted)`. SQL: `AND deleted_at IS NULL` когда `includeDeleted=false`.
>
> ⚠️ **Фильтр "показать удалённые" в UI не реализован** — `includeDeleted` всегда `false`.
- Отображается текущее использование лимита: «использовано X из N».
> **Реализация:** `src/api/routes/entries.js` строка 62: `res.json({ entries, limit, used: entries.length })`. `views/index.ejs` — блок статистики `<%= used %> / <%= limit %>`.
## 4.2. Создание записи
- Форма содержит поля: значение (IPv4-адрес или подсеть CIDR) и необязательный комментарий (до 255 символов).
> **Реализация:** `views/index.ejs` — форма POST /add с полями `value` и `comment`. Атрибут `maxlength="18"` на поле адреса.
- Значение проходит валидацию (см. раздел 5) на клиенте и обязательно повторно на сервере.
> **Реализация:** `src/validators.js` строки 2679 — `validate(input)`. Вызывается в `src/queries.js` строка 33: `const { cidr, wasNormalized } = validate(rawValue)`.
- Перед сохранением проверяется: соблюдение лимита компании, отсутствие пересечений и дубликатов внутри компании, отсутствие принадлежности к запрещённым диапазонам.
> **Реализация:** `src/queries.js` строки 3270 — `createEntry()`: блокировка строки компании (FOR UPDATE), проверка лимита (строки 43–46), проверка дубликатов и пересечений (строки 49–57), проверка запрещённых диапазонов в `validate()` (строки 7375 validators.js).
- При успешном сохранении создаётся запись аудита.
> **Реализация:** `src/queries.js` строка 66: `logAudit(userEmail, companyId, 'CREATE', null, cidr, ...)`.
## 4.3. Редактирование записи
- Редактирование значения и комментария доступно компании в рамках своих прав.
> **Реализация:** `ui/routes/entries.js` — POST /edit/:id. `src/api/routes/entries.js` — PATCH /entries/:id. Modal в `views/index.ejs` — кнопка `.btn-edit`, event delegation в `<script>`.
- При изменении значения повторно выполняется полный набор проверок валидации и пересечений.
> **Реализация:** `src/queries.js` строки 77115 — `updateEntry()`: валидация через `validate()`, проверка пересечений (исключая саму запись: `AND id <> $2`).
- Изменение фиксируется в журнале аудита с сохранением прежнего и нового значения.
> **Реализация:** `src/queries.js` строка 107: `logAudit(userEmail, companyId, 'UPDATE', old.value_cidr, cidr, entryId, ...)`.
## 4.4. Удаление записи (soft delete)
- Удаление выполняется как логическое (soft delete): запись помечается удалённой (deleted_at, deleted_by), но физически сохраняется.
> **Реализация:** `src/queries.js` строки 118145 — `deleteEntry()`: UPDATE SET `deleted_at = NOW(), deleted_by = userEmail`. `sql/schema.sql` — колонки `deleted_at`, `deleted_by` в таблице `whitelist_entries`.
- Удалённая запись освобождает место в лимите компании и исключается из внешней агрегированной выдачи.
> **Реализация:** `src/queries.js` строка 50: COUNT считает только `WHERE deleted_at IS NULL`. `src/routes/export.js` — запрос только активных записей.
- Действие фиксируется в журнале аудита.
> **Реализация:** `src/queries.js` строка 134: `logAudit(userEmail, companyId, 'DELETE', old.value_cidr, null, ...)`.
## 4.5. Лимит записей на компанию
- Действует глобальный лимит по умолчанию: 15 активных записей на компанию.
> **Реализация:** `src/queries.js` строки 1720 — `getLimit()`: `parseInt(process.env.DEFAULT_LIMIT) || 15`.
- Значение глобального лимита по умолчанию задаётся конфигурацией сервиса и может быть изменено без пересборки.
> **Реализация:** `src/queries.js` строка 18: `process.env.DEFAULT_LIMIT` — переменная окружения, не хардкод.
- Для отдельной компании администратор может задать индивидуальный лимит, переопределяющий глобальный (как в большую, так и в меньшую сторону).
> **Реализация:** `src/api/routes/admin.js` строки 2735 — PATCH /companies/:id/limit. `src/queries.js` строка 20: `company.custom_limit != null ? company.custom_limit : defaultLimit`. `sql/schema.sql` — колонка `custom_limit` в таблице `companies`.
- При попытке превысить лимит создание блокируется с понятным сообщением; в подсчёт идут только активные записи.
> **Реализация:** `src/queries.js` строки 4346: `if (cnt >= limit) throw new Error('Лимит исчерпан: N из N')`. API возвращает 409.
- Снижение лимита ниже текущего числа записей не удаляет существующие записи, но блокирует создание новых до приведения в соответствие.
> **Реализация:** `src/api/routes/admin.js` строки 2835 — setLimit просто записывает значение без удаления записей. Блокировка создания — через проверку `cnt >= limit` в `createEntry`.
## 4.6. Журнал аудита
- Все изменяющие операции фиксируются неизменяемыми записями аудита.
> **Реализация:** `src/queries.js` строки 211218 — `logAudit()`: INSERT в `audit_log` без UPDATE/DELETE операций над ней.
- Каждая запись аудита содержит: кто (пользователь), когда (timestamp), компания, тип действия, прежнее и новое состояние.
> **Реализация:** `sql/schema.sql` — таблица `audit_log`: колонки `user_email`, `created_at`, `company_id`, `action`, `old_value`, `new_value`. `views/audit.ejs` строки 131–160 — отображение.
- Журнал доступен для просмотра только администратору.
> **Реализация:** `src/api/routes/admin.js` — GET /audit защищён `apiRequireAdmin`. `ui/routes/admin.js` — GET /audit проверяет `req.session.user.isAdmin`.
## 4.7. Внешняя выдача агрегированного списка
- Подсети суммаризируются (агрегируются в минимальный набор CIDR) по всем компаниям совместно. Пересечения между разными компаниями допустимы.
> **Реализация:** `src/validators.js` строки 125181 — `aggregateCIDRs()`: сортировка, слияние перекрывающихся диапазонов, преобразование обратно в CIDR.
- Предоставляется отдельный HTTP GET endpoint, отдающий полный суммаризированный список активных записей всех компаний файлом в формате txt.
> **Реализация:** `src/routes/export.js` строки 2764 — GET /export. Content-Type: `text/plain`, ответ: `aggregated.join('\n')`.
- Авторизация: на старте endpoint может работать без авторизации (по сетевому ограничению / разрешенный список потребителей по ip).
> ⚠️ **Не реализовано** — /export требует Bearer-токен (авторизован). По ТЗ должен быть доступен без авторизации по IP-списку.
# 5. Требования к валидации
Валидация выполняется на клиенте и обязательно дублируется на сервере. Серверная валидация является авторитетной.
> **Реализация:** `src/validators.js` — вся серверная валидация. `views/index.ejs` — атрибут `pattern` (клиент).
| Правило | Описание | Реализация |
| --- | --- | --- |
| Формат IPv4 | Допускается одиночный адрес или подсеть CIDR /22/32. | `src/validators.js` строки 41–53: добавление /32 если нет маски, проверка `mask < 22 \|\| mask > 32`. |
| Только IPv4 | IPv6-значения или доменные имена отклоняются. | `src/validators.js` строки 3136: `if (raw.includes(':'))` → отклонить, проверка букв. |
| Корректность подсети | Host-биты обнуляются, пользователь уведомляется о нормализации. | `src/validators.js` строки 55–69: битовая арифметика, `wasNormalized = addr !== networkAddr`. Флаш-сообщение в `ui/routes/entries.js`. |
| Запрет серых адресов | Диапазоны из Приложения А запрещены. | `src/validators.js` строки 420: `BLOCKED_RANGES[]`. Строки 73–75: проверка `overlaps()`. |
| Отсутствие дубликатов | Совпадающие записи в компании запрещены. | `src/queries.js` строка 53: `if (row.value_cidr === cidr) throw`. Уникальный индекс в `sql/schema.sql`. |
| Отсутствие пересечений | Пересечение с существующей записью в компании запрещено. | `src/queries.js` строки 49–57: перебор активных записей + `overlaps()`. Между компаниями — допускается. |
| Длина комментария | Не более 255 символов. | `src/validators.js` строка 183 (или в api/routes/entries.js): проверка `comment.length > 255` → 400. |
# Приложение А – Список запрещённых к созданию подсетей
> **Реализация:** `src/validators.js` строки 420 — массив `BLOCKED_RANGES`.
| Назначение | Префикс |
| --- | --- |
| Private (RFC1918) | 10.0.0.0/8 |
| Private (RFC1918) | 172.16.0.0/12 |
| Private (RFC1918) | 192.168.0.0/16 |
| CGNAT (RFC6598) | 100.64.0.0/10 |
| Loopback | 127.0.0.0/8 |
| Link-local (APIPA) | 169.254.0.0/16 |
| IANA special block | 192.0.0.0/24 |
| TEST-NET-1 (docs) | 192.0.2.0/24 |
| TEST-NET-2 (docs) | 198.51.100.0/24 |
| TEST-NET-3 (docs) | 203.0.113.0/24 |
| Benchmarking | 198.18.0.0/15 |
| Multicast | 224.0.0.0/4 |
| Reserved (Class E) | 240.0.0.0/4 |
| Limited broadcast | 255.255.255.255/32 |
---
## ⚠️ Расхождения с ТЗ (что не реализовано)
| Пункт ТЗ | Статус |
| --- | --- |
| 3.1 Несколько компаний для одного пользователя | Не реализовано — ждём формат claim от devops |
| 4.1 Фильтр soft-deleted записей для admin | Не реализовано — `includeDeleted` всегда false |
| 4.7 /export без авторизации (по IP) | Не реализовано — требует Bearer-токен |
-97
View File
@@ -1,97 +0,0 @@
Техническое задание
Микросервис управления доверенными адресами клиентов
Self-service портал для указания клиентами доверенных IPv4-адресов и подсетей,
исключаемых из блокировки на стороне облачного провайдера во время DDoS-атак
# 1. Назначение и цели
## 1.1. Назначение
Микросервис предоставляет клиентам облачного провайдера web-интерфейс для самостоятельного управления списком доверенных IPv4-адресов и подсетей. Записи из этого списка исключаются из автоматической блокировки сетевого взаимодействия системами фильтрации и митигации провайдера, что снижает количество ложноположительных срабатываний для легитимного трафика клиента.
## 1.2. Цели
- Дать клиентам возможность самостоятельно поддерживать актуальный список доверенных IPv4-адресов, которые будут исключаться из фильтрации во время DDoS-атак.
- Предоставить сетевым инженерам единую точку просмотра и управления списками доверенных клиентских белых IPv4-адресов.
- Обеспечить машиночитаемую выдачу агрегированного (суммаризированного) списка для систем фильтрации трафика.
# 2. Объем работ
- Web-страница / закладка в личном кабинете для управления whitelist-записями.
- Авторизация через существующий экземпляр Keycloak (OIDC).
- Валидация формы на стороне клиента и сервера.
- Внешний endpoint выдачи агрегированного списка. Выдача txt-файлом с переносом строки. Одна строка – один объект.
- Хранение записей, журнал аудита.
- Административное управление лимитами по компаниям.
# 3. Роли и права доступа
Роли определяются на основании claims в OIDC-токене Keycloak. Соответствие claim → роль настраивается на этапе развёртывания.
## 3.1. Принадлежность к компании
Принадлежность пользователя к компании определяется из claim токена. Поддерживается сценарий, когда пользователь принадлежит нескольким компаниям: в этом случае в интерфейсе предусматривается переключатель активной компании, а все операции выполняются в контексте выбранной компании.
Ожидаемые claims (имена согласуются с командой Keycloak):
clientID — идентификатор компании
email — идентификация пользователя для аудита
# 4. Функциональные требования
## 4.1. Просмотр списка записей
- Клиент видит таблицу записей активной компании; Администратор – записи всех компаний с фильтром по компании.
- Для каждой записи отображаются: значение (адрес/подсеть), комментарий (если есть), автор(email), дата создания, дата последнего изменения.
- Soft-deleted записи по умолчанию скрыты; для администратора предусмотрен фильтр для их отображения.
- Отображается текущее использование лимита: «использовано X из N».
## 4.2. Создание записи
- Форма содержит поля: значение (IPv4-адрес или подсеть CIDR) и необязательный комментарий (до 255 символов).
- Значение проходит валидацию (см. раздел 5) на клиенте и обязательно повторно на сервере.
- Перед сохранением проверяется: соблюдение лимита компании, отсутствие пересечений и дубликатов внутри компании, отсутствие принадлежности к запрещённым диапазонам.
- При успешном сохранении создаётся запись аудита.
## 4.3. Редактирование записи
- Редактирование значения и комментария доступно компании в рамках своих прав.
- При изменении значения повторно выполняется полный набор проверок валидации и пересечений.
- Изменение фиксируется в журнале аудита с сохранением прежнего и нового значения.
## 4.4. Удаление записи (soft delete)
- Удаление выполняется как логическое (soft delete): запись помечается удалённой (deleted_at, deleted_by), но физически сохраняется.
- Удалённая запись освобождает место в лимите компании и исключается из внешней агрегированной выдачи.
- Действие фиксируется в журнале аудита.
## 4.5. Лимит записей на компанию
- Действует глобальный лимит по умолчанию: 15 активных записей на компанию.
- Значение глобального лимита по умолчанию задаётся конфигурацией сервиса и может быть изменено без пересборки.
- Для отдельной компании администратор может задать индивидуальный лимит, переопределяющий глобальный (как в большую, так и в меньшую сторону).
- При попытке превысить лимит создание блокируется с понятным сообщением; в подсчёт идут только активные записи.
- Снижение лимита ниже текущего числа записей не удаляет существующие записи, но блокирует создание новых до приведения в соответствие.
## 4.6. Журнал аудита
- Все изменяющие операции фиксируются неизменяемыми записями аудита.
- Каждая запись аудита содержит: кто (пользователь), когда (timestamp), компания, тип действия, прежнее и новое состояние.
- Журнал доступен для просмотра только администратору.
## 4.7. Внешняя выдача агрегированного списка
- Подсети суммаризируются (агрегируются в минимальный набор CIDR) по всем компаниям совместно. Пересечения между разными компаниями допустимы.
- Предоставляется отдельный HTTP GET endpoint, отдающий полный суммаризированный список активных записей всех компаний файлом в формате txt.
- Авторизация: на старте endpoint может работать без авторизации (по сетевому ограничению / разрешенный список потребителей по ip).
# 5. Требования к валидации
Валидация выполняется на клиенте и обязательно дублируется на сервере. Серверная валидация является авторитетной.
# Приложение А – Список запрещенных к созданию подсетей.
| Роль | Идентификация | Видимость записей | Права на изменение |
| --- | --- | --- | --- |
| Клиент (client) | clientId | Только записи компаний, к которым принадлежит пользователь. | Создание, редактирование и удаление записей своих компаний (в пределах лимита). |
| Администратор (admin) | clientId = WZ01112 (Нубес) и отдельный чек-бокс | Записи всех компаний. | Создание, редактирование, удаление всех записей. Изменение лимита для отдельных компаний. |
| Правило | Описание |
| --- | --- |
| Формат IPv4 | Допускается одиночный адрес (например 203.0.113.10) или подсеть в нотации CIDR (например 203.0.113.0/24). Допускается использование масок /32 - /22. Маска /21 и больше не допускается. |
| Только IPv4 | IPv6-значения или доменные имена отклоняются. |
| Корректность подсети | Введенный адрес с маской подсети должен нормализоваться к адресу подсети, все host-биты должны быть обнулены. Пользователь должен быть уведомлен, что ввел адрес из хостовой части, а не адрес подсети и произошла нормализация. |
| Запрет серых адресов | Адреса и подсети из частных диапазонов (Приложение А) запрещены к добавлению. |
| Отсутствие дубликатов | В пределах одной компании запрещены полностью совпадающие записи. |
| Отсутствие пересечений | В пределах одной компании запрещено добавление записи, пересекающейся с уже существующей (включая вложенность подсетей). Между разными компаниями пересечения допускаются. |
| Длина комментария | Не более 255 символов; поле необязательное. |
| Назначение | Префикс |
| --- | --- |
| Private (RFC1918) | 10.0.0.0/8 |
| Private (RFC1918) | 172.16.0.0/12 |
| Private (RFC1918) | 192.168.0.0/16 |
| CGNAT (RFC6598) | 100.64.0.0/10 |
| Loopback | 127.0.0.0/8 |
| Link-local (APIPA) | 169.254.0.0/16 |
| IANA special block | 192.0.0.0/24 |
| TEST-NET-1 (docs) | 192.0.2.0/24 |
| TEST-NET-2 (docs) | 198.51.100.0/24 |
| TEST-NET-3 (docs) | 203.0.113.0/24 |
| Benchmarking | 198.18.0.0/15 |
| Multicast | 224.0.0.0/4 |
| Reserved (Class E) | 240.0.0.0/4 |
| Limited broadcast | 255.255.255.255/32 |
-33
View File
@@ -1,33 +0,0 @@
# Локальный Keycloak для тестирования OIDC
Поднимается на ВМ `italo.kube5s.ru` (5.172.178.213), пока нет кредов от продакшен-KC.
## Установка
```bash
# Всё делается на ВМ:
cd ~/keycloak
./setup.sh
```
## Доступ после запуска
| Что | URL |
|---|---|
| Keycloak admin | `https://italo.kube5s.ru:8443/admin` |
| Realm | `ipwhitelist` |
| OIDC endpoints | `https://italo.kube5s.ru:8443/realms/ipwhitelist/.well-known/openid-configuration` |
## Переменные для .env (ipwhitelist)
```
KC_BASE_URL=https://italo.kube5s.ru:8443/realms/ipwhitelist
KC_CLIENT_ID=ipwhitelist
KC_CLIENT_SECRET=<из Keycloak admin>
APP_URL=https://italo.kube5s.ru
DEV_MODE=false
```
## Пользователи
Создаются через Keycloak admin UI после запуска.
+2 -9
View File
@@ -1,23 +1,16 @@
{
"name": "ipwhitelist",
"version": "0.1.13",
"version": "0.5.0",
"description": "IP WhiteList microservice for cloud provider",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js",
"test": "node tests/api.js",
"test:stress": "node tests/stress.js"
"dev": "node --watch server.js"
},
"dependencies": {
"connect-pg-simple": "^10.0.0",
"cookie-parser": "^1.4.7",
"csrf-csrf": "^4.0.3",
"dotenv": "^16.4.7",
"ejs": "^3.1.10",
"express": "^4.21.1",
"express-rate-limit": "^8.5.2",
"express-session": "^1.19.0",
"helmet": "^8.2.0",
"jsonwebtoken": "^9.0.3",
"pg": "^8.13.1"
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1017 B

-2
View File
@@ -1,2 +0,0 @@
Forbidden
Transaction ID: bdd7fbca-219a-4dbf-95ca-a27cc8bdecd9
+209
View File
@@ -0,0 +1,209 @@
# SSO / Auth — исследование и контекст
> Дата: 2026-05-30
> Актуально для ветки `sonnet`
---
## Исходные данные от команды
### Ссылки (получены 2026-05-30)
| Что | URL | Примечание |
|---|---|---|
| Пример SSO на ColdFusion | https://gitea.services.ngcloud.ru/smishchuk/payg-report.git | Написан «на коленке», но рабочий |
| Конфигурация сервиса в Deck | https://deck.ngcloud.ru/services/instance/detail/523413d2-1ffd-457b-b0e3-0a8185fe9358 | Там видно как кладутся env-переменные |
### Файлы из payg-report сохранены локально
```
research/payg-sso/
Application.cfc — основной SSO flow (редирект, обмен code→token, refresh, logout)
lib_oauth2.cfc — Authorization Code Flow клиент
lib_jwt.cfc — валидация JWT RS256
```
---
## Keycloak — эндпоинты
Realm: `cloud`
Base: `https://keycloak.nubes.ru/realms/cloud`
| Назначение | URL |
|---|---|
| Авторизация (браузер → KK) | `https://keycloak.nubes.ru/realms/cloud/protocol/openid-connect/auth` |
| Обмен code → token | `https://keycloak.nubes.ru/realms/cloud/protocol/openid-connect/token` |
| Logout | `https://keycloak.nubes.ru/realms/cloud/protocol/openid-connect/logout` |
| JWKS (публичные ключи) | `https://keycloak.nubes.ru/realms/cloud/protocol/openid-connect/certs` |
| OpenID конфигурация | `https://keycloak.nubes.ru/realms/cloud/.well-known/openid-configuration` |
---
## Реальная схема аутентификации
```
Браузер
│ 1. GET / (без токена)
наше приложение
│ 2. redirect → KK /auth?client_id=...&redirect_uri=...&scope=openid
Keycloak (keycloak.nubes.ru, realm=cloud)
│ 3. Пользователь логинится
│ 4. KK redirect → наш /callback?code=...
наше приложение
│ 5. POST /token (code + client_secret) → access_token + refresh_token
│ 6. Валидация JWT через JWKS (certs endpoint)
│ 7. Извлечение claims из JWT → req.user
Работаем: req.user.ClientID = "WZ01325" и тд
```
**Важно из примера payg-report:**
- Тип flow: Authorization Code Flow (не Implicit, не PKCE — обычный)
- `client_secret` передаётся при обмене code→token (confidential client)
- Сертификат берётся с JWKS endpoint при старте, кэшируется в application scope
- При истечении части времени жизни — автоматический refresh через refresh_token
---
## JWT — структура токена
JWT выпускает **auth-api** (не Keycloak напрямую).
Алгоритм: **RS256**.
JWKS для валидации: из auth-api (уточнить URL у DevOps).
> ⚠️ В payg-report используют `keycloak.nubes.ru/certs`, но наш портал deck.ngcloud.ru
> идёт через auth-api.ngcloud.ru — нужно уточнить чей именно JWT будет у нас.
### Claims в JWT (из реального токена, secrets.txt)
| Claim | Тип | Пример | Назначение |
|---|---|---|---|
| `iss` | string | `"auth-api"` | Издатель |
| `sub` | string | `"0199e325-1cdf-..."` | UUID пользователя |
| `exp` | number | `1795627443` | Срок действия |
| `iat` | number | `1780075443` | Выпущен |
| `ClientID` | string | `"WZ01325"` | **WZ-номер компании** ← ключевой claim |
| `company_id` | string (UUID) | `"3e64aac6-dcfc-..."` | UUID компании |
| `company_name` | string | `"Тест"` | Название компании |
| `email` | string | `"tazet@narod.ru"` | Email (для аудита) |
| `login` | string | `"tazet@narod.ru"` | Логин |
| `firstname` | string | `"Наиль"` | Имя |
| `lastname` | string | `"Тазетдинов"` | Фамилия |
| `token_type` | string | `"tech"` | Тип токена |
**Пример декодированного payload** (из secrets.txt — не секрет, payload JWT не зашифрован):
```json
{
"iss": "auth-api",
"sub": "0199e325-1cdf-7cda-9319-e5302a85e291",
"ClientID": "WZ01325",
"company_id": "3e64aac6-dcfc-4082-88dc-da19c86555a5",
"company_name": "Тест",
"email": "tazet@narod.ru",
"login": "tazet@narod.ru",
"token_type": "tech"
}
```
---
## Конфигурация сервиса (env-переменные)
Из примера payg-report и Deck — конфиги кладутся в переменные окружения:
```bash
# Нужно получить от DevOps:
KC_CLIENT_ID=white.nodejsk8s.dev.nubes.ru # имя нашего клиента в KK (уточнить)
KC_CLIENT_SECRET=<секрет> # из Deck / у DevOps
JWKS_URL=https://keycloak.nubes.ru/realms/cloud/protocol/openid-connect/certs
# ИЛИ если через auth-api:
# JWKS_URL=https://auth-api.ngcloud.ru/.well-known/jwks.json
# В payg-report переменная называется:
IDP_CLIENT_SECRET=<секрет>
```
---
## Что сейчас в нашем коде
Файл: `src/auth.js`
| Режим | Условие | Поведение |
|---|---|---|
| **DEV_MODE** | `DEV_MODE=true` и не production | Авторизация пропущена, юзер захардкожен из `MOCK_USERS` |
| **JWKS_URL задан** | `JWKS_URL != ''` | Валидирует Bearer-токен из заголовка через JWKS |
| **Мок RS256** | Ничего из выше | Генерирует собственную RSA-пару, выдаёт и валидирует JWT сам |
**Чего не хватает для продакшена:**
- Authorization Code Flow (редирект на KK и обратно)
- Обмен `code``access_token` через `/token`
- Session/cookie для хранения токена между запросами
- Refresh токена
---
## Пример SSO flow из payg-report (ключевые строки)
```javascript
// Настройка (из окружения):
const client_id = process.env.IDP_CLIENT_SECRET; // их название
const auth_endpoint = 'https://keycloak.nubes.ru/realms/cloud/protocol/openid-connect/auth';
const token_endpoint = 'https://keycloak.nubes.ru/realms/cloud/protocol/openid-connect/token';
const certs_url = 'https://keycloak.nubes.ru/realms/cloud/protocol/openid-connect/certs';
// 1. Нет сессии → redirect на KK:
location(idp.buildRedirectToAuthURL({scope: 'openid profile email', state: guid}));
// 2. KK вернул ?code=... → меняем на токен:
const resp = idp.makeAccessTokenRequest(url.code);
// 3. Парсим JWT из ответа:
const token_data = jwt.decode(resp.access_token, idpCertificate, 'RS256');
session.auth.wz = token_data.ClientID; // WZ-номер компании
session.auth.login = token_data.preferred_username;
// 4. Refresh когда прошло > 2/3 времени жизни (только на GET):
if (timeSinceIat > refresh_expires_in / 1.5 && method === 'GET') {
const resp = idp.refreshAccessTokenRequest(session.auth.refresh_token);
// ... обновляем сессию
}
```
---
## Открытые вопросы (нужен ответ от DevOps/KK-команды)
| # | Вопрос | Влияет на |
|---|---|---|
| 1 | Какой `client_id` выдан нашему сервису в KK realm `cloud`? | Конфиг |
| 2 | Где взять `client_secret`? | Конфиг |
| 3 | Токен от KK или от auth-api? Соответственно — какой JWKS_URL? | `src/auth.js` |
| 4 | Claim для admin-роли: чек-бокс — это `realm_roles`, `resource_access`, attribute? | `src/auth.js` requireAdmin |
| 5 | `ClientID` в токене — строка или массив (multi-company)? | `src/queries.js`, UI |
| 6 | `/export` — нужна ли авторизация или только сетевое ограничение по IP? | `src/routes/export.js` |
---
## API Gateway (для справки)
Из `docs/auth-architecture.md`:
- Gateway: `lk-api-gateway.ngcloud.ru`
- JWT из localStorage портала: `authApiTokens.access_token`
- **DDOS-Guard cookies обязательны** при curl-тестировании: `__ddg1_`, `__ddg8_`, `__ddg9_`, `__ddg10_`
```bash
# Пример рабочего curl (2026-05-29):
curl -H "Authorization: Bearer <JWT>" \
-H "Origin: https://deck.ngcloud.ru" \
-H "Cookie: __ddg1_=...; __ddg8_=...; __ddg9_=...; __ddg10_=..." \
"https://lk-api-gateway.ngcloud.ru/api/v1/..."
```
+357
View File
@@ -0,0 +1,357 @@
# Full Code Review — IP WhiteList (Opus, 2026-05-30)
> Ревью по коду из `prompt-opus-full-review-2026-05-30.md` (ветка `sonnet`).
> Легенда: ✅ хорошо · ⚠️ замечание · ❌ проблема (блокер/риск).
---
## 0. Краткое резюме (TL;DR)
Проект аккуратно структурирован: фабрики роутеров с DI, транзакции с `FOR UPDATE`,
параметризованные запросы, частичные уникальные индексы для защиты от гонок.
Базовая гигиена SQL/изоляции компаний — на хорошем уровне.
Однако к продакшену проект **не готов**. Найдено несколько серьёзных проблем:
| # | Проблема | Severity |
|---|----------|----------|
| 1 | `/export` смонтирован **до** auth-middleware → публичная выгрузка CIDR **всех** компаний | ❌ Критично |
| 2 | CSRF `getSessionIdentifier` читает несуществующую cookie `jwt` → токен не привязан к сессии | ❌ Критично |
| 3 | Нет `session.regenerate()` при логине → session fixation | ❌ Высокий |
| 4 | Open redirect через `returnTo` | ❌ Высокий |
| 5 | OIDC: нет проверки `issuer`/`audience`, нет matching по `kid`, JWKS не рефрешится и null в bearer-режиме | ❌ Высокий |
| 6 | `ssl: { rejectUnauthorized: false }` к БД | ⚠️/❌ |
| 7 | Дефолтные секреты (`SESSION_SECRET`, `CSRF_SECRET`) не fail-fast в проде | ⚠️ |
| 8 | CSP отключён (`contentSecurityPolicy: false`) | ⚠️ |
| 9 | Нет интеграционных тестов (auth, CSRF, IDOR, export) | ⚠️ |
---
## 1. Безопасность (OWASP Top 10)
### 1.1 ❌ Публичная выгрузка всех компаний через `/export`
В `server.js` порядок монтирования:
```js
app.use(require('./src/routes/export').createRouter({ q, exportLimiter, aggregateCIDRs }));
// ...
app.use(auth.middleware); // ← аутентификация ПОСЛЕ export
```
`/export` доступен **без аутентификации**, а внутри:
```js
const cidrs = await q.getExportCIDRs(); // companyId = null → ВСЕ компании
const aggregated = aggregateCIDRs(cidrs);
```
`getExportCIDRs(null)` возвращает CIDR **всех** компаний, агрегированные вместе.
Любой анонимный пользователь получает полный список whitelisted-IP всех арендаторов.
Это нарушение изоляции данных (A01 Broken Access Control) и утечка информации (A01/A04).
**Рекомендация:** одно из:
- перенести `/export` **после** `auth.middleware` и фильтровать по `req.user` (для админа — все/выбранная компания, для пользователя — только своя);
- либо, если выгрузка для оборудования должна быть машинной, защитить статическим bearer-токеном/mTLS и **никогда** не отдавать срез всех компаний без явной авторизации.
### 1.2 ❌ CSRF: `getSessionIdentifier` привязан к мёртвой cookie
```js
// src/middleware/csrf.js
getSessionIdentifier: (req) => req.cookies.jwt || '',
```
В коде есть честный комментарий, что после перехода на `express-session` cookie `jwt`
больше не выдаётся. Значит идентификатор сессии для **всех** пользователей = `''`.
Double-submit перестаёт быть привязан к конкретной сессии — токен валиден «глобально»,
что ослабляет защиту (особенно с учётом session fixation ниже).
**Рекомендация:**
```js
getSessionIdentifier: (req) => req.session?.id || req.sessionID || '',
```
и убедиться, что `initCsrf()` вызывается после подключения `session` middleware (сейчас так и есть).
### 1.3 ❌ Session fixation — нет регенерации сессии при логине
В `routes/auth.js` (POST `/login`, `/callback`, `/dev-login`) сразу пишется
`req.session.user = ...` без `req.session.regenerate()`. Идентификатор сессии,
выданный до аутентификации, сохраняется — классический session fixation (A07).
**Рекомендация:** перед установкой `user` вызывать:
```js
req.session.regenerate(err => { if (err) ...; req.session.user = user; req.session.save(() => res.redirect(...)); });
```
### 1.4 ❌ Open redirect через `returnTo`
```js
// POST /login
res.redirect(req.query.returnTo || '/');
// /callback
const returnTo = req.session.returnTo || '/';
res.redirect(returnTo);
```
`returnTo` приходит из запроса и не валидируется. Значение вида `//evil.com`
или `https://evil.com` приведёт к открытому редиректу (A01, фишинг).
**Рекомендация:** разрешать только локальные пути:
```js
function safeReturn(t) {
return (typeof t === 'string' && t.startsWith('/') && !t.startsWith('//')) ? t : '/';
}
```
### 1.5 ❌ OIDC verification — недостаточная проверка токена
```js
function verifyOidcToken(token) {
const key = cachedJwks.keys.find(k => k.kty === 'RSA' && k.use === 'sig'); // ← не по kid
...
return jwt.verify(token, pem, { algorithms: ['RS256'] }); // ← нет issuer/audience
}
```
Проблемы:
- **Нет проверки `issuer` и `audience`.** Любой RS256-токен, подписанный ключом из этого JWKS
(например, токен, выданный другому клиенту того же realm), пройдёт проверку → privilege/tenant confusion.
- **Выбор ключа не по `kid`** из заголовка токена, а «первый RSA sig». При ротации/нескольких
ключах возможны как ложные отказы, так и приём не того ключа.
- **`cachedJwks` загружается только в `exchangeCode`** и больше не рефрешится. В bearer-режиме
(запрос с `Authorization: Bearer` без предварительного `/callback`) `cachedJwks === null`
`verifyOidcToken` бросит `JWKS not loaded yet`. При ротации ключей в KC — отказы до рестарта.
**Рекомендация:** грузить JWKS при старте и кэшировать с TTL/refresh по `kid`; в `jwt.verify`
передавать `{ issuer: KC_ISSUER, audience: KC_CLIENT_ID, algorithms: ['RS256'] }`; выбирать ключ
по `kid` из декодированного заголовка.
### 1.6 ⚠️ TLS к БД отключает проверку сертификата
```js
ssl: process.env.DB_SSLMODE === 'require' ? { rejectUnauthorized: false } : false,
```
`rejectUnauthorized: false` = шифрование без аутентификации сервера → MITM возможен (A02/A05).
Имя `require` обманчиво: это поведение `sslmode=require` в libpq, но для прод-окружения
нужен `verify-full` с CA.
**Рекомендация:** добавить режим с CA: `{ ca: fs.readFileSync(DB_CA), rejectUnauthorized: true }`.
### 1.7 ⚠️ Дефолтные секреты не приводят к отказу в проде
```js
secret: process.env.SESSION_SECRET || 'dev-session-secret-change-me',
getSecret: () => process.env.CSRF_SECRET || 'dev-csrf-secret-change-in-prod',
```
Если переменные не заданы в проде — приложение молча стартует со слабыми предсказуемыми
секретами (A02/A05). Подделка сессионных cookie/CSRF становится тривиальной.
**Рекомендация:** при `NODE_ENV === 'production'` — fail-fast, если секреты не заданы/равны дефолту.
### 1.8 ⚠️ CSP отключён
```js
app.use(helmet({ contentSecurityPolicy: false }));
```
Отключённая CSP убирает важный слой защиты от XSS (A03). EJS-шаблоны в промпте не приведены —
**нельзя подтвердить**, что пользовательский ввод (`comment`, `companyName`, сообщения `error`/`message`
из query) экранируется через `<%= %>`, а не `<%- %>`. `error`/`message` берутся прямо из `req.query`
и рендерятся — при `<%- %>` это reflected XSS.
**Рекомендация:** включить разумную CSP; проверить, что все вывод-точки используют экранирование `<%= %>`.
### 1.9 ⚠️ `dev-login` — риск в проде
`/dev-login` при `DEV_MODE=true` (или заданном `DEV_SECRET`) позволяет войти под любым
пользователем, включая `isAdmin: on`, без пароля. Если `DEV_MODE` случайно окажется `true` в проде —
полный обход аутентификации.
**Рекомендация:** жёстко запретить `DEV_MODE` при `NODE_ENV=production` (отказ старта),
а не полагаться на конфигурацию окружения.
### 1.10 ⚠️ Нет rate-limit на логин
POST `/login`, `/dev-login`, `/callback` не покрыты лимитером — для mock некритично,
но при реальном OIDC `/callback` без лимита может использоваться для нагрузки на токен-эндпоинт KC.
### 1.11 ✅ Что сделано хорошо
- **SQL injection** — все запросы параметризованы (`$1, $2, ...`), конкатенации пользовательского
ввода в SQL нет. ✅
- **IDOR / изоляция компаний** — обычный пользователь не может передать `company_id`; для него всегда
`getOrCreateCompany(clientId, ...)` по его собственному `clientId` из токена. Все мутации (`createEntry`,
`updateEntry`, `deleteEntry`) фильтруют по `company_id`, а `getCompanyById` доступен только в админ-ветке. ✅
- **CSRF-обработчик** ошибок (`EBADCSRFTOKEN`) даёт понятный 403. ✅
- Cookie-флаги `httpOnly`, `secure` (в проде), `sameSite: 'lax'`. ✅
---
## 2. Корректность бизнес-логики, транзакции, конкурентность
### 2.1 ✅ Гонки при добавлении/лимиты
`createEntry` берёт `SELECT ... FOR UPDATE` по строке компании, затем считает count и
проверяет пересечения внутри одной транзакции. Это сериализует параллельные вставки в рамках
одной компании. Плюс частичный уникальный индекс `uq_entries_active_cidr` страхует от дублей
на уровне БД. Хорошая многоуровневая защита. ✅
### 2.2 ⚠️ Проверка пересечений O(n) перебором в приложении
`createEntry`/`updateEntry` загружают все активные CIDR и сравнивают через `overlaps` в JS.
При лимите ~15 записей это незаметно, но логика дублируется и проверка пересечений невозможна
на уровне БД (индекс ловит только точный дубль, не overlap). Для текущих лимитов — приемлемо. ⚠️
### 2.3 ⚠️ `updateEntry`: `comment || old.comment`
Пустая строка комментария (`''`) трактуется как «не менять» и возвращает старый комментарий —
пользователь не сможет очистить комментарий. Edge case. ⚠️
### 2.4 ⚠️ `value_cidr VARCHAR(18)` и regex в CHECK
Схема ограничивает `/\d{1,2}/` для маски, но приложение разрешает только `/22``/32`
согласовано. Однако CHECK-regex в БД допускает невалидные октеты (`999.999.999.999/40`),
полагаясь полностью на валидацию приложения. Дубль-валидация на уровне БД неполная. ⚠️
### 2.5 ⚠️ `companyId` (UUID) из токена фактически не используется
Для обычного пользователя доступ к данным идёт по `companies.id` (SERIAL), полученному из
`getOrCreateCompany(clientId)`. UUID `company_id` из токена в выборках не участвует. Это не баг
(изоляция по `clientId` корректна), но источник путаницы: два разных идентификатора компании. ⚠️
### 2.6 ✅ Аудит в той же транзакции
`logAudit(..., client)` выполняется внутри транзакции мутации — запись аудита атомарна
с изменением. ✅
---
## 3. CIDR-валидация и агрегация (`validators.js`)
### 3.1 ✅ `validate()`
- IPv6 отбрасывается, проверка формата, нормализация к адресу сети, проверка против
`BLOCKED_RANGES`. Логика корректна для /22–/32.
- Битовые операции `(acc << 8) + parseInt(...)` дают знаковое 32-битное промежуточное значение,
но финальный `>>> 0` приводит к беззнаковому — для рассматриваемых масок результат верный. ✅
### 3.2 ⚠️ `overlaps()` — корректно, но нечитаемо
```js
return a.start <= b.end && b.start <= a.start ||
b.start <= a.end && a.start <= b.start;
```
Сводится к «начало одного интервала лежит внутри другого» — это **корректный** критерий
пересечения двух интервалов (проверено на граничных случаях: вложенность, смежность, непересечение).
Но запись через смешанные `&&`/`||` без скобок хрупкая и трудна для ревью.
**Рекомендация:** заменить на каноническое `a.start <= b.end && b.start <= a.end`.
### 3.3 ⚠️ Список `BLOCKED_RANGES` неполон
Заблокированы RFC1918/CGNAT/loopback/link-local/multicast/reserved, но **не** `0.0.0.0/8`
(«this network»). Можно добавить, например, `0.0.0.0/22`. Маловажно, но для строгого whitelist стоит закрыть.
### 3.4 ✅ `aggregateCIDRs()` / `rangeToCIDRs()`
- Сортировка по `start`, слияние перекрывающихся и **смежных** диапазонов (с защитой от переполнения
`last.end < 0xFFFFFFFF`), затем разбиение объединённого диапазона на минимальный набор выровненных CIDR.
- `rangeToCIDRs` корректно выбирает наибольший выровненный блок (`trailingZeros`) и уменьшает префикс,
пока блок не помещается в диапазон; курсор всегда продвигается → бесконечного цикла нет, граница
`0xFFFFFFFF` обработана. ✅
Алгоритмически — самая сильная часть проекта.
---
## 4. Архитектура и качество кода
### 4.1 ✅ Сильные стороны
- **Фабрики роутеров с DI** (`createRouter({...})`) — тестируемо, явные зависимости, без скрытых импортов состояния.
- **Разделение слоёв**: `db` / `queries` / `validators` / `routes` / `middleware`.
- **Транзакции** с корректным `BEGIN/COMMIT/ROLLBACK` и `finally { client.release() }`.
- **Auth-абстракция** поддерживает и mock-RS256, и реальный OIDC за единым интерфейсом.
### 4.2 ⚠️ Замечания
- **Дублирование** обработки `company_id` в трёх хендлерах `entries.js` (add/edit/delete) — почти
идентичный блок «определить компанию». Можно вынести в helper-middleware `resolveCompany`.
- **Обработка ошибок через redirect c `error` в query** удобна для UI, но смешивает 4xx-валидацию
и 5xx-сбои БД (любая ошибка `createEntry` уезжает в `?error=...`). Стоит различать пользовательские
ошибки и системные (логировать stack для последних).
- `cachedJwks` / `mockKeyPair` — модульное состояние; для горизонтального масштабирования mock-JWKS
у каждого инстанса свой ключ → токены не валидны между подами. Для mock-режима ок, но в проде
mock использоваться не должен.
- **`express-session` MemoryStore** (стор не задан) — утечки памяти и потеря сессий при рестарте/масштабировании.
Для прода нужен внешний стор (Redis/PG). ⚠️ (фактически блокер прода)
- Комментарии-TODO прямо в коде (`csrf.js`, `db.js`) — хорошо, что зафиксированы, но это незакрытый долг.
---
## 5. Тесты
Из промпта видно ~50 юнит-тестов без БД: загрузка модулей, `config`, `validators` (30+ кейсов),
`auth` (session middleware, `requireAdmin`).
### ✅ Покрыто
- Валидаторы CIDR / агрегация / overlaps — основной риск-домен покрыт хорошо.
- Session-middleware happy path и `requireAdmin`.
### ❌ Не покрыто (критично добавить)
1. **Публичность `/export`** — тест, что неаутентифицированный запрос **не** получает данные
(после фикса 1.1). Сейчас регрессия не отлавливается.
2. **CSRF** — отклонение запроса без/с чужим токеном; привязка токена к сессии (фикс 1.2).
3. **IDOR** — обычный пользователь пытается передать `company_id` чужой компании в add/edit/delete →
должен работать только со своей.
4. **Изоляция в `queries`** — пользователь A не видит/не меняет записи компании B.
5. **Session fixation** — id сессии меняется после логина (фикс 1.3).
6. **Open redirect**`returnTo=//evil.com` не приводит к внешнему редиректу (фикс 1.4).
7. **Лимиты/гонки** — параллельные `createEntry` не превышают лимит (интеграционный, с БД).
8. **OIDC verify** — отклонение токена с чужим `iss`/`aud`, выбор ключа по `kid` (фикс 1.5).
Сейчас нет интеграционных тестов с БД и HTTP-слоем — основной пробел.
---
## 6. Готовность к продакшену — чек-лист блокеров
- [ ] ❌ Закрыть `/export` аутентификацией + фильтрацией по компании.
- [ ] ❌ Починить CSRF `getSessionIdentifier` (`req.session.id`).
- [ ]`session.regenerate()` при логине (fixation).
- [ ] ❌ Валидация `returnTo` (open redirect).
- [ ] ❌ OIDC: `issuer`/`audience`/`kid` + рефреш JWKS.
- [ ] ❌ Внешний session store (Redis/PG) вместо MemoryStore.
- [ ] ⚠️ TLS к БД с проверкой CA (`verify-full`).
- [ ] ⚠️ Fail-fast при дефолтных секретах в проде.
- [ ] ⚠️ Запретить `DEV_MODE`/`/dev-login` в проде.
- [ ] ⚠️ Включить CSP; подтвердить экранирование EJS (`<%= %>`).
- [ ] ⚠️ Добавить интеграционные тесты (export/CSRF/IDOR/fixation/OIDC).
---
## 7. Итоговая оценка по блокам
| Блок | Оценка |
|------|--------|
| SQL injection / параметризация | ✅ |
| Изоляция компаний (IDOR в роутах) | ✅ (но без тестов) |
| `/export` доступ | ❌ |
| CSRF-конфигурация | ❌ |
| Session-управление (fixation, store) | ❌ |
| OIDC / token verification | ❌ |
| Open redirect | ❌ |
| TLS к БД / секреты | ⚠️ |
| CSP / XSS (не подтверждено по views) | ⚠️ |
| Бизнес-логика / транзакции / гонки | ✅ |
| CIDR-валидация и агрегация | ✅ |
| Архитектура / DI | ✅ |
| Покрытие тестами | ⚠️ |
**Вывод:** ядро (валидация, агрегация, транзакции, изоляция в запросах) сделано грамотно.
Блокируют прод в первую очередь четыре вещи: публичный `/export`, сломанная привязка CSRF,
session fixation + MemoryStore и неполная проверка OIDC-токенов. После их устранения и добавления
интеграционных тестов проект можно выводить в эксплуатацию.
+11
View File
@@ -0,0 +1,11 @@
<hr/>
test
<hr/>
<cfflush/>
<!--- <cftry>
<cfdump var=#session#/>
<cfcatch type="any"><cfdump var=#cfcatch#/></cfcatch>
</cftry> --->
<cfmodule template="mod/crm_contract.cfm" wz="WZ01348"/>
<cfdump var=#qComponent#/>
+522
View File
@@ -0,0 +1,522 @@
<cfcomponent
displayname="Application"
output="true"
hint="Handle the application.">
<!--- Pseudoconstructor --->
<!--- Set up the application. --->
<cfset this.Name = "PAYG-REPORT" />
<cfset this.applicationTimeout = createTimeSpan( 0, 3, 0, 0 ) />
<cfset this.sessionmanagement="Yes"/>
<cfset this.clientmanagement="No"/>
<cfset this.sessiontimeout=CreateTimeSpan(0, 1, 0, 0)/>
<cfset this.setclientcookies="No"/>
<cfset this.mappings = structNew() />
<cfset this.mappings["/mod"] = getDirectoryFromPath(getCurrentTemplatePath()) & "mod/" />
<cfset this.customTagPaths = expandPath(getDirectoryFromPath(getCurrentTemplatePath()) & "mod")/>
<!--- <cfset this.nullSupport = true/> --->
<!--- SSO --->
<cfset this.client_id = "payg-report.deck.nubes.ru"/>
<cfset this.auth_endpoint = "https://keycloak.nubes.ru/realms/cloud/protocol/openid-connect/auth" />
<cfset this.logout_endpoint = "https://keycloak.nubes.ru/realms/cloud/protocol/openid-connect/logout" />
<cfset this.access_token_endpoint = "https://keycloak.nubes.ru/realms/cloud/protocol/openid-connect/token" />
<cfset this.idpCertUrl = "https://keycloak.nubes.ru/realms/cloud/protocol/openid-connect/certs" />
<cfset this.client_secret = createObject("java", "java.lang.System").getEnv("IDP_CLIENT_SECRET")/>
<cfset this.iam_url = "https://auth-api.ngcloud.ru/api/v1/auth/user"/>
<cfset this.redirect_uri = "https://#CGI.SERVER_NAME##CGI.SCRIPT_NAME#"/><!--- куда возвращаться от IDP --->
<cfset this.datasource = "rpt"/><!--- к буферной базе --->
<cfset this.defaultdatasource = this.datasource/>
<!--- request scope is available in pseudoconstructor
see also https://www.bennadel.com/blog/1437-coldfusion-scope-existence-during-various-request-types-and-events.htm --->
<cfset request.DS = "#this.datasource#"/>
<cfset request.language=""/>
<cfset getDS(this.datasource)/>
<!--- кажется, нужно инициализировать датасорцы в псевдоконструкторе - onRequest не получается --->
<!--- *** проверить, что есть - если есть, не создавать --->
<cffunction
name="OnApplicationStart"
access="public"
returntype="boolean"
output="false"
hint="Fires when the application is first created.">
<!--- *** мы не проверяем срок жизни сертификата и его отзыв--->
<!--- Obtain idp certificate OnApplicationStart --->
<cfset application.idpCertificate = getIdpCertificate(this.idpCertUrl)/>
<cfreturn true />
</cffunction>
<cffunction
name="OnRequest"
access="public"
returntype="void"
output="true"
hint="Fires after pre page processing is complete.">
<cfargument name="template" type="string" required="true"/>
<cfset request.startTickCount=getTickCount()/>
<cfset setEncoding("FORM", "UTF-8")>
<cfset setEncoding("URL", "UTF-8")>
<cfset request.PERMISSION_NONE=0/>
<cfset request.PERMISSION_READ=1/>
<cfset request.PERMISSION_WRITE=2/>
<!--- <cfset request.UNDEFINED_USR_ID=-1/>
<cfset request.ANONYMOUS_USR_ID=2/>
<cfset request.GUEST_USR_ID=3/> --->
<!--- global settings --->
<cfset request.RECORDS_PER_PAGE=500/>
<cfset request.APP_VERSION="0.00.020"/>
<cfheader name="X-Application-Version" value=#request.APP_VERSION#/>
<cfset request.STAND=getStand()/>
<!--- application constants --->
<!---<cfset request.SMTP_SERVER="172.16.16.16"/>
<cfset request.MAIL_FROM_FOR_NOTIFICATION="Pipeline tracker <pipeline@mail.ru>"/>
<cfset request.SUBJECT_PREFIX="[PIPELINE TRACKER] "/>--->
<!---<cfset request.BASE_URL="https://smtest.dtln.local/pipeline/"/>*** сделать нормально. Внимание! При наличии прокси CGI.SERVER_NAME может быть заменено --->
<cfset local = {} />
<cfset local.basePath = getDirectoryFromPath(
getCurrentTemplatePath()
) />
<cfset local.targetPath = getDirectoryFromPath(
expandPath( arguments.template )
) />
<cfset local.requestDepth = (
listLen( local.targetPath, "\/" ) -
listLen( local.basePath, "\/" )
) />
<cfset request.webRoot = repeatString(
"../",
local.requestDepth
) />
<!---
While we wouldn't normally do this for every page
request (it would normally be cached in the
application initialization), I'm going to calculate
the site URL based on the web root.
--->
<cfset request.siteUrl = (
IIF(
(CGI.server_port_secure <!--- CGI.https EQ "On" does not work with apache+tomcat and nginx+tomcat --->),
DE( "https://" ),
DE( "http://" )
) &
cgi.http_host &
reReplace(
getDirectoryFromPath( arguments.template ), "([^\\/]+[\\/]){#local.requestDepth#}$",
"",
"one"
)
) />
<cfset request.thisPage=Replace(ReplaceNoCase(expandPath(ARGUMENTS.template), local.basePath, ""), "\", "/")/>
<!--- <cfset request.thisUrl="#request.thisPage##len(CGI.queryString)#"/> чревато циклом --->
<!--- получение сертификата может занимать длительное время.
Блокировать на это время приложение или сессию не следует.
Вообще не следует ничего блокировать на время сетевого взаимодействия
Нижеследующая, на первый взгляд, странная конструкция ровно для этого придумана
--->
<!--- <cfoutput>onRequest:check for cert started #(getTickCount()-request.startTickCount)#</cfoutput> --->
<cfset var idpCertificateExists = false/>
<cflock scope="application" type="readonly" timeout=1>
<cfset request.APP_NAME=this.Name/>
<cfset idpCertificateExists = structKeyExists(application,"idpCertificate")/>
</cflock>
<cfif NOT idpCertificateExists>
<!--- Obtain IDP Certificate (onRequest) --->
<cfset var idpCertificate = getIdpCertificate(this.idpCertUrl) />
<cflock scope="application" type="exclusive" timeout=1>
<cfset application.idpCertificate = idpCertificate/>
</cflock>
</cfif>
<!--- <cfoutput>onRequest:check for cert finished #(getTickCount()-request.startTickCount)#</cfoutput> --->
<cfif structKeyExists(url,"logout")>
<cflock scope="session" type="exclusive" timeout="3">
<cfset structDelete(session, "auth")/>
</cflock>
</cfif>
<cfcookie name="CFID" value="#session.CFID#"/>
<cfcookie name="CFTOKEN" value="#session.CFTOKEN#"/>
<cfinclude template="inc/functions.cfm"/>
<!--- SSO --->
<!--- Если без сессии. Проверяем наличие токена в заголовке (и в куках?)
Если его нет, идем к IDP за токеном. Получив токен, складываем его в куки
Если токен есть, распаковываем его. Проверяем протухание. Если прошла установленная часть времени жизни, идем за рефрешем (внимание: это сработает только в браузере,
если клиент не умеет редиректы - то токен должен быть достаточно свежий)
(для начала делаем без рефреша, только проверяем протухание)
А рефреш можно сделать на серверной стороне?
Кладем данные из токена в реквест и наслаждаемся --->
<!--- Вариант: если есть Authorization:bearer, идем с ним к IAM --->
<cfscript>
var requestData = GetHttpRequestData();
var headers = requestData.headers;
if (structKeyExists(headers,"authorization")) { // аутентификация для клиента ЛК
var authorizationHeader = headers.authorization;
if (authorizationHeader.startsWith("Bearer ")) {
var bearerToken = Mid(authorizationHeader, 8);
writeDump(bearerToken);
//var token=toString(binaryDecode(bearerToken, "base64"));
//writeDump(token);
//jwt = new lib.jwt(this.client_secret);
//lock scope="application" type="readonly" timeout="1" {
// var token_data = (jwt.decode(token, application.idpCertificate,"RS256"));
//}
var result="";
//try {
var iamService = new http(method = "GET", charset = "utf-8", url = #this.iam_url#, timeout="5");
iamService.addParam(type = "HEADER", name = "Accept", value = "application/json");
iamService.addParam(type = "HEADER", name = "Authorization", value = "#authorizationHeader#"); //passthrough
//writedump(this.iamServiceUrl);abort;
var resp = iamService.send();
//if (resp.status_code NEQ 200) throw("IDP response not OK");
//writedump(resp);abort;
var prefix = resp.getPrefix();
if (prefix.status_code NEQ 200) {
//writedump(this.iamServiceUrl);
//writedump(resp);
var iamStatusCode = (isValid("integer", prefix.status_code)) ? val(prefix.status_code) : 500;
throw(message="IAM error", detail="URL: #this.iam_url# Status Code: #prefix.status_code#");
//abort;
//throw("IDP response not OK");
}
result = prefix.filecontent;
var idpUserData=deserializeJson(result);
//request.auth.wz=idpUserData.userInfo.ClientId;//GUID!
request.auth.login = idpUserData.userInfo.login;
//request.usrUid=idpUserData.userInfo.contactId;//userId; //GUID!
//writedump(idpUserData);abort;
//} catch (e) {
/*if (fileExists("#GetDirectoryFromPath(GetCurrentTemplatePath())#/etc/local-debug")) { //true for local debug without IDP, etc/* does not go to repository
var result='{"accounts":[{"login":"","type":"telegram"}],"avatar":["d2d2b7ac-50af-432b-b7c4-f7d6561e288a"],"company":"ООО «НУБЕС»","companyId":"8ec70ac0-546d-42a7-8cff-339c8fb51a23","contactId":"983967a3-58c2-4cdd-84a5-8b427ccfac82","email":"smishchuk@nubes.ru","externalUser":false,"fio":{"fullName":"Мищук Сергей ","name":"Сергей","secondName":"","surname":"Мищук"},"groupIds":["94bf5be7-52f4-5c13-87c3-513786934685","ab12b6b8-0265-4683-a5c2-5e1a74a55216","aff008c3-7443-50f6-86e1-ecff3cd3b04d","d6000da0-c9aa-55eb-9882-f118b432730b","d89a33dc-3177-5854-9580-f7f860a5ab7c","ddfe2555-9ef4-42cb-9a2b-7f24e61e7747","df12926f-ecb8-5479-857f-6d291464baad","fda5c295-230a-5025-9797-b8b4e99e08aa","2be9b4b6-94d1-59f9-a649-cab228d82169","64fe6f5c-a91b-5fea-a7ca-d9823701ebd4"],"integration":{"serviceId":""},"login":"smishchuk@nubes.ru","mobilePhone":[],"position":"","userId":"d72530e1-66a4-412f-a046-38153c4e5405"}';}
*/
//writedump(resp);
//return representationOf( {"exception"=e} );
//rethrow(e);
//}
}
} else {
//точно мы так не словим дедлок ВПР
lock scope="session" type="readonly" timeout="3" {
if (structKeyExists(session, "auth")) {
request.auth = structCopy(session.auth);
}
}
if (structKeyExists(request, "auth") AND Now() < request.auth.exp) {
if (dateDiff("s", request.auth.iat, Now()) > request.auth.refresh_expires_in/1.5 AND CGI.REQUEST_METHOD EQ "GET") {
// доля времени, после которой происходит рефреш, прибита гвоздями
// По всей видимости, рефреш будет ломать обращения POSТ
// В связи с чем предлагается дождаться GET
// Приклеивать query_string как-то не пришлось, браузер сам справляется
// refresh
var idp = new lib.oauth2(this.client_id, this.client_secret, this.auth_endpoint, this.access_token_endpoint, this.redirect_uri);
var resp = idp.refreshAccessTokenRequest(request.auth.refresh_token);
lock scope="application" type="readonly" timeout="1" {
idpCertificate = application.idpCertificate;
}
var auth = parseIdpresponse(resp, idpCertificate, this.client_secret); //все эти сложности из-за опасений насчет многопоточности и блокировок
lock scope="session" type="exclusive" timeout="1" {
session.auth = auth;
}
request.auth = auth;
writeDump(auth);
}
} else {
// сессии SSO нет
// или токен протух
request.auth_state = createGUID().toString();
lock scope="session" type="exclusive" timeout="1" {
structDelete(session, "auth"); // зачистили сессию от данных SSO
session.auth_state = request.auth_state;
}
var idp = new lib.oauth2(this.client_id, this.client_secret, this.auth_endpoint, this.access_token_endpoint, this.redirect_uri);
/*
часто появляется ошибка: после неудачного запроса к IDP
в адресной строке браузера остается все, с чем его редиректил IDP,
а повторный запрос с этим контентом уже не работает. Получаем 400 Bad Request -
может быть, KeyCloak так защищается от циклических редиректов
Не очень понятно, как предотвратить повторный запрос (можно, конечно, использовать сессию, но странно)
*/
//writeOutput(getTickCount()-request.startTickCount);
if(structKeyExists(url, "code")) { //обрабатываем редирект от IDP *** наличие поля code в URL введет нас в заблуждение,
try {
//writeOutput("idp.makeAccessTokenRequest");
//writeOutput(getTickCount()-request.startTickCount);
var resp = idp.makeAccessTokenRequest(url.code);
lock scope="application" type="readonly" timeout="1" {
idpCertificate = application.idpCertificate;
}
var auth = parseIdpresponse(resp, idpCertificate, this.client_secret); //все эти сложности из-за опасений насчет многопоточности и блокировок
lock scope="session" type="exclusive" timeout="1" {
session.auth = auth;
}
//request.auth = auth;
location(this.redirect_uri, false);
} catch (e) {echo('<br>****************** #e.message# : #e.detail# **************');}
} else { //отправляем браузер к IDP
var strURL =idp.buildRedirectToAuthURL({"scope":'openid profile email',"state":request.auth_state,"allow_signup":false});
echo('<a href="#strURL#">self-made Auth link (idp) #strURL#</a> <br> <br>');
location(strUrl,false);
}
}
}
//request.logout_url = "";
if (structKeyExists(request, "auth") AND structKeyExists(request.auth, "id_token")) {
request.logout_url = "#this.logout_endpoint#?id_token_hint=#request.auth.id_token#&post_logout_redirect_uri=#this.redirect_uri#?logout";
// внимание, здесь подразумевается, что redirect_uri не содержит query_string
// после возвращения от IDP нам нужно будет зачистить сессию SSO, сделаем это по слову logout
}
request.auth.wz='WZ01348'; //*******************
</cfscript>
<cfif structKeyExists(request, "auth")>
<cfinclude template="#ARGUMENTS.template#"/>
<cfelse>
<cfset currentDir = (
lCase(
replace(
getDirectoryFromPath(
replaceNoCase(
expandPath(
ARGUMENTS.template
),
getDirectoryFromPath(
getCurrentTemplatePath()
),
""
)
), "\", "/", "ALL"
)
)
)/>
<!--- <cfif currentDir EQ "saml/"><!--- note traling slash --->
<cfinclude template="#ARGUMENTS.template#"/>
<cfelse>
<cfinclude template="login.cfm" />
</cfif> --->
</cfif>
<cfreturn />
</cffunction>
<cffunction
name="OnRequestEnd"
access="public"
returntype="void"
output="true"
hint="Fires after the page processing is complete.">
<!--- Attention! Before CF9, OnrequestEnd is not executed in case of redirect. That is why we use session.save_login --->
<cfif structKeyExists(session, "save_login")>
<cfif session.save_login EQ "">
<!--- unset (expire) cookie --->
<cfcookie expires="-1" name="portalUser" value="">
<cfelse>
<!--- set persistent cookie with no expiration --->
<cfcookie expires="NEVER" name="portalUser" value="#encrypt(session.save_login,COOKIEENCKEY)#">
</cfif>
<cfset structDelete(session, "save_login")>
</cfif>
<cfreturn />
</cffunction>
<cffunction
name="getStand"
access="private"
returntype="string"
output="true"><!--- *** duplicated in svc-api --->
<cftry>
<cfquery name=qConfig>
select value as stand from config
where name='STAND'
</cfquery>
<cfreturn qConfig.stand/>
<cfcatch type="ANY">
<!--- do nothing, default will be returned --->
</cfcatch>
</cftry>
<cfreturn ""/>
</cffunction>
<cffunction
name="getDS"
access="private"
returntype="void"
output="true"
hint="Configure data source from environment variables (if datasource with the name provided already exists, does nothing). Convention: data source name is an environment varialble prefix">
<cfargument name="dsname" type="string" required="true"/>
<cfargument name="prefix" type="string" default=#dsname#/>
<cftry>
<cfquery name="qTestDs" datasource=#arguments.dsname#>
select 1;
</cfquery>
<cfcatch type="any">
<cfset var ds={}/>
<cfset var system = createObject("java", "java.lang.System")/>
<cfloop list="class,connectionString,database,driver,dbdriver,host,port,type,url,username,password,bundleName,bundleVersion,connectionLimit,liveTimeout,validate" item="field"><!--- driver vs dbdriver --->
<cfset var value=system.getEnv("#arguments.prefix#_#field#")/>
<cfif isDefined("value") AND len(value)>
<cfset structInsert(ds,field,value)/>
</cfif>
</cfloop>
<cfif structIsEmpty(ds)>
<cfthrow type="application" message="Datasource not configured" detail="Datasource not defined in the environment. Expected prefix is #arguments.prefix#"/>
</cfif>
<!--- test datasource (just to get exception if invalid) --->
<cftry>
<cfquery name="qTestDsNextTry" datasource=#ds#>
select 2;
</cfquery>
<cfcatch type="any">
<!--- <cfdump var=#ds#/><cfabort/> --->
<cfdump var=#arguments#/>
<cfdump var=#ds#/>
<cfrethrow/>
</cfcatch>
</cftry>
<cfset this.datasources["#arguments.dsname#"]=#ds#/> <!--- Интересно, доступен ли здесь this --->
</cfcatch>
</cftry>
<!--- <cfdump var=#ds#/> --->
<cfreturn/>
</cffunction>
<cfscript>
//https://keycloak.nubes.ru/admin
//https://keycloak.nubes.ru/realms/SSH_CA/account //smishchuk@mgmt.nubes.ru
//https://stackoverflow.com/questions/28658735/what-are-keycloaks-oauth2-openid-connect-endpoints
//http://https://keycloak.nubes.ru/realms/SSH_CA/.well-known/openid-configuration
//https://keycloak.nubes.ru/realms/cloud/.well-known/openid-configuration тут ссылка
//https://keycloak.nubes.ru/realms/cloud/protocol/openid-connect/certs тут серт
//только надо его обрамить -----BEGIN CERTIFICATE-----
private function getIdpCertificate(certUrl) {
var httpService = new http();
httpService.setMethod( "get" );
httpService.setCharset( "utf-8" );
httpService.setUrl(arguments.certUrl);
var result = httpService.send().getPrefix();
var status_code = result.ResponseHeader['Status_Code'];
if ('200' == status_code) {
var content = deserializeJson(result.FileContent);
//мы подразумеваем структуру конкретно KeyCloak
var key = "";
for (key in content.keys) {
if ("RSA" == key.kty AND "sig" == key.use) {
return "-----BEGIN CERTIFICATE-----" & key.x5c[1] & "-----END CERTIFICATE-----";
}
}
throw(message="RSA cetificate not found");
} else {
throw (message="Cannot obtain IDP certificate, request failed", detail="Status_Code #status_code#");
}
}
private struct function parseIdpResponse(struct resp, string certificate, string jwt_secret) {
var auth = structNew("linked");
var data = deserializeJson(arguments.resp.content);
var jwt = new lib.jwt(arguments.jwt_secret);
auth.expires_in = data.expires_in;
auth.refresh_expires_in = data.refresh_expires_in;
auth.access_token = data.access_token;
auth.refresh_token = data.refresh_token;
auth.id_token = data.id_token;
auth.token_type = data.token_type;
//idp_response_content = structCopy(data); //for debug
var token_data = (jwt.decode(data.access_token, arguments.certificate,"RS256"));
auth.session_state = token_data.session_state;
auth.sid = token_data.sid;
auth.auth_time = token_data.auth_time;
auth.exp = token_data.exp;
auth.iat = token_data.iat;
auth.login = token_data.preferred_username;
auth.wz = token_data.ClientID;
auth.fullname = token_data.name;
auth.groups = token_data.groups;
//auth.token_data = structCopy(token_data); //for debug
return (auth);
}
</cfscript>
<cffunction name="rethrow" returntype="void">
<!--- https://www.raymondcamden.com/2004/03/09/3089633C-9FA0-606B-3F540AE9642A795F --->
<cftry>
<cfcatch>
<cfrethrow/>
</cfcatch>
</cftry>
<cfthrow type="Context validation error" message="RETHROW() called outside TRY-CATCH"/>
</cffunction>
</cfcomponent>
+6
View File
@@ -0,0 +1,6 @@
корректный период
другие WZ
? full join
детализация - для S3 это бакеты
@@ -0,0 +1,595 @@
база rpt сделана из базы billing (название изменено, чтобы избежать путаницы)
-- удалены последовательности, переназначены владельцы и права
-- удалены внешние ключи
-- создать роль dbo (владелец), без connect
-- создать пользователя nifi, с правом логина
-- создать пользователя appserver, с правом логина
-- дать права пользователю nifi на схемы и таблицы
-- дать права пользователю appserver на схемы и таблицы на чтение
SET ROLE postgres;
CREATE SCHEMA IF NOT EXISTS s3billing;
CREATE SCHEMA IF NOT EXISTS ngcloud_ru;
ALTER SCHEMA s3billing OWNER TO dbo;
ALTER SCHEMA ngcloud_ru OWNER TO dbo;
ALTER DATABASE rpt SET search_path TO s3billing, ngcloud_ru;
ALTER DEFAULT PRIVILEGES FOR ROLE dbo, postgres GRANT ALL ON TABLES TO nifi;
ALTER DEFAULT PRIVILEGES FOR ROLE dbo, postgres GRANT SELECT ON TABLES TO appserver;
SET ROLE dbo;
GRANT USAGE ON SCHEMA s3billing TO appserver,nifi;
GRANT USAGE ON SCHEMA ngcloud_ru TO appserver,nifi;
GRANT ALL ON ALL TABLES IN SCHEMA s3billing TO nifi;
GRANT SELECT ON ALL TABLES IN SCHEMA s3billing TO appserver;
GRANT ALL ON ALL TABLES IN SCHEMA ngcloud_ru TO nifi;
GRANT SELECT ON ALL TABLES IN SCHEMA ngcloud_ru TO appserver;
--GRANT ALL ON ALL SEQUENCES IN SCHEMA s3billing TO appserver,nifi;
--GRANT ALL ON ALL SEQUENCES IN SCHEMA ngcloud_ru TO appserver,nifi;
--ALTER DEFAULT PRIVILEGES FOR ROLE dbo GRANT ALL ON SEQUENCES TO appserver,nifi;
--
-- PostgreSQL database dump
--
-- Dumped from database version 16.0
-- Dumped by pg_dump version 16.4
-- Started on 2025-09-15 09:50:58
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
SELECT pg_catalog.set_config('search_path', '', false);
SET check_function_bodies = false;
SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
SET default_tablespace = '';
SET default_table_access_method = heap;
--
-- TOC entry 230 (class 1259 OID 2160668)
-- Name: bucket_info; Type: TABLE; Schema: s3billing; Owner: s3billing
--
DROP TABLE IF EXISTS s3billing.bucket_info;
CREATE TABLE s3billing.bucket_info (
id integer NOT NULL,
bucket_id character varying(255) NOT NULL,
name character varying(255) NOT NULL,
marker character varying(255) NOT NULL,
placement_id bigint DEFAULT 0 NOT NULL
);
--
-- TOC entry 226 (class 1259 OID 2160642)
-- Name: bucket_stat; Type: TABLE; Schema: s3billing; Owner: s3billing
--
DROP TABLE IF EXISTS s3billing.bucket_stat;
CREATE TABLE s3billing.bucket_stat (
bucket_id bigint,
num_shards bigint NOT NULL,
tenant character varying(255) DEFAULT NULL::character varying,
zone_group bigint NOT NULL,
placement_id bigint NOT NULL,
explicit_placement_data_pool character varying(255) DEFAULT NULL::character varying,
explicit_placement_data_extra_pool character varying(255) DEFAULT NULL::character varying,
explicit_placement_index_pool character varying(255) DEFAULT NULL::character varying,
index_type_id bigint NOT NULL,
versioned boolean DEFAULT false,
versioning_enabled boolean DEFAULT false,
object_lock_enabled boolean DEFAULT false,
mfa_enabled boolean DEFAULT false,
owner character varying(255) NOT NULL,
bucket_quota_enabled boolean DEFAULT false,
bucket_quota_check_on_raw boolean DEFAULT false,
bucket_quota_max_size bigint NOT NULL,
bucket_quota_max_size_kb bigint NOT NULL,
bucket_quota_max_objects bigint NOT NULL,
usage_rgw_main_size bigint NOT NULL,
usage_rgw_main_size_actual bigint NOT NULL,
usage_rgw_main_size_utilized bigint NOT NULL,
usage_rgw_main_size_kb bigint NOT NULL,
usage_rgw_main_size_kb_actual bigint NOT NULL,
usage_rgw_main_size_kb_utilized bigint NOT NULL,
usage_rgw_main_num_objects bigint NOT NULL,
usage_rgw_multimeta_size bigint,
usage_rgw_multimeta_size_actual bigint,
usage_rgw_multimeta_size_utilized bigint,
usage_rgw_multimeta_size_kb bigint,
usage_rgw_multimeta_size_kb_actual bigint,
usage_rgw_multimeta_size_kb_utilized bigint,
usage_rgw_multimeta_num_objects bigint,
creation_time timestamp without time zone NOT NULL,
mtime timestamp without time zone NOT NULL,
timestamp_addition timestamp without time zone DEFAULT date_trunc('hour'::text, CURRENT_TIMESTAMP)
);
--
-- TOC entry 245 (class 1259 OID 2160763)
-- Name: category; Type: TABLE; Schema: s3billing; Owner: s3billing
--
DROP TABLE IF EXISTS s3billing.category;
CREATE TABLE s3billing.category (
id integer NOT NULL,
name character varying(255) NOT NULL
);
--
-- TOC entry 225 (class 1259 OID 2160635)
-- Name: goose_db_version; Type: TABLE; Schema: s3billing; Owner: s3billing
--
DROP TABLE IF EXISTS s3billing.goose_db_version;
CREATE TABLE s3billing.goose_db_version (
id integer NOT NULL,
version_id bigint NOT NULL,
is_applied boolean NOT NULL,
tstamp timestamp without time zone DEFAULT now() NOT NULL
);
--
-- TOC entry 234 (class 1259 OID 2160684)
-- Name: index_type; Type: TABLE; Schema: s3billing; Owner: s3billing
--
DROP TABLE IF EXISTS index_type;
CREATE TABLE s3billing.index_type (
id integer NOT NULL,
name character varying(255) NOT NULL
);
--
-- TOC entry 232 (class 1259 OID 2160677)
-- Name: placement; Type: TABLE; Schema: s3billing; Owner: s3billing
--
DROP TABLE IF EXISTS s3billing.placement;
CREATE TABLE s3billing.placement (
id integer NOT NULL,
name character varying(255) NOT NULL
);
--
-- TOC entry 239 (class 1259 OID 2160705)
-- Name: usage_bucket_by_user; Type: TABLE; Schema: s3billing; Owner: s3billing
--
DROP TABLE IF EXISTS s3billing.usage_bucket_by_user;
CREATE TABLE s3billing.usage_bucket_by_user (
bucketid bigint,
"time" timestamp without time zone NOT NULL,
bytes_sent bigint NOT NULL,
bytes_received bigint NOT NULL,
ops bigint NOT NULL,
successful_ops bigint NOT NULL,
date_start timestamp without time zone NOT NULL,
date_end timestamp without time zone NOT NULL,
user_id bigint NOT NULL,
category_id bigint NOT NULL
);
--
-- TOC entry 238 (class 1259 OID 2160698)
-- Name: usage_stat_summary; Type: TABLE; Schema: s3billing; Owner: s3billing
--
DROP TABLE IF EXISTS s3billing.usage_stat_summary;
CREATE TABLE s3billing.usage_stat_summary (
id integer NOT NULL,
userid character varying(255) NOT NULL,
category character varying(255) NOT NULL,
bytes_sent bigint NOT NULL,
bytes_received bigint NOT NULL,
ops bigint NOT NULL,
successful_ops bigint NOT NULL,
date_start timestamp without time zone NOT NULL,
date_end timestamp without time zone NOT NULL
);
--
-- TOC entry 236 (class 1259 OID 2160691)
-- Name: usage_stat_total; Type: TABLE; Schema: s3billing; Owner: s3billing
--
DROP TABLE IF EXISTS s3billing.usage_stat_total;
CREATE TABLE s3billing.usage_stat_total (
id integer NOT NULL,
userid character varying(255) NOT NULL,
bytes_sent bigint NOT NULL,
bytes_received bigint NOT NULL,
ops bigint NOT NULL,
successful_ops bigint NOT NULL,
date_start timestamp without time zone NOT NULL,
date_end timestamp without time zone NOT NULL
);
--
-- TOC entry 243 (class 1259 OID 2160751)
-- Name: user_info; Type: TABLE; Schema: s3billing; Owner: s3billing
--
DROP TABLE IF EXISTS s3billing.user_info;
CREATE TABLE s3billing.user_info (
id integer NOT NULL,
name character varying(255) NOT NULL,
owner character varying(255) NOT NULL
);
--
-- TOC entry 241 (class 1259 OID 2160712)
-- Name: user_quota_bucket; Type: TABLE; Schema: s3billing; Owner: s3billing
--
DROP TABLE IF EXISTS s3billing.user_quota_bucket;
CREATE TABLE s3billing.user_quota_bucket (
id integer NOT NULL,
userid character varying(255) NOT NULL,
enabled boolean DEFAULT false,
check_on_raw boolean DEFAULT false,
max_size bigint NOT NULL,
max_size_kb bigint NOT NULL,
max_objects bigint NOT NULL
);
--
-- TOC entry 228 (class 1259 OID 2160661)
-- Name: zone_group; Type: TABLE; Schema: s3billing; Owner: s3billing
--
DROP TABLE IF EXISTS s3billing.zone_group;
CREATE TABLE s3billing.zone_group (
id integer NOT NULL,
name uuid NOT NULL
);
--
-- TOC entry 3278 (class 2606 OID 2160675)
-- Name: bucket_info bucket_info_pkey; Type: CONSTRAINT; Schema: s3billing; Owner: s3billing
--
ALTER TABLE ONLY s3billing.bucket_info
ADD CONSTRAINT bucket_info_pkey PRIMARY KEY (id);
--
-- TOC entry 3273 (class 2606 OID 2476484)
-- Name: bucket_stat bucket_stat_unique; Type: CONSTRAINT; Schema: s3billing; Owner: s3billing
--
--ALTER TABLE ONLY s3billing.bucket_stat
-- ADD CONSTRAINT bucket_stat_unique UNIQUE (bucket_id, placement_id, timestamp_addition);
ALTER TABLE ONLY s3billing.bucket_stat
ADD CONSTRAINT bucket_stat_pkey PRIMARY KEY (bucket_id, placement_id, timestamp_addition);
--
-- TOC entry 3298 (class 2606 OID 2160768)
-- Name: category category_pkey; Type: CONSTRAINT; Schema: s3billing; Owner: s3billing
--
ALTER TABLE ONLY s3billing.category
ADD CONSTRAINT category_pkey PRIMARY KEY (id);
--
-- TOC entry 3270 (class 2606 OID 2160640)
-- Name: goose_db_version goose_db_version_pkey; Type: CONSTRAINT; Schema: s3billing; Owner: s3billing
--
ALTER TABLE ONLY s3billing.goose_db_version
ADD CONSTRAINT goose_db_version_pkey PRIMARY KEY (id);
--
-- TOC entry 3284 (class 2606 OID 2160689)
-- Name: index_type index_type_pkey; Type: CONSTRAINT; Schema: s3billing; Owner: s3billing
--
ALTER TABLE ONLY s3billing.index_type
ADD CONSTRAINT index_type_pkey PRIMARY KEY (id);
--
-- TOC entry 3282 (class 2606 OID 2160682)
-- Name: placement placement_pkey; Type: CONSTRAINT; Schema: s3billing; Owner: s3billing
--
ALTER TABLE ONLY s3billing.placement
ADD CONSTRAINT placement_pkey PRIMARY KEY (id);
--
-- TOC entry 3290 (class 2606 OID 2476705)
-- Name: usage_bucket_by_user usage_bucket_by_user_unique; Type: CONSTRAINT; Schema: s3billing; Owner: s3billing
--
--ALTER TABLE ONLY s3billing.usage_bucket_by_user
-- ADD CONSTRAINT usage_bucket_by_user_unique UNIQUE (bucketid, date_start, date_end, user_id, category_id);
ALTER TABLE ONLY s3billing.usage_bucket_by_user
ADD CONSTRAINT usage_bucket_by_user_pkey PRIMARY KEY (bucketid, date_start, date_end, user_id, category_id);
--
-- TOC entry 3288 (class 2606 OID 2160703)
-- Name: usage_stat_summary usage_stat_summary_pkey; Type: CONSTRAINT; Schema: s3billing; Owner: s3billing
--
ALTER TABLE ONLY s3billing.usage_stat_summary
ADD CONSTRAINT usage_stat_summary_pkey PRIMARY KEY (id);
--
-- TOC entry 3286 (class 2606 OID 2160696)
-- Name: usage_stat_total usage_stat_total_pkey; Type: CONSTRAINT; Schema: s3billing; Owner: s3billing
--
ALTER TABLE ONLY s3billing.usage_stat_total
ADD CONSTRAINT usage_stat_total_pkey PRIMARY KEY (id);
--
-- TOC entry 3295 (class 2606 OID 2160758)
-- Name: user_info user_info_pkey; Type: CONSTRAINT; Schema: s3billing; Owner: s3billing
--
ALTER TABLE ONLY s3billing.user_info
ADD CONSTRAINT user_info_pkey PRIMARY KEY (id);
--
-- TOC entry 3292 (class 2606 OID 2160719)
-- Name: user_quota_bucket user_quota_bucket_pkey; Type: CONSTRAINT; Schema: s3billing; Owner: s3billing
--
ALTER TABLE ONLY s3billing.user_quota_bucket
ADD CONSTRAINT user_quota_bucket_pkey PRIMARY KEY (id);
--
-- TOC entry 3275 (class 2606 OID 2160666)
-- Name: zone_group zone_group_pkey; Type: CONSTRAINT; Schema: s3billing; Owner: s3billing
--
ALTER TABLE ONLY s3billing.zone_group
ADD CONSTRAINT zone_group_pkey PRIMARY KEY (id);
--
-- TOC entry 3276 (class 1259 OID 2474077)
-- Name: bucket_info_name_idx; Type: INDEX; Schema: s3billing; Owner: s3billing
--
CREATE INDEX bucket_info_name_idx ON s3billing.bucket_info USING btree (name);
--
-- TOC entry 3279 (class 1259 OID 2474078)
-- Name: bucket_info_placement_id_idx; Type: INDEX; Schema: s3billing; Owner: s3billing
--
CREATE INDEX bucket_info_placement_id_idx ON s3billing.bucket_info USING btree (placement_id);
--
-- TOC entry 3271 (class 1259 OID 2474094)
-- Name: bucket_stat_owner_idx; Type: INDEX; Schema: s3billing; Owner: s3billing
--
CREATE INDEX bucket_stat_owner_idx ON s3billing.bucket_stat USING btree (owner);
--
-- TOC entry 3296 (class 1259 OID 2474445)
-- Name: category_name_idx; Type: INDEX; Schema: s3billing; Owner: s3billing
--
CREATE UNIQUE INDEX category_name_idx ON s3billing.category USING btree (name);
--
-- TOC entry 3280 (class 1259 OID 2474468)
-- Name: placement_name_idx; Type: INDEX; Schema: s3billing; Owner: s3billing
--
CREATE UNIQUE INDEX placement_name_idx ON s3billing.placement USING btree (name);
--
-- TOC entry 3293 (class 1259 OID 2474438)
-- Name: user_info_name_idx; Type: INDEX; Schema: s3billing; Owner: s3billing
--
CREATE INDEX user_info_name_idx ON s3billing.user_info USING btree (name);
-- Completed on 2025-09-15 09:50:59
--
-- PostgreSQL database dump complete
--
-------------------------------------------------------------------------------------
-------------------------------------------------------------------------------------
-------------------------------------------------------------------------------------
DROP TABLE IF EXISTS ngcloud_ru.capacity_resource;
CREATE TABLE ngcloud_ru.capacity_resource (
vdc_id uuid NOT NULL,
"timestamp" timestamp with time zone NOT NULL,
cpu_allocated integer NOT NULL,
cpu_limit integer NOT NULL,
cpu_reserved integer NOT NULL,
cpu_used integer NOT NULL,
cpu_speed integer NOT NULL,
mem_allocated integer NOT NULL,
mem_limit integer NOT NULL,
mem_reserved integer NOT NULL,
mem_used integer NOT NULL
);
--
-- TOC entry 223 (class 1259 OID 1893993)
-- Name: capacity_storage; Type: TABLE; Schema: ngcloud_ru; Owner: vc_billing
--
DROP TABLE IF EXISTS ngcloud_ru.capacity_storage;
CREATE TABLE ngcloud_ru.capacity_storage (
vdc_id uuid NOT NULL,
storage_profile_types_id integer NOT NULL,
"timestamp" timestamp with time zone NOT NULL,
id uuid NOT NULL,
"limit" integer NOT NULL,
used integer NOT NULL
);
--
-- TOC entry 220 (class 1259 OID 1893965)
-- Name: storage_profile_types; Type: TABLE; Schema: ngcloud_ru; Owner: vc_billing
--
DROP TABLE IF EXISTS ngcloud_ru.storage_profile_types;
CREATE TABLE ngcloud_ru.storage_profile_types (
id integer NOT NULL,
name character varying(255) NOT NULL
);
--
-- TOC entry 218 (class 1259 OID 1893959)
-- Name: tenant; Type: TABLE; Schema: ngcloud_ru; Owner: vc_billing
--
DROP TABLE IF EXISTS ngcloud_ru.tenant;
CREATE TABLE ngcloud_ru.tenant (
id uuid NOT NULL,
name character varying(255) NOT NULL,
wzcode bigint
);
--
-- TOC entry 221 (class 1259 OID 1893973)
-- Name: vdc; Type: TABLE; Schema: ngcloud_ru; Owner: vc_billing
--
DROP TABLE IF EXISTS ngcloud_ru.vdc;
CREATE TABLE ngcloud_ru.vdc (
id uuid NOT NULL,
tenant_id uuid NOT NULL,
name character varying(255) NOT NULL
);
--
-- TOC entry 3255 (class 2606 OID 1893987)
-- Name: capacity_resource capacity_resource_pkey; Type: CONSTRAINT; Schema: ngcloud_ru; Owner: vc_billing
--
ALTER TABLE ONLY ngcloud_ru.capacity_resource
ADD CONSTRAINT capacity_resource_pkey PRIMARY KEY (vdc_id, "timestamp");
--
-- TOC entry 3257 (class 2606 OID 1893997)
-- Name: capacity_storage capacity_storage_pkey; Type: CONSTRAINT; Schema: ngcloud_ru; Owner: vc_billing
--
ALTER TABLE ONLY ngcloud_ru.capacity_storage
ADD CONSTRAINT capacity_storage_pkey PRIMARY KEY (vdc_id, id, "timestamp");
--
-- TOC entry 3249 (class 2606 OID 1893972)
-- Name: storage_profile_types storage_profile_types_name_key; Type: CONSTRAINT; Schema: ngcloud_ru; Owner: vc_billing
--
ALTER TABLE ONLY ngcloud_ru.storage_profile_types
ADD CONSTRAINT storage_profile_types_name_key UNIQUE (name);
--
-- TOC entry 3251 (class 2606 OID 1893970)
-- Name: storage_profile_types storage_profile_types_pkey; Type: CONSTRAINT; Schema: ngcloud_ru; Owner: vc_billing
--
ALTER TABLE ONLY ngcloud_ru.storage_profile_types
ADD CONSTRAINT storage_profile_types_pkey PRIMARY KEY (id);
--
-- TOC entry 3247 (class 2606 OID 1893963)
-- Name: tenant tenant_pkey; Type: CONSTRAINT; Schema: ngcloud_ru; Owner: vc_billing
--
ALTER TABLE ONLY ngcloud_ru.tenant
ADD CONSTRAINT tenant_pkey PRIMARY KEY (id);
--
-- TOC entry 3253 (class 2606 OID 1893977)
-- Name: vdc vdc_pkey; Type: CONSTRAINT; Schema: ngcloud_ru; Owner: vc_billing
--
ALTER TABLE ONLY ngcloud_ru.vdc
ADD CONSTRAINT vdc_pkey PRIMARY KEY (id);
--
-- TOC entry 3259 (class 2606 OID 1893988)
-- Name: capacity_resource capacity_resource_vdc_id_fkey; Type: FK CONSTRAINT; Schema: ngcloud_ru; Owner: vc_billing
--
--ALTER TABLE ONLY ngcloud_ru.capacity_resource
-- ADD CONSTRAINT capacity_resource_vdc_id_fkey FOREIGN KEY (vdc_id) REFERENCES ngcloud_ru.vdc(id) ON DELETE CASCADE;
--
-- TOC entry 3260 (class 2606 OID 1894003)
-- Name: capacity_storage capacity_storage_storage_profile_types_id_fkey; Type: FK CONSTRAINT; Schema: ngcloud_ru; Owner: vc_billing
--
--ALTER TABLE ONLY ngcloud_ru.capacity_storage
-- ADD CONSTRAINT capacity_storage_storage_profile_types_id_fkey FOREIGN KEY (storage_profile_types_id) REFERENCES ngcloud_ru.storage_profile_types(id);
--
-- TOC entry 3261 (class 2606 OID 1893998)
-- Name: capacity_storage capacity_storage_vdc_id_fkey; Type: FK CONSTRAINT; Schema: ngcloud_ru; Owner: vc_billing
--
--ALTER TABLE ONLY ngcloud_ru.capacity_storage
-- ADD CONSTRAINT capacity_storage_vdc_id_fkey FOREIGN KEY (vdc_id) REFERENCES ngcloud_ru.vdc(id) ON DELETE CASCADE;
--
-- TOC entry 3258 (class 2606 OID 1893978)
-- Name: vdc vdc_tenant_id_fkey; Type: FK CONSTRAINT; Schema: ngcloud_ru; Owner: vc_billing
--
--ALTER TABLE ONLY ngcloud_ru.vdc
-- ADD CONSTRAINT vdc_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES ngcloud_ru.tenant(id);
-- Completed on 2025-09-15 09:53:18
--
-- PostgreSQL database dump complete
--
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,72 @@
set role postgres;
--
CREATE SCHEMA IF NOT EXISTS gpu;
--ALTER SCHEMA gpu OWNER TO dbo;
GRANT USAGE ON SCHEMA gpu TO nifi;
GRANT ALL ON ALL TABLES IN SCHEMA gpu TO nifi;
GRANT ALL ON ALL SEQUENCES IN SCHEMA gpu TO nifi;
--ALTER DEFAULT PRIVILEGES IN SCHEMA gpu GRANT ALL ON TABLES TO nifi;
--ALTER DEFAULT PRIVILEGES IN SCHEMA gpu GRANT ALL ON SEQUENCES TO nifi;
ALTER DEFAULT PRIVILEGES FOR ROLE dbo, postgres IN SCHEMA gpu GRANT ALL ON TABLES TO nifi;
ALTER DEFAULT PRIVILEGES FOR ROLE dbo, postgres IN SCHEMA gpu GRANT ALL ON SEQUENCES TO nifi;
--SET ROLE dbo;
DROP TABLE IF EXISTS gpu.vm;
CREATE TABLE gpu.vm (
objUUID uuid NOT NULL PRIMARY KEY
,groupUUID uuid NULL
,objId varchar(255) NULL
,ClientID varchar(255) NULL -- WZ
,VCD varchar(255) NULL
,Org varchar(255) NULL
,OrgVDC varchar(255) NULL
,vCenter varchar(255) NULL
,GPU varchar(255) NULL
,dt_load timestamptz NOT NULL
);
ALTER TABLE gpu.vm OWNER TO dbo;
/*
{
"type": "record",
"name": "gpu.vm",
"namespace": "org.apache.nifi",
"fields": [
{"name":"objUUID", "type":{"type": "string", "logicalType": "uuid"}},
{"name":"groupUUID", "type":[{"type": "string", "logicalType": "uuid"} ,"null"]},
{"name":"objId", "type":["string" ,"null"]},
{"name":"ClientID", "type":["int" ,"null"]},
{"name":"VCD", "type":["string" ,"null"]},
{"name":"Org", "type":["string" ,"null"]},
{"name":"OrgVDC", "type":["string" ,"null"]},
{"name":"vCenter", "type":["string" ,"null"]},
{"name":"GPU", "type":["string" ,"null"]},
{"name":"VCD", "type":["string" ,"null"]},
{"name":"dt_load","type":{"type": "long", "logicalType": "local-timestamp-millis"}}
]
}
*/
DROP TABLE IF EXISTS gpu.vm_power_state;
CREATE TABLE gpu.vm_power_state (
objId varchar(255) NULL
,ts timestamptz NOT NULL -- без таймштампа загрузки
,value int NULL
,CONSTRAINT PK_vm_power_state PRIMARY KEY(objId, ts)
);
ALTER TABLE gpu.vm_power_state OWNER TO dbo;
/*
{
"type": "record",
"name": "gpu.vm_power_state,
"namespace": "org.apache.nifi",
"fields": [
{"name":"objId", "type":["string" ,"null"]},
{"name":"ts","type":{"type": "long", "logicalType": "local-timestamp-millis"}},
{"name":"value", "type":["int" ,"null"]}
]
}
*/
@@ -0,0 +1,828 @@
--
-- PostgreSQL database dump
--
-- Dumped from database version 16.0
-- Dumped by pg_dump version 16.4
-- Started on 2025-09-15 09:50:58
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
SELECT pg_catalog.set_config('search_path', '', false);
SET check_function_bodies = false;
SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
SET default_tablespace = '';
SET default_table_access_method = heap;
--
-- TOC entry 230 (class 1259 OID 2160668)
-- Name: bucket_info; Type: TABLE; Schema: s3billing; Owner: s3billing
--
CREATE TABLE s3billing.bucket_info (
id integer NOT NULL,
bucket_id character varying(255) NOT NULL,
name character varying(255) NOT NULL,
marker character varying(255) NOT NULL,
placement_id bigint DEFAULT 0 NOT NULL
);
ALTER TABLE s3billing.bucket_info OWNER TO s3billing;
--
-- TOC entry 229 (class 1259 OID 2160667)
-- Name: bucket_info_id_seq; Type: SEQUENCE; Schema: s3billing; Owner: s3billing
--
CREATE SEQUENCE s3billing.bucket_info_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE s3billing.bucket_info_id_seq OWNER TO s3billing;
--
-- TOC entry 3448 (class 0 OID 0)
-- Dependencies: 229
-- Name: bucket_info_id_seq; Type: SEQUENCE OWNED BY; Schema: s3billing; Owner: s3billing
--
ALTER SEQUENCE s3billing.bucket_info_id_seq OWNED BY s3billing.bucket_info.id;
--
-- TOC entry 226 (class 1259 OID 2160642)
-- Name: bucket_stat; Type: TABLE; Schema: s3billing; Owner: s3billing
--
CREATE TABLE s3billing.bucket_stat (
bucket_id bigint,
num_shards bigint NOT NULL,
tenant character varying(255) DEFAULT NULL::character varying,
zone_group bigint NOT NULL,
placement_id bigint NOT NULL,
explicit_placement_data_pool character varying(255) DEFAULT NULL::character varying,
explicit_placement_data_extra_pool character varying(255) DEFAULT NULL::character varying,
explicit_placement_index_pool character varying(255) DEFAULT NULL::character varying,
index_type_id bigint NOT NULL,
versioned boolean DEFAULT false,
versioning_enabled boolean DEFAULT false,
object_lock_enabled boolean DEFAULT false,
mfa_enabled boolean DEFAULT false,
owner character varying(255) NOT NULL,
bucket_quota_enabled boolean DEFAULT false,
bucket_quota_check_on_raw boolean DEFAULT false,
bucket_quota_max_size bigint NOT NULL,
bucket_quota_max_size_kb bigint NOT NULL,
bucket_quota_max_objects bigint NOT NULL,
usage_rgw_main_size bigint NOT NULL,
usage_rgw_main_size_actual bigint NOT NULL,
usage_rgw_main_size_utilized bigint NOT NULL,
usage_rgw_main_size_kb bigint NOT NULL,
usage_rgw_main_size_kb_actual bigint NOT NULL,
usage_rgw_main_size_kb_utilized bigint NOT NULL,
usage_rgw_main_num_objects bigint NOT NULL,
usage_rgw_multimeta_size bigint,
usage_rgw_multimeta_size_actual bigint,
usage_rgw_multimeta_size_utilized bigint,
usage_rgw_multimeta_size_kb bigint,
usage_rgw_multimeta_size_kb_actual bigint,
usage_rgw_multimeta_size_kb_utilized bigint,
usage_rgw_multimeta_num_objects bigint,
creation_time timestamp without time zone NOT NULL,
mtime timestamp without time zone NOT NULL,
timestamp_addition timestamp without time zone DEFAULT date_trunc('hour'::text, CURRENT_TIMESTAMP)
);
ALTER TABLE s3billing.bucket_stat OWNER TO s3billing;
--
-- TOC entry 245 (class 1259 OID 2160763)
-- Name: category; Type: TABLE; Schema: s3billing; Owner: s3billing
--
CREATE TABLE s3billing.category (
id integer NOT NULL,
name character varying(255) NOT NULL
);
ALTER TABLE s3billing.category OWNER TO s3billing;
--
-- TOC entry 244 (class 1259 OID 2160762)
-- Name: category_id_seq; Type: SEQUENCE; Schema: s3billing; Owner: s3billing
--
CREATE SEQUENCE s3billing.category_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE s3billing.category_id_seq OWNER TO s3billing;
--
-- TOC entry 3451 (class 0 OID 0)
-- Dependencies: 244
-- Name: category_id_seq; Type: SEQUENCE OWNED BY; Schema: s3billing; Owner: s3billing
--
ALTER SEQUENCE s3billing.category_id_seq OWNED BY s3billing.category.id;
--
-- TOC entry 225 (class 1259 OID 2160635)
-- Name: goose_db_version; Type: TABLE; Schema: s3billing; Owner: s3billing
--
CREATE TABLE s3billing.goose_db_version (
id integer NOT NULL,
version_id bigint NOT NULL,
is_applied boolean NOT NULL,
tstamp timestamp without time zone DEFAULT now() NOT NULL
);
ALTER TABLE s3billing.goose_db_version OWNER TO s3billing;
--
-- TOC entry 224 (class 1259 OID 2160634)
-- Name: goose_db_version_id_seq; Type: SEQUENCE; Schema: s3billing; Owner: s3billing
--
ALTER TABLE s3billing.goose_db_version ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
SEQUENCE NAME s3billing.goose_db_version_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1
);
--
-- TOC entry 234 (class 1259 OID 2160684)
-- Name: index_type; Type: TABLE; Schema: s3billing; Owner: s3billing
--
CREATE TABLE s3billing.index_type (
id integer NOT NULL,
name character varying(255) NOT NULL
);
ALTER TABLE s3billing.index_type OWNER TO s3billing;
--
-- TOC entry 233 (class 1259 OID 2160683)
-- Name: index_type_id_seq; Type: SEQUENCE; Schema: s3billing; Owner: s3billing
--
CREATE SEQUENCE s3billing.index_type_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE s3billing.index_type_id_seq OWNER TO s3billing;
--
-- TOC entry 3454 (class 0 OID 0)
-- Dependencies: 233
-- Name: index_type_id_seq; Type: SEQUENCE OWNED BY; Schema: s3billing; Owner: s3billing
--
ALTER SEQUENCE s3billing.index_type_id_seq OWNED BY s3billing.index_type.id;
--
-- TOC entry 232 (class 1259 OID 2160677)
-- Name: placement; Type: TABLE; Schema: s3billing; Owner: s3billing
--
CREATE TABLE s3billing.placement (
id integer NOT NULL,
name character varying(255) NOT NULL
);
ALTER TABLE s3billing.placement OWNER TO s3billing;
--
-- TOC entry 231 (class 1259 OID 2160676)
-- Name: placement_id_seq; Type: SEQUENCE; Schema: s3billing; Owner: s3billing
--
CREATE SEQUENCE s3billing.placement_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE s3billing.placement_id_seq OWNER TO s3billing;
--
-- TOC entry 3456 (class 0 OID 0)
-- Dependencies: 231
-- Name: placement_id_seq; Type: SEQUENCE OWNED BY; Schema: s3billing; Owner: s3billing
--
ALTER SEQUENCE s3billing.placement_id_seq OWNED BY s3billing.placement.id;
--
-- TOC entry 239 (class 1259 OID 2160705)
-- Name: usage_bucket_by_user; Type: TABLE; Schema: s3billing; Owner: s3billing
--
CREATE TABLE s3billing.usage_bucket_by_user (
bucketid bigint,
"time" timestamp without time zone NOT NULL,
bytes_sent bigint NOT NULL,
bytes_received bigint NOT NULL,
ops bigint NOT NULL,
successful_ops bigint NOT NULL,
date_start timestamp without time zone NOT NULL,
date_end timestamp without time zone NOT NULL,
user_id bigint NOT NULL,
category_id bigint NOT NULL
);
ALTER TABLE s3billing.usage_bucket_by_user OWNER TO s3billing;
--
-- TOC entry 238 (class 1259 OID 2160698)
-- Name: usage_stat_summary; Type: TABLE; Schema: s3billing; Owner: s3billing
--
CREATE TABLE s3billing.usage_stat_summary (
id integer NOT NULL,
userid character varying(255) NOT NULL,
category character varying(255) NOT NULL,
bytes_sent bigint NOT NULL,
bytes_received bigint NOT NULL,
ops bigint NOT NULL,
successful_ops bigint NOT NULL,
date_start timestamp without time zone NOT NULL,
date_end timestamp without time zone NOT NULL
);
ALTER TABLE s3billing.usage_stat_summary OWNER TO s3billing;
--
-- TOC entry 237 (class 1259 OID 2160697)
-- Name: usage_stat_summary_id_seq; Type: SEQUENCE; Schema: s3billing; Owner: s3billing
--
CREATE SEQUENCE s3billing.usage_stat_summary_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE s3billing.usage_stat_summary_id_seq OWNER TO s3billing;
--
-- TOC entry 3459 (class 0 OID 0)
-- Dependencies: 237
-- Name: usage_stat_summary_id_seq; Type: SEQUENCE OWNED BY; Schema: s3billing; Owner: s3billing
--
ALTER SEQUENCE s3billing.usage_stat_summary_id_seq OWNED BY s3billing.usage_stat_summary.id;
--
-- TOC entry 236 (class 1259 OID 2160691)
-- Name: usage_stat_total; Type: TABLE; Schema: s3billing; Owner: s3billing
--
CREATE TABLE s3billing.usage_stat_total (
id integer NOT NULL,
userid character varying(255) NOT NULL,
bytes_sent bigint NOT NULL,
bytes_received bigint NOT NULL,
ops bigint NOT NULL,
successful_ops bigint NOT NULL,
date_start timestamp without time zone NOT NULL,
date_end timestamp without time zone NOT NULL
);
ALTER TABLE s3billing.usage_stat_total OWNER TO s3billing;
--
-- TOC entry 235 (class 1259 OID 2160690)
-- Name: usage_stat_total_id_seq; Type: SEQUENCE; Schema: s3billing; Owner: s3billing
--
CREATE SEQUENCE s3billing.usage_stat_total_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE s3billing.usage_stat_total_id_seq OWNER TO s3billing;
--
-- TOC entry 3461 (class 0 OID 0)
-- Dependencies: 235
-- Name: usage_stat_total_id_seq; Type: SEQUENCE OWNED BY; Schema: s3billing; Owner: s3billing
--
ALTER SEQUENCE s3billing.usage_stat_total_id_seq OWNED BY s3billing.usage_stat_total.id;
--
-- TOC entry 243 (class 1259 OID 2160751)
-- Name: user_info; Type: TABLE; Schema: s3billing; Owner: s3billing
--
CREATE TABLE s3billing.user_info (
id integer NOT NULL,
name character varying(255) NOT NULL,
owner character varying(255) NOT NULL
);
ALTER TABLE s3billing.user_info OWNER TO s3billing;
--
-- TOC entry 242 (class 1259 OID 2160750)
-- Name: user_info_id_seq; Type: SEQUENCE; Schema: s3billing; Owner: s3billing
--
CREATE SEQUENCE s3billing.user_info_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE s3billing.user_info_id_seq OWNER TO s3billing;
--
-- TOC entry 3463 (class 0 OID 0)
-- Dependencies: 242
-- Name: user_info_id_seq; Type: SEQUENCE OWNED BY; Schema: s3billing; Owner: s3billing
--
ALTER SEQUENCE s3billing.user_info_id_seq OWNED BY s3billing.user_info.id;
--
-- TOC entry 241 (class 1259 OID 2160712)
-- Name: user_quota_bucket; Type: TABLE; Schema: s3billing; Owner: s3billing
--
CREATE TABLE s3billing.user_quota_bucket (
id integer NOT NULL,
userid character varying(255) NOT NULL,
enabled boolean DEFAULT false,
check_on_raw boolean DEFAULT false,
max_size bigint NOT NULL,
max_size_kb bigint NOT NULL,
max_objects bigint NOT NULL
);
ALTER TABLE s3billing.user_quota_bucket OWNER TO s3billing;
--
-- TOC entry 240 (class 1259 OID 2160711)
-- Name: user_quota_bucket_id_seq; Type: SEQUENCE; Schema: s3billing; Owner: s3billing
--
CREATE SEQUENCE s3billing.user_quota_bucket_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE s3billing.user_quota_bucket_id_seq OWNER TO s3billing;
--
-- TOC entry 3465 (class 0 OID 0)
-- Dependencies: 240
-- Name: user_quota_bucket_id_seq; Type: SEQUENCE OWNED BY; Schema: s3billing; Owner: s3billing
--
ALTER SEQUENCE s3billing.user_quota_bucket_id_seq OWNED BY s3billing.user_quota_bucket.id;
--
-- TOC entry 228 (class 1259 OID 2160661)
-- Name: zone_group; Type: TABLE; Schema: s3billing; Owner: s3billing
--
CREATE TABLE s3billing.zone_group (
id integer NOT NULL,
name uuid NOT NULL
);
ALTER TABLE s3billing.zone_group OWNER TO s3billing;
--
-- TOC entry 227 (class 1259 OID 2160660)
-- Name: zone_group_id_seq; Type: SEQUENCE; Schema: s3billing; Owner: s3billing
--
CREATE SEQUENCE s3billing.zone_group_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE s3billing.zone_group_id_seq OWNER TO s3billing;
--
-- TOC entry 3467 (class 0 OID 0)
-- Dependencies: 227
-- Name: zone_group_id_seq; Type: SEQUENCE OWNED BY; Schema: s3billing; Owner: s3billing
--
ALTER SEQUENCE s3billing.zone_group_id_seq OWNED BY s3billing.zone_group.id;
--
-- TOC entry 3258 (class 2604 OID 2160671)
-- Name: bucket_info id; Type: DEFAULT; Schema: s3billing; Owner: s3billing
--
ALTER TABLE ONLY s3billing.bucket_info ALTER COLUMN id SET DEFAULT nextval('s3billing.bucket_info_id_seq'::regclass);
--
-- TOC entry 3268 (class 2604 OID 2160766)
-- Name: category id; Type: DEFAULT; Schema: s3billing; Owner: s3billing
--
ALTER TABLE ONLY s3billing.category ALTER COLUMN id SET DEFAULT nextval('s3billing.category_id_seq'::regclass);
--
-- TOC entry 3261 (class 2604 OID 2160687)
-- Name: index_type id; Type: DEFAULT; Schema: s3billing; Owner: s3billing
--
ALTER TABLE ONLY s3billing.index_type ALTER COLUMN id SET DEFAULT nextval('s3billing.index_type_id_seq'::regclass);
--
-- TOC entry 3260 (class 2604 OID 2160680)
-- Name: placement id; Type: DEFAULT; Schema: s3billing; Owner: s3billing
--
ALTER TABLE ONLY s3billing.placement ALTER COLUMN id SET DEFAULT nextval('s3billing.placement_id_seq'::regclass);
--
-- TOC entry 3263 (class 2604 OID 2160701)
-- Name: usage_stat_summary id; Type: DEFAULT; Schema: s3billing; Owner: s3billing
--
ALTER TABLE ONLY s3billing.usage_stat_summary ALTER COLUMN id SET DEFAULT nextval('s3billing.usage_stat_summary_id_seq'::regclass);
--
-- TOC entry 3262 (class 2604 OID 2160694)
-- Name: usage_stat_total id; Type: DEFAULT; Schema: s3billing; Owner: s3billing
--
ALTER TABLE ONLY s3billing.usage_stat_total ALTER COLUMN id SET DEFAULT nextval('s3billing.usage_stat_total_id_seq'::regclass);
--
-- TOC entry 3267 (class 2604 OID 2160754)
-- Name: user_info id; Type: DEFAULT; Schema: s3billing; Owner: s3billing
--
ALTER TABLE ONLY s3billing.user_info ALTER COLUMN id SET DEFAULT nextval('s3billing.user_info_id_seq'::regclass);
--
-- TOC entry 3264 (class 2604 OID 2160715)
-- Name: user_quota_bucket id; Type: DEFAULT; Schema: s3billing; Owner: s3billing
--
ALTER TABLE ONLY s3billing.user_quota_bucket ALTER COLUMN id SET DEFAULT nextval('s3billing.user_quota_bucket_id_seq'::regclass);
--
-- TOC entry 3257 (class 2604 OID 2160664)
-- Name: zone_group id; Type: DEFAULT; Schema: s3billing; Owner: s3billing
--
ALTER TABLE ONLY s3billing.zone_group ALTER COLUMN id SET DEFAULT nextval('s3billing.zone_group_id_seq'::regclass);
--
-- TOC entry 3278 (class 2606 OID 2160675)
-- Name: bucket_info bucket_info_pkey; Type: CONSTRAINT; Schema: s3billing; Owner: s3billing
--
ALTER TABLE ONLY s3billing.bucket_info
ADD CONSTRAINT bucket_info_pkey PRIMARY KEY (id);
--
-- TOC entry 3273 (class 2606 OID 2476484)
-- Name: bucket_stat bucket_stat_unique; Type: CONSTRAINT; Schema: s3billing; Owner: s3billing
--
ALTER TABLE ONLY s3billing.bucket_stat
ADD CONSTRAINT bucket_stat_unique UNIQUE (bucket_id, placement_id, timestamp_addition);
--
-- TOC entry 3298 (class 2606 OID 2160768)
-- Name: category category_pkey; Type: CONSTRAINT; Schema: s3billing; Owner: s3billing
--
ALTER TABLE ONLY s3billing.category
ADD CONSTRAINT category_pkey PRIMARY KEY (id);
--
-- TOC entry 3270 (class 2606 OID 2160640)
-- Name: goose_db_version goose_db_version_pkey; Type: CONSTRAINT; Schema: s3billing; Owner: s3billing
--
ALTER TABLE ONLY s3billing.goose_db_version
ADD CONSTRAINT goose_db_version_pkey PRIMARY KEY (id);
--
-- TOC entry 3284 (class 2606 OID 2160689)
-- Name: index_type index_type_pkey; Type: CONSTRAINT; Schema: s3billing; Owner: s3billing
--
ALTER TABLE ONLY s3billing.index_type
ADD CONSTRAINT index_type_pkey PRIMARY KEY (id);
--
-- TOC entry 3282 (class 2606 OID 2160682)
-- Name: placement placement_pkey; Type: CONSTRAINT; Schema: s3billing; Owner: s3billing
--
ALTER TABLE ONLY s3billing.placement
ADD CONSTRAINT placement_pkey PRIMARY KEY (id);
--
-- TOC entry 3290 (class 2606 OID 2476705)
-- Name: usage_bucket_by_user usage_bucket_by_user_unique; Type: CONSTRAINT; Schema: s3billing; Owner: s3billing
--
ALTER TABLE ONLY s3billing.usage_bucket_by_user
ADD CONSTRAINT usage_bucket_by_user_unique UNIQUE (bucketid, date_start, date_end, user_id, category_id);
--
-- TOC entry 3288 (class 2606 OID 2160703)
-- Name: usage_stat_summary usage_stat_summary_pkey; Type: CONSTRAINT; Schema: s3billing; Owner: s3billing
--
ALTER TABLE ONLY s3billing.usage_stat_summary
ADD CONSTRAINT usage_stat_summary_pkey PRIMARY KEY (id);
--
-- TOC entry 3286 (class 2606 OID 2160696)
-- Name: usage_stat_total usage_stat_total_pkey; Type: CONSTRAINT; Schema: s3billing; Owner: s3billing
--
ALTER TABLE ONLY s3billing.usage_stat_total
ADD CONSTRAINT usage_stat_total_pkey PRIMARY KEY (id);
--
-- TOC entry 3295 (class 2606 OID 2160758)
-- Name: user_info user_info_pkey; Type: CONSTRAINT; Schema: s3billing; Owner: s3billing
--
ALTER TABLE ONLY s3billing.user_info
ADD CONSTRAINT user_info_pkey PRIMARY KEY (id);
--
-- TOC entry 3292 (class 2606 OID 2160719)
-- Name: user_quota_bucket user_quota_bucket_pkey; Type: CONSTRAINT; Schema: s3billing; Owner: s3billing
--
ALTER TABLE ONLY s3billing.user_quota_bucket
ADD CONSTRAINT user_quota_bucket_pkey PRIMARY KEY (id);
--
-- TOC entry 3275 (class 2606 OID 2160666)
-- Name: zone_group zone_group_pkey; Type: CONSTRAINT; Schema: s3billing; Owner: s3billing
--
ALTER TABLE ONLY s3billing.zone_group
ADD CONSTRAINT zone_group_pkey PRIMARY KEY (id);
--
-- TOC entry 3276 (class 1259 OID 2474077)
-- Name: bucket_info_name_idx; Type: INDEX; Schema: s3billing; Owner: s3billing
--
CREATE INDEX bucket_info_name_idx ON s3billing.bucket_info USING btree (name);
--
-- TOC entry 3279 (class 1259 OID 2474078)
-- Name: bucket_info_placement_id_idx; Type: INDEX; Schema: s3billing; Owner: s3billing
--
CREATE INDEX bucket_info_placement_id_idx ON s3billing.bucket_info USING btree (placement_id);
--
-- TOC entry 3271 (class 1259 OID 2474094)
-- Name: bucket_stat_owner_idx; Type: INDEX; Schema: s3billing; Owner: s3billing
--
CREATE INDEX bucket_stat_owner_idx ON s3billing.bucket_stat USING btree (owner);
--
-- TOC entry 3296 (class 1259 OID 2474445)
-- Name: category_name_idx; Type: INDEX; Schema: s3billing; Owner: s3billing
--
CREATE UNIQUE INDEX category_name_idx ON s3billing.category USING btree (name);
--
-- TOC entry 3280 (class 1259 OID 2474468)
-- Name: placement_name_idx; Type: INDEX; Schema: s3billing; Owner: s3billing
--
CREATE UNIQUE INDEX placement_name_idx ON s3billing.placement USING btree (name);
--
-- TOC entry 3293 (class 1259 OID 2474438)
-- Name: user_info_name_idx; Type: INDEX; Schema: s3billing; Owner: s3billing
--
CREATE INDEX user_info_name_idx ON s3billing.user_info USING btree (name);
--
-- TOC entry 3447 (class 0 OID 0)
-- Dependencies: 230
-- Name: TABLE bucket_info; Type: ACL; Schema: s3billing; Owner: s3billing
--
GRANT SELECT ON TABLE s3billing.bucket_info TO grafanabilling;
--
-- TOC entry 3449 (class 0 OID 0)
-- Dependencies: 226
-- Name: TABLE bucket_stat; Type: ACL; Schema: s3billing; Owner: s3billing
--
GRANT SELECT ON TABLE s3billing.bucket_stat TO grafanabilling;
--
-- TOC entry 3450 (class 0 OID 0)
-- Dependencies: 245
-- Name: TABLE category; Type: ACL; Schema: s3billing; Owner: s3billing
--
GRANT SELECT ON TABLE s3billing.category TO grafanabilling;
--
-- TOC entry 3452 (class 0 OID 0)
-- Dependencies: 225
-- Name: TABLE goose_db_version; Type: ACL; Schema: s3billing; Owner: s3billing
--
GRANT SELECT ON TABLE s3billing.goose_db_version TO grafanabilling;
--
-- TOC entry 3453 (class 0 OID 0)
-- Dependencies: 234
-- Name: TABLE index_type; Type: ACL; Schema: s3billing; Owner: s3billing
--
GRANT SELECT ON TABLE s3billing.index_type TO grafanabilling;
--
-- TOC entry 3455 (class 0 OID 0)
-- Dependencies: 232
-- Name: TABLE placement; Type: ACL; Schema: s3billing; Owner: s3billing
--
GRANT SELECT ON TABLE s3billing.placement TO grafanabilling;
--
-- TOC entry 3457 (class 0 OID 0)
-- Dependencies: 239
-- Name: TABLE usage_bucket_by_user; Type: ACL; Schema: s3billing; Owner: s3billing
--
GRANT SELECT ON TABLE s3billing.usage_bucket_by_user TO grafanabilling;
--
-- TOC entry 3458 (class 0 OID 0)
-- Dependencies: 238
-- Name: TABLE usage_stat_summary; Type: ACL; Schema: s3billing; Owner: s3billing
--
GRANT SELECT ON TABLE s3billing.usage_stat_summary TO grafanabilling;
--
-- TOC entry 3460 (class 0 OID 0)
-- Dependencies: 236
-- Name: TABLE usage_stat_total; Type: ACL; Schema: s3billing; Owner: s3billing
--
GRANT SELECT ON TABLE s3billing.usage_stat_total TO grafanabilling;
--
-- TOC entry 3462 (class 0 OID 0)
-- Dependencies: 243
-- Name: TABLE user_info; Type: ACL; Schema: s3billing; Owner: s3billing
--
GRANT SELECT ON TABLE s3billing.user_info TO grafanabilling;
--
-- TOC entry 3464 (class 0 OID 0)
-- Dependencies: 241
-- Name: TABLE user_quota_bucket; Type: ACL; Schema: s3billing; Owner: s3billing
--
GRANT SELECT ON TABLE s3billing.user_quota_bucket TO grafanabilling;
--
-- TOC entry 3466 (class 0 OID 0)
-- Dependencies: 228
-- Name: TABLE zone_group; Type: ACL; Schema: s3billing; Owner: s3billing
--
GRANT SELECT ON TABLE s3billing.zone_group TO grafanabilling;
-- Completed on 2025-09-15 09:50:59
--
-- PostgreSQL database dump complete
--
@@ -0,0 +1,278 @@
--
-- PostgreSQL database dump
--
-- Dumped from database version 16.0
-- Dumped by pg_dump version 16.4
-- Started on 2025-09-15 09:53:16
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
SELECT pg_catalog.set_config('search_path', '', false);
SET check_function_bodies = false;
SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
SET default_tablespace = '';
SET default_table_access_method = heap;
--
-- TOC entry 222 (class 1259 OID 1893983)
-- Name: capacity_resource; Type: TABLE; Schema: ngcloud_ru; Owner: vc_billing
--
CREATE TABLE ngcloud_ru.capacity_resource (
vdc_id uuid NOT NULL,
"timestamp" timestamp with time zone NOT NULL,
cpu_allocated integer NOT NULL,
cpu_limit integer NOT NULL,
cpu_reserved integer NOT NULL,
cpu_used integer NOT NULL,
cpu_speed integer NOT NULL,
mem_allocated integer NOT NULL,
mem_limit integer NOT NULL,
mem_reserved integer NOT NULL,
mem_used integer NOT NULL
);
ALTER TABLE ngcloud_ru.capacity_resource OWNER TO vc_billing;
--
-- TOC entry 223 (class 1259 OID 1893993)
-- Name: capacity_storage; Type: TABLE; Schema: ngcloud_ru; Owner: vc_billing
--
CREATE TABLE ngcloud_ru.capacity_storage (
vdc_id uuid NOT NULL,
storage_profile_types_id integer NOT NULL,
"timestamp" timestamp with time zone NOT NULL,
id uuid NOT NULL,
"limit" integer NOT NULL,
used integer NOT NULL
);
ALTER TABLE ngcloud_ru.capacity_storage OWNER TO vc_billing;
--
-- TOC entry 220 (class 1259 OID 1893965)
-- Name: storage_profile_types; Type: TABLE; Schema: ngcloud_ru; Owner: vc_billing
--
CREATE TABLE ngcloud_ru.storage_profile_types (
id integer NOT NULL,
name character varying(255) NOT NULL
);
ALTER TABLE ngcloud_ru.storage_profile_types OWNER TO vc_billing;
--
-- TOC entry 219 (class 1259 OID 1893964)
-- Name: storage_profile_types_id_seq; Type: SEQUENCE; Schema: ngcloud_ru; Owner: vc_billing
--
CREATE SEQUENCE ngcloud_ru.storage_profile_types_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE ngcloud_ru.storage_profile_types_id_seq OWNER TO vc_billing;
--
-- TOC entry 3413 (class 0 OID 0)
-- Dependencies: 219
-- Name: storage_profile_types_id_seq; Type: SEQUENCE OWNED BY; Schema: ngcloud_ru; Owner: vc_billing
--
ALTER SEQUENCE ngcloud_ru.storage_profile_types_id_seq OWNED BY ngcloud_ru.storage_profile_types.id;
--
-- TOC entry 218 (class 1259 OID 1893959)
-- Name: tenant; Type: TABLE; Schema: ngcloud_ru; Owner: vc_billing
--
CREATE TABLE ngcloud_ru.tenant (
id uuid NOT NULL,
name character varying(255) NOT NULL,
wzcode bigint
);
ALTER TABLE ngcloud_ru.tenant OWNER TO vc_billing;
--
-- TOC entry 221 (class 1259 OID 1893973)
-- Name: vdc; Type: TABLE; Schema: ngcloud_ru; Owner: vc_billing
--
CREATE TABLE ngcloud_ru.vdc (
id uuid NOT NULL,
tenant_id uuid NOT NULL,
name character varying(255) NOT NULL
);
ALTER TABLE ngcloud_ru.vdc OWNER TO vc_billing;
--
-- TOC entry 3245 (class 2604 OID 1893968)
-- Name: storage_profile_types id; Type: DEFAULT; Schema: ngcloud_ru; Owner: vc_billing
--
ALTER TABLE ONLY ngcloud_ru.storage_profile_types ALTER COLUMN id SET DEFAULT nextval('ngcloud_ru.storage_profile_types_id_seq'::regclass);
--
-- TOC entry 3255 (class 2606 OID 1893987)
-- Name: capacity_resource capacity_resource_pkey; Type: CONSTRAINT; Schema: ngcloud_ru; Owner: vc_billing
--
ALTER TABLE ONLY ngcloud_ru.capacity_resource
ADD CONSTRAINT capacity_resource_pkey PRIMARY KEY (vdc_id, "timestamp");
--
-- TOC entry 3257 (class 2606 OID 1893997)
-- Name: capacity_storage capacity_storage_pkey; Type: CONSTRAINT; Schema: ngcloud_ru; Owner: vc_billing
--
ALTER TABLE ONLY ngcloud_ru.capacity_storage
ADD CONSTRAINT capacity_storage_pkey PRIMARY KEY (vdc_id, id, "timestamp");
--
-- TOC entry 3249 (class 2606 OID 1893972)
-- Name: storage_profile_types storage_profile_types_name_key; Type: CONSTRAINT; Schema: ngcloud_ru; Owner: vc_billing
--
ALTER TABLE ONLY ngcloud_ru.storage_profile_types
ADD CONSTRAINT storage_profile_types_name_key UNIQUE (name);
--
-- TOC entry 3251 (class 2606 OID 1893970)
-- Name: storage_profile_types storage_profile_types_pkey; Type: CONSTRAINT; Schema: ngcloud_ru; Owner: vc_billing
--
ALTER TABLE ONLY ngcloud_ru.storage_profile_types
ADD CONSTRAINT storage_profile_types_pkey PRIMARY KEY (id);
--
-- TOC entry 3247 (class 2606 OID 1893963)
-- Name: tenant tenant_pkey; Type: CONSTRAINT; Schema: ngcloud_ru; Owner: vc_billing
--
ALTER TABLE ONLY ngcloud_ru.tenant
ADD CONSTRAINT tenant_pkey PRIMARY KEY (id);
--
-- TOC entry 3253 (class 2606 OID 1893977)
-- Name: vdc vdc_pkey; Type: CONSTRAINT; Schema: ngcloud_ru; Owner: vc_billing
--
ALTER TABLE ONLY ngcloud_ru.vdc
ADD CONSTRAINT vdc_pkey PRIMARY KEY (id);
--
-- TOC entry 3259 (class 2606 OID 1893988)
-- Name: capacity_resource capacity_resource_vdc_id_fkey; Type: FK CONSTRAINT; Schema: ngcloud_ru; Owner: vc_billing
--
ALTER TABLE ONLY ngcloud_ru.capacity_resource
ADD CONSTRAINT capacity_resource_vdc_id_fkey FOREIGN KEY (vdc_id) REFERENCES ngcloud_ru.vdc(id) ON DELETE CASCADE;
--
-- TOC entry 3260 (class 2606 OID 1894003)
-- Name: capacity_storage capacity_storage_storage_profile_types_id_fkey; Type: FK CONSTRAINT; Schema: ngcloud_ru; Owner: vc_billing
--
ALTER TABLE ONLY ngcloud_ru.capacity_storage
ADD CONSTRAINT capacity_storage_storage_profile_types_id_fkey FOREIGN KEY (storage_profile_types_id) REFERENCES ngcloud_ru.storage_profile_types(id);
--
-- TOC entry 3261 (class 2606 OID 1893998)
-- Name: capacity_storage capacity_storage_vdc_id_fkey; Type: FK CONSTRAINT; Schema: ngcloud_ru; Owner: vc_billing
--
ALTER TABLE ONLY ngcloud_ru.capacity_storage
ADD CONSTRAINT capacity_storage_vdc_id_fkey FOREIGN KEY (vdc_id) REFERENCES ngcloud_ru.vdc(id) ON DELETE CASCADE;
--
-- TOC entry 3258 (class 2606 OID 1893978)
-- Name: vdc vdc_tenant_id_fkey; Type: FK CONSTRAINT; Schema: ngcloud_ru; Owner: vc_billing
--
ALTER TABLE ONLY ngcloud_ru.vdc
ADD CONSTRAINT vdc_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES ngcloud_ru.tenant(id);
--
-- TOC entry 3410 (class 0 OID 0)
-- Dependencies: 222
-- Name: TABLE capacity_resource; Type: ACL; Schema: ngcloud_ru; Owner: vc_billing
--
GRANT SELECT ON TABLE ngcloud_ru.capacity_resource TO grafanabilling;
--
-- TOC entry 3411 (class 0 OID 0)
-- Dependencies: 223
-- Name: TABLE capacity_storage; Type: ACL; Schema: ngcloud_ru; Owner: vc_billing
--
GRANT SELECT ON TABLE ngcloud_ru.capacity_storage TO grafanabilling;
--
-- TOC entry 3412 (class 0 OID 0)
-- Dependencies: 220
-- Name: TABLE storage_profile_types; Type: ACL; Schema: ngcloud_ru; Owner: vc_billing
--
GRANT SELECT ON TABLE ngcloud_ru.storage_profile_types TO grafanabilling;
--
-- TOC entry 3414 (class 0 OID 0)
-- Dependencies: 218
-- Name: TABLE tenant; Type: ACL; Schema: ngcloud_ru; Owner: vc_billing
--
GRANT SELECT ON TABLE ngcloud_ru.tenant TO grafanabilling;
--
-- TOC entry 3415 (class 0 OID 0)
-- Dependencies: 221
-- Name: TABLE vdc; Type: ACL; Schema: ngcloud_ru; Owner: vc_billing
--
GRANT SELECT ON TABLE ngcloud_ru.vdc TO grafanabilling;
-- Completed on 2025-09-15 09:53:18
--
-- PostgreSQL database dump complete
--
@@ -0,0 +1,9 @@
CREATE INDEX ON ngcloud_ru.capacity_resource (timestamp);
CREATE INDEX ON ngcloud_ru.capacity_storage (timestamp);
CREATE INDEX ON s3billing.bucket_stat (timestamp_addition);
CREATE INDEX ON s3billing.usage_bucket_by_user (time);
CREATE INDEX ON gpu.vm (dt_load);
CREATE INDEX ON gpu.vm_power_state (ts);
+8
View File
@@ -0,0 +1,8 @@
{
"ds_rpt_class": "org.postgresql.Driver",
"ds_rpt_connectionString": "jdbc:postgresql://write.09d78921-77a7-401a-b2cb-5ca11be850a0.services.ngcloud.ru:5432/rpt",
"ds_rpt_username": "appserver",
"ds_rpt_password": "encrypted:********",
"ds_rpt_connectionLimit": "5",
"IDP_CLIENT_SECRET": "********",
}
+10
View File
@@ -0,0 +1,10 @@
REM set Windows env
setx rpt_class org.postgresql.Driver /M
setx rpt_connectionString jdbc:postgresql://write.09d78921-77a7-401a-b2cb-5ca11be850a0.services.ngcloud.ru:5432/rpt /M
setx rpt_username appserver /M
setx rpt_password encrypted:******** /M
setx rpt_connectionLimit 5 /M
setx IDP_CLIENT_SECRET ******** /M
pause
@@ -0,0 +1,281 @@
<cfimport prefix="m" taglib="../lib"/>
<cfimport prefix="d" taglib="../lib/data"/>
<cfparam name="ATTRIBUTES.dt_finish" type="date" default=#createDateTime(year(Now()),month(Now()),1,0,0,0)#/>
<cfparam name="ATTRIBUTES.dt_start" type="date" default=#dateAdd('m',-1,ATTRIBUTES.dt_finish)#/>
<cfparam name="ATTRIBUTES.output" type="string" default="report"/>
<cfparam name="ATTRIBUTES.debug" type="boolean" default=false/>
<cfset hours=dateDiff('h',dt_start,dt_finish)/>
<cfset tenant_wz_index=val(mid(request.auth.wz,3,5))/><!--- cut off WZ --->
<!--- QoQ does not support join syntax, at least of Lucee 5.4--->
<!--- Для начала выкатываем просто кусок каталога, не фильтруя по спеке (которой иногда нет) --->
<cfquery name="qSpec">
select * from (
values
('Объектное хранилище S3 CEPH','Дисковое пространство: Объем хранения','paas.s3.ceph.vol-m','ГБайт',1.80,0)
,('Объектное хранилище S3 CEPH','Исходящий трафик','paas.s3.ceph.trf-m','ГБайт',1.50,0)
,('Объектное хранилище S3 CEPH','Запросы GET, HEAD, OPTIONS','paas.s3.ceph.get-m','10тыс.шт.',0.35,0)
,('Объектное хранилище S3 CEPH','Запросы PUT, POST, PATCH','paas.s3.ceph.put-m','10тыс.шт.',4.50,0)
) as
s (svc, component, code, unit, price, discount)
order by code
</cfquery>
<!--- Нужно еще реализовать в модели данных политики бесплатного минимума --->
<cfquery name="qComputing">
select 'WZ'||to_char(tenant.wzcode,'FM00000') as wz, vdc.name as vdc_name,
case
when vdc.name like '%-v1cl1%' then 'iaas.ngc.i29'
when vdc.name like '%-v1cl2%' then 'iaas.ngc.i31'
when vdc.name like '%-v1cl4%' then 'iaas.ngc.a28'
when vdc.name like '%-v1cl6%' then 'iaas.ngc.i28'
else '['||vdc.name||']' end as code,
sum(cpu_used/cpu_speed)/4. as core_h,
sum(mem_used)/1024/4. as gb_h
from ngcloud_ru.capacity_resource
join ngcloud_ru.vdc on capacity_resource.vdc_id=vdc.id
join ngcloud_ru.tenant on tenant.id=vdc.tenant_id
where tenant.wzcode = <cfqueryparam cfsqltype="cf_sql_integer" value=#tenant_wz_index#/>
AND capacity_resource.timestamp >= <cfqueryparam cfsqltype="cf_sql_timestamp" value=#ATTRIBUTES.dt_start#/>
AND capacity_resource.timestamp < <cfqueryparam cfsqltype="cf_sql_timestamp" value=#ATTRIBUTES.dt_finish#/>
group by tenant.wzcode, vdc.name
order by tenant.wzcode, vdc.name;
</cfquery>
<cfif (ATTRIBUTES.debug)>
<cfdump var=#qComputing#/>
<cfflush/>
</cfif>
<cfquery name="qComputingAge">
select max(capacity_resource.timestamp) as dt_load from ngcloud_ru.capacity_resource
</cfquery>
<!--- переделать storage_profile_types с расшифровкой ssd, sata, можно прямо до каталожного кода --->
<cfquery name="qStorage">
select 'WZ'||to_char(tenant.wzcode,'FM00000') as wz, vdc.name as vdc_name,
case
WHEN vdc.name LIKE '%-v1cl1%' THEN 'iaas.ngc.i29'
WHEN vdc.name LIKE '%-v1cl2%' THEN 'iaas.ngc.i31'
WHEN vdc.name LIKE '%-v1cl4%' THEN 'iaas.ngc.a28'
WHEN vdc.name LIKE '%-v1cl6%' THEN 'iaas.ngc.i28'
ELSE '['||vdc.name||']' END
|| CASE
WHEN storage_profile_types.name ILIKE '%-SSD%' THEN '.ssd-m'
WHEN storage_profile_types.name ILIKE '%-SAS' THEN '.sas-m'
WHEN storage_profile_types.name ILIKE '%-SATA' THEN '.sata-m'
ELSE '.['|| storage_profile_types.name ||']' END
as code,
storage_profile_types.name, -- *** это не тип, а профайл
sum("limit")/1024./4 as gb_h_limit, -- 4 потому, что сбор раз в 15 минут
sum(used)/1024./4 as gb_h_used
from ngcloud_ru.capacity_storage
join ngcloud_ru.vdc on capacity_storage.vdc_id=vdc.id
join ngcloud_ru.tenant on tenant.id=vdc.tenant_id
join ngcloud_ru.storage_profile_types on capacity_storage.storage_profile_types_id=storage_profile_types.id
where tenant.wzcode = <cfqueryparam cfsqltype="cf_sql_integer" value=#tenant_wz_index#/>
AND capacity_storage.timestamp >= <cfqueryparam cfsqltype="cf_sql_timestamp" value=#ATTRIBUTES.dt_start#/>
AND capacity_storage.timestamp < <cfqueryparam cfsqltype="cf_sql_timestamp" value=#ATTRIBUTES.dt_finish#/>
group by tenant.wzcode, vdc.name, storage_profile_types.name
order by tenant.wzcode, vdc.name, storage_profile_types.name;
</cfquery>
<cfquery name="qStorageAge" datasource="rpt">
select max(capacity_storage.timestamp) as dt_load from ngcloud_ru.capacity_storage
</cfquery>
<cfif (ATTRIBUTES.debug)>
<cfdump var=#qStorage#/>
<cfflush/>
</cfif>
<!---
"placement" "size_gb" "get_10k" "put_10k" "bytes_sent_gb"
HOT_FREE_LIMIT 1 10 1 100
--->
<!--- надо дисконтировать не ГБ-часы а ГБ-месяцы --->
<cfquery name="qS3Vol">
select /* *** ниже дублирование кода агрегации - round ceil etc, обратить внимание при правке */
(sum(bucket_stat.usage_rgw_main_size_actual)/<cfqueryparam cfsqltype="cf_sql_integer" value=#hours#/>) as vol_B
,ceil(sum(bucket_stat.usage_rgw_main_size_actual)/<cfqueryparam cfsqltype="cf_sql_integer" value=#hours#/>/1024/1024/1024) as vol_GB
,greatest(ceil(sum(bucket_stat.usage_rgw_main_size_actual)/<cfqueryparam cfsqltype="cf_sql_integer" value=#hours#/>
/1024/1024/1024)-
case when bucket_stat.placement_id = 1 then 1 else 0 end, 0) as vol_GB_free_tier_subtracted /*считается не в точности так, как в функции*/
,case
when bucket_stat.placement_id = 1 then 'paas.s3.ceph'
when bucket_stat.placement_id = 2 then 'paas.s3.cphc'
else '['|| TO_CHAR(bucket_stat.placement_id,'FM9') ||']' end as code
--,bucket_stat.placement_id
,'WZ'||lpad(bucket_stat.owner, 5, '0') as wz -- *** здесь не число, а строка, поэтому другая формула обратной сборки WZ
from s3billing.bucket_stat
join s3billing.placement ON s3billing.placement.id=bucket_stat.placement_id
join s3billing.bucket_info ON bucket_info.id=bucket_stat.bucket_id
where bucket_stat.placement_id in (1,2)
AND bucket_stat.owner = <cfqueryparam cfsqltype="cf_sql_varchar" value=#tenant_wz_index#/>
AND bucket_stat.timestamp_addition >= <cfqueryparam cfsqltype="cf_sql_timestamp" value=#ATTRIBUTES.dt_start#/>
AND bucket_stat.timestamp_addition < <cfqueryparam cfsqltype="cf_sql_timestamp" value=#ATTRIBUTES.dt_finish#/>
group by bucket_stat.owner, bucket_stat.placement_id
order by bucket_stat.owner, bucket_stat.placement_id;
</cfquery>
<cfif (ATTRIBUTES.debug)>
<cfdump var=#qS3Vol#/>
<cfflush/>
</cfif>
<cfquery name="qS3VolAge">
select max(timestamp_addition) as dt_load from s3billing.bucket_stat
</cfquery>
<cfquery name="qS3OpsTrf">
select
sum(
CASE WHEN
(category.name LIKE 'get%'
OR category.name LIKE 'head%'
OR category.name LIKE 'options%'
) THEN ops ELSE 0 END
) as get
,ceil(sum(
CASE WHEN
(category.name LIKE 'get%'
OR category.name LIKE 'head%'
OR category.name LIKE 'options%'
) THEN ops ELSE 0 END
)/10000) as get_10k
,greatest(ceil(sum(
CASE WHEN
(category.name LIKE 'get%'
OR category.name LIKE 'head%'
OR category.name LIKE 'options%'
) THEN ops ELSE 0 END
)/10000) - CASE WHEN bucket_info.placement_id = 1 THEN 10 ELSE 0 END, 0
) as get_10k_free_tier_subtracted
,sum(
CASE WHEN
(category.name LIKE 'put%'
OR category.name LIKE 'post%'
OR category.name LIKE 'patch%'
OR category.name LIKE 'list%'
) THEN ops ELSE 0 END
) as put
,ceil(sum(
CASE WHEN
(category.name LIKE 'put%'
OR category.name LIKE 'post%'
OR category.name LIKE 'patch%'
OR category.name LIKE 'list%'
) THEN ops ELSE 0 END
)/10000) as put_10k
,greatest(ceil(sum(
CASE WHEN
(category.name LIKE 'put%'
OR category.name LIKE 'post%'
OR category.name LIKE 'patch%'
OR category.name LIKE 'list%'
) THEN ops ELSE 0 END
)/10000) - CASE WHEN bucket_info.placement_id = 1 THEN 1 ELSE 0 END, 0
) as put_10k_free_tier_subtracted
,(sum(bytes_sent)) as bytes_sent
,ceil(sum(bytes_sent)/1024/1024/1024) as bytes_sent_GB
,greatest(ceil(sum(bytes_sent)/1024/1024/1024) - CASE WHEN bucket_info.placement_id = 1 THEN 100 ELSE 0 END, 0
) as bytes_sent_GB_free_tier_subtracted
,case
when bucket_info.placement_id = 1 then 'paas.s3.ceph'
when bucket_info.placement_id = 2 then 'paas.s3.cphc'
else '['|| TO_CHAR(bucket_info.placement_id,'FM9') ||']' end as code --*** зачем делать там ключ bigint
,bucket_info.placement_id
--category.name, bucket_info.name
,'WZ'||lpad(user_info.name, 5, '0') as wz
from s3billing.usage_bucket_by_user
join s3billing.user_info on usage_bucket_by_user.user_id=user_info.id
join s3billing.category on usage_bucket_by_user.category_id=category.id
join s3billing.bucket_info on usage_bucket_by_user.bucketid=bucket_info.id
join s3billing.placement on bucket_info.placement_id=placement.id
where 1=1
AND user_info.name = <cfqueryparam cfsqltype="cf_sql_varchar" value=#tenant_wz_index#/>
AND usage_bucket_by_user.time >= <cfqueryparam cfsqltype="cf_sql_timestamp" value=#ATTRIBUTES.dt_start#/>
AND usage_bucket_by_user.time < <cfqueryparam cfsqltype="cf_sql_timestamp" value=#ATTRIBUTES.dt_finish#/>
group by user_info.name, bucket_info.placement_id
order by user_info.name, bucket_info.placement_id;
</cfquery>
<cfif (ATTRIBUTES.debug)>
<cfdump var=#qS3OpsTrf#/>
<cfflush/>
</cfif>
<cfquery name="qS3OpsTrfAge">
select max(time) as dt_load from s3billing.usage_bucket_by_user
</cfquery>
<cfquery name="qUnifiedMetric" dbType="query">
select wz, code || '.vcpu-m' as code, core_h as raw_metric, core_h as metric, core_h as chargeable_metric from qComputing
union all
select wz, code || '.ram-m' as code, gb_h as raw_metric, gb_h as metric, gb_h as chargeable_metric from qComputing
union all
select wz, code, gb_h_used as raw_metric, gb_h_used as metric, gb_h_used as chargeable_metric from qStorage
union all
select wz, code || '.vol-m', vol_b as raw_metric, vol_gb as metric, vol_GB_free_tier_subtracted as chargeable_metric from qS3Vol
union all
select wz, code || '.get-m', get as raw_metric, get_10k as metric, get_10k_free_tier_subtracted as chargeable_metric from qS3OpsTrf
union all
select wz, code || '.put-m', put as raw_metric, put_10k as metric, put_10k_free_tier_subtracted as chargeable_metric from qS3OpsTrf
union all
select wz, code || '.trf-m', bytes_sent as raw_metric, bytes_sent_gb as metric, bytes_sent_gb_free_tier_subtracted as chargeable_metric from qS3OpsTrf
</cfquery>
<cfif (ATTRIBUTES.debug)>
<cfdump var=#qUnifiedMetric#/>
<cfflush/>
</cfif>
<cfquery name="qCharge" dbType="query">
SELECT
<d:field_set titleMapOut="titleMap" lengthOut="fieldCount">
<d:field title="Артикул">s.code</d:field>
<d:field title="Услуга">s.svc</d:field>
<d:field title="Компонент">s.component</d:field>
<d:field title="Кол-во (метрика)" cfSqlType="CF_SQL_NUMERIC">m.chargeable_metric</d:field>
<d:field title="Ед.изм.">s.unit</d:field>
<d:field title="Цена &##8381; с НДС" cfSqlType="CF_SQL_NUMERIC">s.price*(100-s.discount)/100 as discounted_price</d:field>
<d:field title="Стоимость &##8381; с НДС" cfSqlType="CF_SQL_NUMERIC">s.price*(100-s.discount)/100*m.chargeable_metric as charge</d:field>
<!--- <d:field title="Дата НОУ" cfSqlType="CF_SQL_TIMESTAMP">s.dt_from</d:field> --->
<!--- <d:field title="Дата оконч." cfSqlType="CF_SQL_TIMESTAMP">s.dt_to</d:field>
<d:field title="Ключ строки">s.line_key</d:field> --->
<d:field title="GPL с НДС" cfSqlType="CF_SQL_NUMERIC">s.price</d:field>
<d:field title="Скидка%" cfSqlType="CF_SQL_NUMERIC">s.discount</d:field>
<d:field title="Сырая метрика" cfSqlType="CF_SQL_NUMERIC">m.raw_metric</d:field>
<d:field title="Приведенная метрика" cfSqlType="CF_SQL_NUMERIC">m.metric</d:field>
<!--- <d:field title="Клиентс. назв.">s.user_description</d:field> --->
</d:field_set>
FROM qSpec as s, qUnifiedMetric as m
WHERE m.wz=<cfqueryparam cfsqltype="cf_sql_varchar" value=#request.auth.wz#/> AND s.code=m.code
order by s.code
</cfquery>
<cfset var report={}/>
<cfset report.qComputingAge = qComputingAge/>
<cfset report.qStorageAge = qStorageAge/>
<cfset report.qS3VolAge = qS3VolAge/>
<cfset report.qS3OpsTrfAge = qS3OpsTrfAge/>
<cfset report.qCharge = qCharge/>
<cfset report.chargeTitleMap = titleMap/>
<cfset report.chargeFieldCount = fieldCount/>
<cfset "CALLER.#ATTRIBUTES.output#" = report/>
<cfexit method="exittag"/>
@@ -0,0 +1,159 @@
<cfsilent>
<cfimport prefix="m" taglib="lib"/>
<cfimport prefix="c" taglib="lib/controls"/>
<cfimport prefix="d" taglib="lib/data"/>
<cfimport prefix="layout" taglib="layout"/>
</cfsilent>
<m:prepare_ls entity="payg" accessObject="" pageInfoOut="pageInfo" trackOut="tr"/>
<cfparam name="dt_finish" type="date" default=#createDateTime(year(Now()),month(Now()),1,0,0,0)#/>
<cfparam name="dt_start" type="date" default=#dateAdd('m',-1,dt_finish)#/>
<cfset hours=dateDiff('h',dt_start,dt_finish)/>
<cfset tenant_wz_index=val(mid(request.auth.wz,3,5))/><!--- cut off WZ --->
<cfset tenant_wz_index="00169"/><!--- cut off WZ --->
<cfif isDefined("output_xls")><cfmodule template="mod/payg.cfm"
dt_finish=#dt_finish#
dt_start=#dt_start#
tenant_wz_index=#tenant_wz_index#
output="report"
debug=#isDefined("DEBUG")#
/><layout:xml qRead=#report.qCharge# titleMap=#report.chargeTitleMap# filename="#pageInfo.entity#.xml"/>
<cfabort/>
</cfif><cfif isDefined("output_json")><cfmodule template="mod/payg.cfm"
dt_finish=#dt_finish#
dt_start=#dt_start#
tenant_wz_index=#tenant_wz_index#
output="report"
debug=#isDefined("DEBUG")#
/><!--- <cfdump var=#report#/><cfabort/> ---><layout:json qRead=#report.qCharge# titleMap=#report.chargeTitleMap# filename="#pageInfo.entity#.json"/>
<cfabort/>
</cfif><!---
---><layout:page section="header" pageInfo=#pageInfo#>
<layout:attribute name="title">
<cfoutput><b>Отчет по PAYG</b> #request.auth.wz#</cfoutput>
</layout:attribute>
<layout:attribute name="controls">
<!---skip filter link, filter is not implemented--->
<!---<layout:language_switch/>--->
</layout:attribute>
</layout:page>
<!--- Внимание! округление вверх при расчете метрик get, put S3. Расчетные суммы могут содержать погрешности округления, поэтому окончательный расчет стоимости (количество * цену) должен выполняться в бухгалтерской программе.
<b>Если у клиента больше 1 действующего допника с услугами PAYG, то данные в отчете могут быть замножены, в этом случае считать вручную</b> --->
<cfoutput>
<form method="post" action="">
Период с <input type="text" name="dt_start" value="#dateFormat(dt_start,'YYYY-MM-DD')#"/>
по <input type="text" name="dt_finish" value="#dateFormat(dt_finish,'YYYY-MM-DD')#"/>
<input type="submit" style="cursor:pointer;"/>
<!--- <input type="submit" name="DEBUG" value="DEBUG" style="cursor:pointer;"/> --->
</form>
<cfflush/>
<cfmodule template="mod/payg.cfm"
dt_finish=#dt_finish#
dt_start=#dt_start#
tenant_wz_index=#tenant_wz_index#
output="report"
debug=#isDefined("DEBUG")#
/>
Часов в периоде: <b>#hours#</b> Строк в отчете: <b>#report.qCharge.recordCount#</b><br/>
Актуальность данных:
VCD Computing: <b>#dateFormat(report.qComputingAge.dt_load,'YYYY-MM-DD')# #timeFormat(report.qComputingAge.dt_load,'HH:MM:SS')#</b>
VCD Storage: <b>#dateFormat(report.qStorageAge.dt_load,'YYYY-MM-DD')# #timeFormat(report.qStorageAge.dt_load,'HH:MM:SS')#</b>
S3 хранение: <b>#dateFormat(report.qS3VolAge.dt_load,'YYYY-MM-DD')# #timeFormat(report.qS3VolAge.dt_load,'HH:MM:SS')#</b>
S3 операции и трафик: <b>#dateFormat(report.qS3OpsTrfAge.dt_load,'YYYY-MM-DD')# #timeFormat(report.qS3OpsTrfAge.dt_load,'HH:MM:SS')#</b>
<br/>
<a href="#request.thisPage#?output_xls" title="экспорт в Excel" style="margin-left:.5em; height:100%;" target="_blank"><img src="img/xls.gif" style="vertical-align:text-bottom;"/></a>
<a href="#request.thisPage#?output_json" title="экспорт в json" style="margin-left:.5em; height:100%;" target="_blank"><img src="img/json.svg" style="vertical-align:text-bottom;" width="13" height="13"/></a>
</cfoutput>
<table class="worktable">
<thead>
<td width="1%"></td>
<!--- <th width="3%">WZ</th> --->
<th width="7%">Артикул</th>
<th width="7%">Кол-во (метрика)</th>
<th width="3%">Ед.изм.</th>
<th width="3%">Цена &#8381; со скид., с НДС</th>
<th width="3%">Стоимость &#8381; с НДС</th>
<td width="3%">&nbsp;&nbsp;&nbsp;</td>
<td width="10%">Услуга</td>
<td width="10%">Компонент</td>
<!--- <td width="10%">Клиентс. назв.</td> --->
<td width="3%">GPL с НДС</td>
<td width="3%">Скидка%</td>
<!--- <td width="6%">Дата НОУ</td>
<td width="6%">Дата оконч.</td>
<td width="3%">Ключ строки</td> --->
<!--- <td width="3%">Сырая метрика</td> --->
<td width="3%">Оплачиваемый объем</td>
<!--- <td width="5%">Договор</td>
<td width="10%">Допник</td>
<td width="10%">Сделка</td> --->
</thead>
<cfoutput query="report.qCharge" group="svc">
<cfset var acc=0/>
<tr>
<td bgcolor="##ccc"></td>
<td bgcolor="##ccc" colspan="5" class="b"> #svc#</td>
<td></td>
<td bgcolor="##ccc" colspan="99"></td>
</tr>
<cfoutput>
<cfset acc=acc+charge/>
<tr>
<td>#currentRow#</a></td>
<td>#code#</td>
<td class="r" style="font-size:120%; padding:0 1em;">#chargeable_metric#</td>
<td class="c" style="font-size:90%;">#unit#</td>
<td class="r">#discounted_price#</td>
<td class="r">#charge#</td>
<td></td>
<td>#svc#</td>
<td>#component#</td>
<!--- <td>#user_description#</td> --->
<td class="r">#price#</td>
<td class="r">#discount#</td>
<td class="r">#metric#</td>
</tr>
</cfoutput>
<tr>
<td bgcolor="##eee" style="border-bottom:2px solid gray;"></td>
<td bgcolor="##eee" colspan="4" class="r" style="border-bottom:2px solid gray;"><b>Итого</b> #svc#:</td>
<td bgcolor="##eee" colspan="1" class="r b" style="border-bottom:2px solid gray;">#NumFmt(acc,2)#</td>
<td colspan="14" style="border-bottom:2px solid gray;"></td>
</tr >
</cfoutput>
</table>
<!--- <cfdump var=#qCharge#/> --->
<layout:page section="footer"/>
<!--- select * from s3billing.billing_per_user_fix_pl('2025-05-01 00:00:00','2025-06-01 00:00:00','1395'); --->
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

+47
View File
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Creator: CorelDRAW X8 -->
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="78.6273mm" height="70.4009mm" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
viewBox="0 0 37746 33797"
xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<style type="text/css">
<![CDATA[
.fil3 {fill:none}
.fil2 {fill:#D7F372}
.fil4 {fill:#FEFEFE;fill-rule:nonzero}
.fil5 {fill:#638328;fill-rule:nonzero}
.fil1 {fill:url(#id1)}
.fil0 {fill:url(#id2)}
]]>
</style>
<clipPath id="id0">
<path d="M20432 0c8718,-38 15581,2266 15458,9209 -25,787 -197,745 -1206,434 -5376,-1662 -8578,-3016 -12410,-6248 -1067,-900 -1945,-1797 -2743,-2837 -478,-648 -8,-545 901,-558zm-18416 19428c1785,-4186 2803,-6725 6956,-9214 1814,-1088 3728,-1795 5744,-2401 5011,-1469 9917,-953 14495,540 1697,562 2833,1208 4123,2158 602,450 989,178 1872,-71 402,-113 1572,-529 1912,-305 933,615 -675,1120 -1291,1389 -1798,785 -1256,251 -1265,1780 -43,7030 -7302,11174 -13250,12374 -3409,688 -7029,1146 -9343,1499 -5916,900 -5060,-1185 -9071,-2819 -937,-381 -2531,-341 -2879,-1361 -223,-655 1540,-2751 1997,-3569z"/>
</clipPath>
<linearGradient id="id1" gradientUnits="userSpaceOnUse" x1="-740.954" y1="15224.8" x2="37478.6" y2="11458.4">
<stop offset="0" style="stop-opacity:1; stop-color:#C6C517"/>
<stop offset="1" style="stop-opacity:1; stop-color:#E7D900"/>
</linearGradient>
<linearGradient id="id2" gradientUnits="userSpaceOnUse" x1="-442.19" y1="20004.6" x2="37830.6" y2="16055.8">
<stop offset="0" style="stop-opacity:1; stop-color:#89A423"/>
<stop offset="1" style="stop-opacity:1; stop-color:#638429"/>
</linearGradient>
</defs>
<g id="Layer_x0020_1">
<metadata id="CorelCorpID_0Corel-Layer"/>
<path class="fil0" d="M22767 31689l-14060 2085c-2226,330 -4572,-2956 -6349,-3695 -1559,-649 -1653,-822 -1915,-2004 -266,-1196 -259,-3137 -401,-4460 -150,-1391 203,-1130 973,-2123 591,-795 964,-1292 1359,-2228 1413,-3396 2313,-5352 5285,-7695 3781,-2753 8236,-4067 12877,-4299 774,-89 484,-275 112,-747 -812,-1079 -1165,-2747 -1381,-4068 -68,-416 -308,-2197 198,-2372 228,-79 1093,72 1388,100 101,9 200,16 301,22 1094,51 2280,-41 2615,-17 4852,417 10503,1550 11899,7016 226,935 209,1325 200,2255 -2,142 -8,1026 126,1086 215,97 947,-1057 1467,133 110,344 145,764 169,1126 8,120 17,237 29,356 91,880 115,1755 53,2638 -23,319 -23,817 -223,1077 -611,795 -1576,1292 -2268,2065 -435,506 -635,1974 -834,2703 -720,2633 -1869,4361 -3594,4590l-7936 1054 -90 5402z"/>
<path class="fil1" d="M20432 0c8718,-38 15581,2266 15458,9209 -25,787 -197,745 -1206,434 -5376,-1662 -8578,-3016 -12410,-6248 -1067,-900 -1945,-1797 -2743,-2837 -478,-648 -8,-545 901,-558zm-18416 19428c1785,-4186 2803,-6725 6956,-9214 1814,-1088 3728,-1795 5744,-2401 5011,-1469 9917,-953 14495,540 1697,562 2833,1208 4123,2158 602,450 989,178 1872,-71 402,-113 1572,-529 1912,-305 933,615 -675,1120 -1291,1389 -1798,785 -1256,251 -1265,1780 -43,7030 -7302,11174 -13250,12374 -3409,688 -7029,1146 -9343,1499 -5916,900 -5060,-1185 -9071,-2819 -937,-381 -2531,-341 -2879,-1361 -223,-655 1540,-2751 1997,-3569z"/>
<g style="clip-path:url(#id0)">
<g id="_3107826610240">
<path id="1" class="fil2" d="M32620 15645c91,-46 98,-37 3,-91 -66,80 -20,59 -3,91zm-9180 173l399 13 94 3 24 -102c121,-34 39,27 76,-81l-104 -24c-76,-2 -129,-55 -130,-59 -3,-10 -11,-11 -18,-15l-68 -4c-94,14 -151,38 -213,69l-167 12 -8 120 12 65 103 3zm3362 -16369c31,57 -35,35 43,50 123,23 4,11 64,-18 -27,-64 39,-43 -41,-56 -131,-22 -7,-12 -66,24zm10290 3584c-14,-80 10,-36 -56,-77 -2,4 -94,5 -19,84 51,53 74,-7 75,-7zm2277 11808c100,-46 108,-71 4,-122 -65,81 -40,56 -4,122zm-598 -13384c72,-96 70,-72 -25,-113 -75,90 -74,70 25,113zm-486 7658c-47,-62 -81,7 -83,7 65,99 -66,22 64,80 3,-6 100,22 19,-87zm-14361 -9918c35,-131 44,-89 -39,-138 -4,7 -53,-26 -31,67 27,121 -23,26 70,71zm13953 15010c7,-87 -6,-35 -58,-63 -74,84 -46,57 -4,129 45,-22 53,54 62,-66zm1320 -15163c106,-113 26,-91 4,-126 -51,29 -67,-45 -62,71 4,73 20,40 58,55zm2646 15025c-2,-2 9,-68 -88,-57 -109,12 -37,7 -73,64 105,50 64,60 161,-7zm-292 -3726c48,-52 47,-33 49,-171 -84,-2 -93,-38 -195,78 -83,11 -33,-18 -92,47l115 91c47,-29 48,-35 123,-45zm-2986 3302c-113,22 -40,12 -65,57 182,135 184,-23 184,-24 -10,-6 19,-60 -119,-33zm-1300 1033c-76,-44 -26,-43 -193,-4 18,157 33,163 111,169 166,12 14,18 79,-36l3 -129zm-28 818c-15,-28 43,-120 -226,-46 35,72 -28,65 93,92 164,37 124,-39 133,-46zm-7887 -16516l182 4c21,-158 -15,-177 -161,-176l-21 172zm1556 1675l148 114c99,86 124,57 174,-16 -50,-145 -12,-36 -146,-105 -44,-243 65,-253 -76,-325 -82,93 -25,15 -43,187 -15,145 -10,6 -57,145zm-16717 6826c58,-32 47,42 53,-46 7,-83 4,-30 -50,-59l-24 38c3,61 -11,24 21,67zm-1336 -2651c25,-6 44,9 63,-22 77,-123 10,-42 -8,-80 -80,40 -47,5 -55,102zm-4579 -6954l-112 -3 26 95c101,-38 42,50 86,-92zm7494 3833c33,-24 94,21 33,-122 -79,33 -49,-52 -62,48 -20,150 -16,19 29,74zm14390 12465c-110,-30 -18,-3 -62,20 24,35 -18,154 116,33 -22,-35 -5,-40 -54,-53zm-16336 1107c44,-72 -19,-62 -22,-69 -108,42 -55,-27 -72,102 120,-4 51,36 94,-33zm-15555 -8727c0,-1 54,27 19,-104 -41,1 -53,-32 -83,34 -36,76 49,66 64,70zm29578 -7130c115,91 90,22 123,-26 -55,-41 62,-61 -72,-36 -85,16 -30,37 -51,62zm-15613 10133c8,-12 163,46 3,-107 -158,125 -4,106 -3,107zm19829 6238c-113,-3 -59,-5 -148,25 75,104 35,82 146,96l2 -121zm-1810 -750c-9,127 22,73 56,93 13,-18 98,-37 6,-177 -40,23 -55,-4 -62,84zm-22909 -1701c110,-49 103,-21 88,-170l-170 17 -8 103 29 61 61 -11zm17260 -7459c57,-32 46,52 67,-70 25,-145 -10,-61 -33,-93 -62,23 -49,-63 -69,61 -24,151 20,79 35,102zm6292 8218c-86,10 -54,14 -87,55 20,17 25,52 112,48 130,-7 69,-26 89,-71 -34,-23 -5,-44 -114,-32zm-18726 -12582l138 126c48,-78 33,-47 37,-156 -115,-27 -88,-44 -175,30zm5425 2089c112,8 43,39 121,-26l69 -111c-57,-38 50,-12 -63,-36 -108,-23 -8,-17 -111,3l-16 170zm199 -5155c68,-15 102,32 91,-91 -10,-106 -55,-86 -91,-103 -91,200 -13,173 0,194zm-4498 9344c44,-116 -2,-63 -8,-78 -188,-98 -154,47 -70,159 12,-9 44,9 78,-81zm-14595 -8298l-37 -85 -225 -25c13,84 -5,39 37,90 11,13 11,8 29,27 101,111 -29,62 100,79 134,17 77,-16 96,-28l0 -58zm16516 14224c-51,-40 -1,-28 -113,-50 -41,-8 -243,-20 -290,67 61,129 108,18 237,86 55,29 -10,50 104,69 198,32 132,-42 146,-52 -49,-181 23,-57 -89,-111l5 -9zm-15097 58c48,-69 44,-72 16,-145 -273,51 -22,42 -250,167 31,-165 -35,-99 -48,-114l-10 228 209 8 83 -144zm10508 -4775c26,-191 49,60 -34,-54 -80,69 -76,58 -4,123 52,-55 25,22 38,-69zm2333 -7579c33,-5 125,10 56,-109 -99,52 -45,-60 -56,109zm-3430 -2611c31,53 -29,36 45,50 112,21 9,9 62,-17 -23,-52 62,-39 -40,-56 -76,-13 -10,-11 -67,23zm-5194 1548c20,-171 28,-57 -48,-102 -23,40 -71,-39 -25,67 24,53 -2,25 73,35zm2622 14176c16,-129 5,-16 -32,-67 -96,48 -105,68 -4,128 34,-41 21,52 36,-61zm20631 -662c17,-119 -2,-25 -34,-66 -97,47 -110,67 -4,121 35,-34 24,49 38,-55zm-26456 -15122c101,69 78,57 133,-25 -30,-22 47,-55 -72,-37 -124,19 -29,27 -61,62zm-3047 11843c12,19 -43,82 103,51 57,-13 47,-31 63,-80 -75,-40 -83,-41 -168,-21l2 50zm590 1476c-68,85 -67,86 -5,161 100,-47 96,-110 5,-161zm9676 2249c132,-45 120,-6 96,-144 -185,-46 -50,-16 -91,31 11,22 4,32 23,36 49,12 16,9 52,-9 -39,-41 -1,-18 -75,-27 -8,6 -42,26 -44,28 -15,11 -32,39 -36,33 -3,-7 -12,13 -19,19l7 24c-83,-5 -17,-34 -119,21l-151 150c-7,14 -26,96 -30,118 184,-1 47,-2 195,-98 58,-38 107,-51 184,-77l17 -88 -9 -17zm-10949 -16354c55,-82 68,-123 -24,-178 -4,5 -49,-31 -45,89 4,131 35,74 69,89zm5381 2060c95,56 114,57 192,-23 -73,-75 -188,-91 -192,23zm4150 14183c1,-1 64,-22 50,-106 -19,-117 0,-36 -73,-72 -30,39 -121,103 23,178zm17483 -2004c-59,-80 -103,-72 -188,-35 18,24 -70,68 84,81 141,12 94,-39 104,-46zm-13680 -1675c141,42 169,46 247,-29 -37,-39 -45,-69 -145,-59 -99,10 -73,22 -102,88zm-8064 1221l-36 23c-90,81 -54,49 -56,120 261,25 188,-170 188,-171 -115,-7 34,-13 -60,2l-36 26c7,17 -3,42 39,48 3,1 16,-4 19,-4 5,0 15,-7 23,-10 -39,-35 6,-19 -81,-34zm8633 -1952c-7,-10 54,-79 -137,-26 9,87 13,131 90,196 33,-28 19,-1 55,-52 24,-33 -22,88 23,-39 49,-141 4,-71 -31,-79l-78 -1 25 81 53 -80zm-7088 2769c-11,-74 -88,-139 -148,-163l-43 119 162 129c15,-21 48,44 29,-85zm-7692 303l-2 61 261 28c11,-216 -161,-128 -259,-89zm39584 -10253c14,28 -26,64 65,64 135,1 27,20 78,-60 -44,-33 11,-44 -75,-39 -129,8 -18,-2 -68,35zm419 750c28,-36 34,52 41,-69 10,-164 -9,-60 -36,-101 -30,17 -56,-27 -71,73 -20,138 30,78 66,97zm3066 2473c55,-72 68,-95 66,-198 -92,-19 -136,-38 -155,50 -18,87 89,148 89,148zm2396 -3620c57,42 -52,30 106,49 159,20 20,7 113,-14 -22,-95 28,-126 -112,-132 -102,-5 -86,13 -107,97zm-718 4372l-51 -56c213,-116 92,-238 -70,-239 -152,-1 -176,38 -165,195l14 39c15,128 95,141 227,140l45 -79zm-11882 -7680c96,59 33,27 76,3l-58 -18c-3,-8 -12,11 -18,15zm-21198 15445c35,43 20,47 60,29 5,-2 14,-15 22,-22l-82 -7zm5007 1132l97 3 -6 -58c-40,-60 5,-36 -76,-65 -35,-12 -55,0 -88,1l5 117 68 2zm19326 -14421l-61 0c-35,54 -28,-70 -32,67 -4,136 11,60 30,93 73,-19 31,14 65,-50 146,-35 47,16 96,-68 -57,-55 15,-32 -98,-42zm-8429 13335c39,-6 206,-169 -164,-173 -70,95 -80,76 -102,170l266 3zm14571 -9261c47,74 57,123 127,4 -116,-124 -105,-20 -127,-4zm909 -1194c55,84 170,131 199,-23 -26,-14 -10,-39 -115,-26 -57,8 -60,10 -84,49zm271 2163c-1,-1 34,-56 -85,-37 -107,17 -28,-8 -84,61 38,68 -52,70 107,81 68,5 70,-17 106,-46 -12,-97 2,-33 -44,-59l-117 15 83 62c43,-50 20,-9 34,-77zm2227 -30c105,19 81,28 165,-11 -16,-86 -60,-131 -144,-155 -48,81 -43,60 -21,166zm-680 -1938c36,-37 13,13 35,-54 59,-184 69,-195 19,-219 -7,-2 -19,-13 -22,-4 -3,7 -75,-30 -95,109 -15,102 -6,135 63,168zm-7887 -1031c174,9 174,-15 198,-181l-195 0 -3 181zm-434 -3677c0,-132 -97,-207 -219,-204 -14,163 61,224 219,204zm9067 10670l-228 -20c15,143 32,200 191,182 20,-35 15,-15 29,-68 0,-1 7,-80 8,-94zm-11458 -8654l180 15c41,-77 53,-78 67,-187l-240 -2 -7 174zm-13484 -1787c67,65 -100,40 38,31 70,-5 24,3 66,-28 -77,-64 98,-40 -43,-30 -87,6 4,-25 -61,27zm15318 6420c8,8 -37,96 94,3 -76,-59 111,-37 -40,-27 -71,5 -10,-11 -54,24zm-4361 -5494c0,-176 44,-57 -80,-98 19,109 -24,65 80,98zm5777 11913c-52,-67 -18,-71 -104,-32 75,108 -101,-50 19,46 3,3 11,10 15,12 122,59 35,-5 70,-26zm452 -9192c-16,-105 17,-46 -64,-89 -51,58 -31,28 -37,112l0 24c139,141 57,117 171,135l19 -142 -89 -40zm1300 -184c26,36 -55,67 176,58 3,-16 70,-189 -107,-115 -68,28 -31,-2 -69,57zm-6645 5321c35,-145 -23,-89 -32,-106 -123,17 -84,-60 -108,72 -22,127 47,87 73,100 61,-30 38,56 67,-66zm12539 -5533c161,1 121,36 140,-122 -54,-35 -31,-46 -160,-30 -8,73 -19,77 20,152zm-7898 4642l158 -3 13 -151 -160 -4 -11 158zm-17042 -7038c56,111 45,115 104,4 -52,-38 -53,-32 -104,-4zm9439 14921c154,-74 34,-104 32,-108 -148,63 -33,108 -32,108zm-15492 -14617c77,-131 38,-84 -75,-106 31,146 -22,74 75,106zm2595 5412c115,-55 112,-64 4,-122 -16,20 -39,-42 -33,60 3,61 1,30 29,62zm3716 -5475c14,118 -32,80 92,86 38,-95 6,-127 -92,-86zm-11754 7101c6,-101 5,-86 -98,-105 -13,127 -2,118 98,105zm5801 -8233c54,-27 68,45 62,-68 -3,-75 -27,-45 -58,-61 -2,3 -85,14 -4,129zm-10835 7881c-39,-69 -44,-121 -135,-4 60,52 -67,23 67,40 113,15 27,-3 68,-36zm25397 -740c-39,10 -173,13 -98,95 96,105 86,-47 98,-95zm-2065 8351c-44,-72 23,-10 -75,-89 -65,-51 0,-9 -75,-42l-42 107 31 161c92,9 87,6 142,-32l19 -105zm-14954 -14214c87,52 55,48 150,6 -45,-139 -121,-139 -150,-6zm2674 8592c-160,2 -83,47 -93,81l164 16c3,-66 28,-97 -71,-97zm-12857 -11651c60,116 145,73 198,6 -88,-66 -119,-55 -198,-6zm14730 9603c8,31 -35,70 85,81 110,11 71,-14 85,-76 -83,-43 -68,-37 -170,-5zm-1945 -4959c-114,-51 -60,-57 -140,3l-4 58c93,64 166,73 228,-21 -41,-48 23,-26 -84,-40l-116 0c37,83 -28,35 83,68 54,-39 15,12 33,-68zm1791 10456c-76,-57 -141,-72 -205,22 16,13 16,57 118,48 109,-8 68,-30 87,-70zm6791 -1117c36,-46 36,70 46,-94 8,-128 -24,-68 -40,-87 -41,17 -64,-27 -71,84 -7,141 28,77 65,97zm-19845 -1831c156,4 126,-56 115,-191 -148,46 -119,6 -115,191zm28588 2560c110,14 138,-57 145,-166l-129 10c-85,158 -16,155 -16,156zm-24535 1960c-95,25 -67,45 -80,133 123,4 100,21 190,-35 -15,-82 23,-133 -110,-98zm-1340 -12555c34,-29 35,29 35,-82 -176,-85 -220,125 -198,245 233,90 139,21 170,-18l-9 -56 2 -89zm-138 10191c7,-3 15,-9 19,-11 187,-98 143,-30 160,-133 -81,-49 -97,-61 -235,49 -9,7 -14,11 -18,14l-10 -4 3 8c-6,4 -7,6 -36,40 48,43 -12,16 62,44 104,41 37,-1 55,-7zm8367 -12746l142 -3 17 -140c-107,-42 -56,-44 -174,-39 -39,107 -19,70 15,182zm13541 9864c19,-155 31,-118 -58,-202l-114 -68 -18 135c5,4 38,33 47,42 111,117 -30,57 143,93zm988 4753c45,-102 30,10 23,-135l-34 -127c-82,44 -10,4 -55,41l-47 40c-59,65 -45,-8 -44,128 63,55 64,54 157,53zm12225 -12623c69,-36 48,37 60,-51 11,-77 1,-29 -27,-74 -74,34 -47,-58 -61,47 -12,85 -16,23 28,78zm-541 532c79,-38 56,43 72,-73 18,-134 -33,-95 -37,-102 -62,28 -51,-64 -71,68 -26,164 18,78 36,107zm3249 -2408c42,-47 40,67 48,-70 9,-158 -18,-67 -43,-99 -62,25 -63,-61 -81,69 -20,138 47,86 76,100zm1303 9111c11,10 -39,54 99,53 166,-1 78,-27 103,-76 -9,-7 35,-50 -103,-50 -165,1 -70,41 -99,73zm-491 -3647c91,-9 105,-16 198,-88 -55,-161 -128,-160 -199,-171 8,167 44,104 1,259zm-24692 -3316c89,44 53,43 105,-26 -38,-27 54,-78 -66,-29 -4,1 -13,8 -16,10l-23 45zm13671 13200c-34,-129 31,-90 -104,-104 13,123 -20,66 104,104zm-2531 -392c-27,-46 15,-108 -118,-32 19,31 -50,43 47,68 133,33 20,3 71,-36zm7334 520c-76,-6 -56,36 -71,58 59,45 -67,20 67,36 129,15 17,4 69,-31 -26,-54 52,-54 -65,-63zm-2346 -1174c-22,-102 14,-11 -61,-51 -44,43 -78,-66 -39,94 19,78 18,22 64,42 41,-48 53,-4 36,-85zm-2672 1531c-126,3 -69,18 -100,66 34,27 41,99 222,7 -23,-40 -10,-76 -122,-73zm2389 -1391c-144,-10 -215,11 -139,175l166 10 -27 -185zm-13510 -6878c31,-55 21,-40 3,-86 -56,59 -65,-88 -32,42 34,130 -97,-14 29,44zm11036 7899c51,38 -7,20 62,28 135,17 -25,23 42,-24 -37,-35 4,-24 -62,-33 -161,-23 46,-43 -42,29zm-26597 -10879c62,73 10,58 96,32 -17,-44 72,-25 -23,-45 -72,-14 -23,-8 -73,13zm24877 10019c-13,-82 1,-14 -47,-36 -75,86 -26,63 26,92 24,-39 30,7 21,-56zm-13301 -13607c15,-24 58,33 23,-60 -25,-65 14,-8 -49,-43 -39,74 -63,63 26,103zm-1474 10770c72,-89 63,-70 -26,-109 -101,108 -21,86 26,109zm2490 -9768c5,-7 104,11 5,-148 -44,51 -53,-76 -44,73 6,81 4,33 39,75zm-455 582l45 -26c-21,-110 32,-69 -101,-93 -6,111 -40,72 56,119zm-2645 4875c5,-126 -3,-96 -98,-104 -11,117 -39,123 98,104zm-15387 -7335c18,35 -39,43 62,61 137,25 73,-13 98,-27 -5,-10 32,-155 -160,-34zm3775 14905c-5,113 34,83 37,87 96,-48 93,-123 5,-181 -22,29 -35,-53 -42,94zm16124 -7293c1,0 19,130 175,5 -14,-31 12,-58 -77,-63 -125,-7 -71,37 -98,58zm4169 4706c36,-17 59,39 73,-89 14,-124 -31,-78 -39,-88 -47,21 -58,-27 -69,83 -13,129 26,80 35,94zm-14149 1449c-78,-12 -84,28 -88,128l195 -3c-12,-79 -17,-110 -107,-125zm-10285 -377c144,13 156,25 270,-28 -59,-65 -141,-121 -249,-121l-21 149zm30877 -12750c85,40 49,47 105,-26 -20,-16 24,-117 -105,26zm-4545 -547c57,-82 74,-71 -25,-119 -65,93 -66,76 25,119zm6390 36c29,-122 42,-90 -84,-116 14,175 -38,71 84,116zm5154 2987c-1,45 -19,42 30,111 107,-34 39,71 81,-92 -1,-4 -13,-87 -13,-87 -16,-49 -13,-36 -31,-65 -83,37 -49,-29 -67,133zm-7078 -529c164,-26 122,59 107,-100 -108,-4 -71,-31 -107,100zm1289 -911c4,-125 47,-86 -67,-119 -93,100 -38,96 67,119zm2389 12606c-126,16 -58,-23 -117,61 78,102 120,95 117,-61zm-6679 -14484c93,-49 101,-26 33,-126 -50,15 -41,-45 -66,34 -45,140 4,62 33,92zm-6632 -463c15,26 -10,57 69,74 138,32 72,-15 95,-30 -35,-82 47,-48 -65,-68 -29,-6 -93,19 -99,24zm9162 3323c39,-11 64,56 82,-82 14,-103 -25,-89 -47,-113 -98,45 -81,123 -35,195zm11121 7323c77,-41 52,57 68,-98 6,-49 -11,-65 -20,-97 -60,22 -76,-44 -92,91 -17,134 36,91 44,104zm-6944 6433l64 2c35,-109 52,-107 -11,-201 -29,8 -65,-56 -82,91 -8,64 4,48 29,108zm-9851 -12707c33,-80 14,-192 -50,-252 -80,-76 -14,-26 -117,-47 51,222 26,39 90,163 44,86 -28,59 77,136zm17007 11129c-5,-4 -12,-10 -18,-14l-82 -58c-96,106 -9,225 150,201 10,-144 19,-76 -50,-129zm-14523 -7357l6 118 177 17 4 -157c-119,-17 -93,-25 -187,22zm1634 621c122,-10 52,43 137,-39 82,-80 30,-20 53,-141l-179 10 -11 170zm4069 -5651l169 46c4,-99 16,-85 -35,-146 -85,-102 -17,-50 -154,-73l20 173zm10176 12053c-10,-85 13,-6 -49,-52 -38,41 -37,-9 -30,69 9,97 -12,14 55,43 37,-56 37,58 24,-60zm-8682 -13172c37,61 -40,39 51,54 69,12 24,3 66,-21 -13,-32 49,-134 -117,-33zm2107 1274c32,-19 113,33 33,-122 -78,35 -50,-47 -63,48 -17,129 -11,23 30,74zm-2180 -3663l-108 -3c2,174 -2,49 42,106 98,-52 42,59 66,-103zm10237 6623c21,-30 58,43 35,-71 -21,-101 17,-22 -68,-66 -52,93 -66,71 33,137zm-4681 8279c36,-113 -28,-91 -30,-94 -79,34 -36,-30 -61,65 -8,29 10,72 15,95 50,-25 47,25 76,-66zm1712 -10475c91,-11 72,27 106,-40 54,-109 -37,-81 -62,-94l-37 44c-16,57 -18,8 -7,90zm-2158 653c14,19 -16,66 79,65 118,-2 32,5 75,-60 -18,-16 33,-129 -154,-5zm-9161 -4516c140,63 104,33 149,-46 -10,-8 40,-74 -77,-47 -186,44 -11,10 -72,93zm14791 10390c-148,-15 -74,13 -105,64 54,49 -82,47 102,53 102,4 98,-46 99,-47 -19,-29 7,-60 -96,-70zm-7396 -6688c36,71 -84,46 67,61 7,-6 18,-26 21,-20l74 -89c49,-90 34,-58 -1,-120 -66,20 -27,3 -73,42 -4,4 -10,10 -14,14 -4,4 -10,10 -14,14 -5,5 -12,11 -16,16l-35 33 -9 49zm3325 -2979c18,41 -50,73 94,76 129,2 71,-21 95,-70 -6,-6 38,-52 -90,-53 -164,-1 -55,12 -99,47zm-4326 1161l115 0c21,-126 29,-84 -30,-190 -116,49 -118,87 -85,190zm5264 1728c3,-4 48,17 51,-93 3,-169 -3,-47 -45,-100 -39,16 -76,-51 -89,107 -7,77 16,64 83,86zm3737 8908c127,-174 17,-185 6,-201 -65,17 -98,-21 -96,100 3,114 51,86 90,101zm-578 -7651c-69,-84 28,-32 -83,-63 -47,-14 -89,-4 -137,1 12,162 117,193 220,62zm-10792 -2729c100,-7 112,56 202,-184l-175 3 -27 181zm11216 4612c69,-72 57,-54 46,-178l-200 -7c-4,7 -10,9 -11,18l-76 95c135,22 67,-7 160,87l81 -15zm-25274 1701c77,60 7,56 87,3 -48,-47 101,-40 -34,-29 -73,6 -3,-17 -53,26zm-2320 -4177c8,14 14,124 93,3 -55,-40 95,-28 -36,-22 -55,3 -22,-1 -57,19zm3531 6166c27,41 -51,47 54,45 61,-2 16,-6 40,-42 -34,-25 -1,-17 -55,-23 -3,0 -17,14 -19,7 -3,-7 -13,9 -20,13zm9742 -9188c141,-30 64,23 96,-75 -192,-10 -27,10 -96,75zm-816 7909c14,-101 12,5 -30,-35 -126,99 -4,94 -3,96 46,-55 21,17 33,-61zm4718 4713c-57,112 -6,23 20,71 19,-13 146,-53 23,-91 -70,-21 -30,-5 -43,20zm-8122 -431c-16,-86 16,-10 -58,-43 -83,124 13,93 34,107 35,-58 47,63 24,-64zm17489 -3007c175,99 121,19 148,5 -163,-141 -131,-17 -148,-5zm-22342 4930c132,-4 153,38 191,-77l-183 -33c-126,37 -56,-3 -107,63 48,51 -83,44 99,47zm12980 -12068c-5,14 -15,15 -16,31 -2,16 -12,19 -13,27 -6,30 -6,60 -4,92l176 14 5 -153 -148 -11zm3361 -432c36,-54 25,-37 3,-87 -60,72 -60,-95 -28,43 33,144 -77,-37 25,44zm-4106 70c11,-16 62,31 29,-65 -27,-79 22,-7 -54,-51 -28,44 -74,-63 -27,70 23,65 -11,17 52,46zm4107 -1014c84,-42 48,57 62,-54 19,-151 13,-10 -29,-78 -90,38 -52,-66 -66,48 -12,105 -22,20 33,84zm5309 -1291c25,-37 79,60 36,-83 -31,-104 27,-9 -60,-59 -32,47 -83,-71 -36,83 27,91 -22,17 60,59zm1248 6583c63,-42 37,35 61,-54 36,-132 -10,-66 -27,-91 -56,21 -39,-53 -66,42 -50,177 2,67 32,103zm-3412 -2551c58,-28 78,64 72,-75 -4,-112 -22,-47 -68,-70 0,1 -117,-7 -4,145zm-290 -1225c21,58 -53,43 42,70 192,56 47,8 103,-36 -35,-66 28,-44 -52,-65 -126,-33 -82,23 -93,31zm-5356 -3095l-18 46c15,128 58,97 106,82 112,-34 73,-59 86,-77 -19,-13 -52,-43 -84,-53l-90 2zm8115 7257c60,-74 70,111 51,-115 -8,-93 -53,-79 -74,-92 -6,7 -48,-10 -51,93 -7,206 -25,60 74,114zm-358 -3417c71,-45 53,62 74,-83 25,-183 -18,-80 -39,-116 -80,37 -60,-87 -83,79 -26,191 22,83 48,120zm-17157 14c84,70 -122,41 42,30 72,-5 22,6 65,-27 -62,-52 99,-38 -41,-29 -76,5 -12,-10 -66,26zm5328 -1528c64,-38 48,39 53,-51 7,-103 14,-24 -49,-60 -33,49 -32,66 -4,111zm-27615 8986c78,-37 55,63 54,-88 -92,2 -45,-22 -73,27 -47,84 8,41 19,61zm32764 3684c-10,-69 1,-14 -45,-37 -107,108 -16,75 26,101 17,-36 27,-9 19,-64zm-6438 -13319c1,0 47,9 52,-34 12,-113 25,-18 -49,-67 -15,24 -88,9 -3,101zm-2549 -1315c-17,8 -170,44 -28,87 58,17 34,0 42,-20 45,-117 9,-15 -14,-67zm4500 3122c50,-16 41,47 55,-34 13,-73 3,-24 -22,-71 -54,37 -107,11 -33,105zm100 11390c59,41 -62,18 67,32 141,16 7,10 72,-28 -141,-125 -131,-11 -139,-4zm5906 333c26,-38 129,23 33,-116 -108,38 -98,26 -33,116zm-36586 -127c129,-18 2,10 67,-35 -65,-124 -157,-104 -185,26 57,9 59,18 118,9zm29127 1316c73,-30 40,-35 106,4 -33,22 14,19 -51,22 -116,5 -14,2 -55,-26zm-38 58l179 5 2 -117 -181 -1 0 113zm-23361 -15949c1,-1 61,10 45,-97 -16,-103 8,-32 -68,-78 -13,20 -65,-31 -41,104 21,124 2,35 64,71zm7079 11660c-101,8 -43,-8 -107,52 -56,54 -46,20 -62,107l176 18 -7 -177zm27110 -785c-83,-43 -52,-52 -103,26 49,34 -62,39 59,24 76,-9 15,3 44,-50zm-1211 -2487c86,-45 122,22 33,-115 -52,20 -45,-52 -59,40 -13,86 -25,15 26,75zm849 -5816c135,-10 77,32 107,-104 -166,22 -89,-39 -107,104zm-8045 -1393l59 -2c61,-97 37,-142 -53,-191 -5,7 -123,39 -6,193zm-12585 2344c3,-126 9,-31 -32,-70 -25,25 -18,13 -37,64l39 57 30 -51zm21445 8401c-173,28 -5,162 -5,162 17,-23 34,53 35,-88 2,-133 2,-16 -30,-74zm-7726 -10714c54,-16 48,46 60,-34 14,-86 9,-15 -27,-73 -49,31 -121,-28 -33,107zm2226 -437c29,-40 52,53 35,-69 -15,-105 -2,-34 -59,-73 -14,17 -58,-43 -43,72 17,126 -14,33 67,70zm-13381 14473c-108,-44 -32,2 -69,25 153,144 93,45 120,32 -37,-65 17,-30 -51,-57zm-16107 -2399c103,-46 70,-13 61,-117 -96,10 -54,-27 -90,34 -46,78 29,83 29,83zm6804 -10029c-8,-5 -18,-24 -24,-15l-95 -9c0,144 -25,145 119,124l0 -100c-90,37 -68,-13 -76,69 108,-5 13,38 76,-69zm-11048 7103c11,-112 26,21 -39,-41l8 93c60,-55 22,37 31,-52zm6 -5040c76,71 -99,41 38,30 70,-5 11,13 56,-27 -53,-44 90,-32 -36,-25 -65,4 -11,-8 -58,22zm701 2991c38,31 32,40 85,3 -102,-100 -85,-3 -85,-3zm38576 5668c152,93 87,13 102,3 -42,-43 17,-22 -57,-32l-45 29z"/>
<path class="fil2" d="M22443 33531c91,-46 99,-37 3,-91 -65,80 -19,59 -3,91zm-9179 174l399 12 94 3 23 -102c121,-33 40,27 77,-81l-104 -24c-77,-2 -129,-55 -130,-59 -3,-10 -12,-11 -18,-15l-68 -4c-94,14 -152,38 -213,70l-167 11 -9 121 13 64 103 4zm3362 -16370c31,57 -35,35 43,50 123,23 4,11 64,-17 -27,-65 39,-44 -41,-57 -132,-21 -7,-12 -66,24zm10290 3584c-14,-80 9,-36 -56,-77 -3,4 -94,6 -19,84 51,53 74,-6 75,-7zm2277 11808c100,-46 108,-71 3,-122 -64,81 -39,56 -3,122zm-598 -13384c72,-96 69,-72 -26,-113 -75,90 -73,70 26,113zm-487 7658c-46,-62 -80,7 -82,7 65,99 -67,22 63,80 4,-5 100,22 19,-87zm-14360 -9918c34,-131 44,-88 -39,-138 -4,7 -53,-26 -32,67 28,121 -23,26 71,71zm13953 15010c6,-87 -6,-35 -58,-63 -75,84 -46,57 -4,130 44,-23 53,54 62,-67zm1319 -15162c106,-114 26,-92 4,-127 -51,29 -67,-45 -61,71 4,73 20,40 57,56zm2647 15024c-2,-2 8,-68 -88,-57 -109,12 -38,7 -73,64 105,50 64,60 161,-7zm-293 -3726c49,-51 48,-33 50,-171 -85,-2 -93,-38 -195,78 -83,12 -33,-18 -93,47l116 91c47,-29 48,-34 122,-45zm-2986 3302c-112,22 -39,12 -64,57 182,135 183,-23 184,-23 -10,-7 18,-61 -120,-34zm-1299 1033c-77,-44 -27,-43 -194,-4 19,157 34,164 112,169 166,12 14,18 78,-36l4 -129zm-29 818c-14,-28 44,-120 -225,-46 34,72 -28,65 93,92 164,37 124,-39 132,-46zm-7886 -16516l181 4c22,-158 -15,-177 -160,-176l-21 172zm1555 1675l149 114c98,86 124,57 174,-16 -50,-145 -13,-36 -146,-105 -44,-243 65,-253 -76,-325 -82,93 -26,15 -43,187 -15,145 -11,7 -58,145zm-16716 6826c57,-32 46,42 53,-46 6,-83 4,-30 -50,-59l-24 38c3,61 -12,24 21,67zm-1337 -2651c26,-6 45,9 64,-22 76,-123 9,-42 -9,-80 -79,40 -46,5 -55,102zm-4578 -6954l-112 -3 26 95c100,-38 42,50 86,-92zm7494 3833c33,-24 94,21 33,-122 -79,33 -50,-52 -63,48 -20,150 -15,19 30,74zm14390 12465c-111,-30 -19,-3 -63,20 25,35 -17,155 117,33 -23,-35 -5,-39 -54,-53zm-16337 1107c45,-72 -18,-62 -21,-68 -108,41 -55,-28 -72,101 119,-4 51,36 93,-33zm-15554 -8727c0,0 54,27 19,-104 -42,1 -53,-32 -84,34 -35,76 50,66 65,70zm29578 -7130c115,91 90,22 123,-26 -55,-41 62,-61 -72,-36 -85,16 -31,37 -51,62zm-15614 10133c9,-12 164,46 4,-107 -159,125 -4,106 -4,107zm19830 6238c-114,-3 -59,-5 -148,25 75,104 35,82 145,96l3 -121zm-1810 -750c-9,127 22,73 56,93 12,-18 98,-37 5,-177 -39,23 -54,-4 -61,84zm-22909 -1701c109,-48 103,-21 88,-170l-170 17 -8 103 28 61 62 -11zm17260 -7459c57,-31 46,52 67,-70 25,-145 -11,-61 -33,-93 -63,23 -49,-63 -69,61 -24,151 20,79 35,102zm6292 8218c-87,10 -54,14 -87,55 20,17 25,52 112,48 129,-7 69,-26 89,-70 -34,-24 -5,-45 -114,-33zm-18726 -12582l138 126c48,-78 33,-47 37,-155 -115,-28 -88,-44 -175,29zm5425 2089c111,8 43,39 121,-26l69 -111c-57,-37 50,-12 -63,-36 -108,-23 -9,-17 -111,3l-16 170zm198 -5155c69,-15 103,32 92,-91 -10,-106 -55,-86 -92,-103 -90,200 -13,173 0,194zm-4497 9344c43,-116 -3,-63 -8,-78 -188,-98 -155,47 -70,159 12,-9 44,9 78,-81zm-14595 -8298l-38 -84 -224 -26c12,84 -6,39 36,90 11,13 12,8 29,28 102,110 -28,62 101,78 134,18 76,-16 96,-27l0 -59zm16516 14224c-52,-40 -1,-28 -113,-50 -42,-8 -243,-19 -291,67 61,129 109,18 238,86 55,29 -10,50 104,69 197,32 132,-42 145,-52 -48,-181 24,-57 -88,-111l5 -9zm-15098 58c48,-69 45,-72 17,-145 -274,51 -23,42 -250,167 30,-165 -35,-99 -48,-113l-11 227 209 8 83 -144zm10508 -4775c27,-191 50,60 -34,-54 -79,69 -75,58 -4,123 52,-55 26,22 38,-69zm2333 -7579c33,-5 126,10 57,-109 -99,52 -45,-60 -57,109zm-3429 -2611c31,53 -29,36 45,50 112,21 8,9 62,-17 -23,-52 62,-39 -41,-56 -76,-13 -10,-11 -66,23zm-5194 1548c20,-171 28,-57 -49,-102 -23,40 -71,-39 -24,67 24,53 -2,25 73,35zm2621 14176c16,-129 6,-16 -31,-67 -97,48 -106,68 -4,128 34,-41 21,52 35,-61zm20632 -662c16,-119 -2,-25 -34,-66 -97,47 -111,67 -4,122 35,-34 23,48 38,-56zm-26456 -15122c101,69 77,57 133,-25 -31,-22 47,-55 -73,-37 -124,19 -29,27 -60,62zm-3048 11843c12,19 -42,82 104,51 57,-12 47,-31 63,-80 -76,-39 -83,-41 -168,-21l1 50zm591 1476c-69,86 -68,86 -5,161 100,-47 96,-110 5,-161zm9675 2249c133,-45 121,-5 97,-144 -185,-45 -50,-16 -91,31 11,22 3,32 23,36 48,12 16,9 52,-9 -40,-40 -1,-18 -75,-27 -8,6 -43,27 -44,28 -15,11 -33,39 -36,33 -3,-7 -13,13 -19,19l7 24c-84,-5 -18,-34 -119,21l-151 150c-8,14 -26,96 -31,118 184,-1 48,-2 196,-98 58,-38 107,-51 184,-77l16 -87 -9 -18zm-10948 -16354c55,-82 67,-123 -24,-178 -4,5 -50,-31 -46,89 4,132 36,74 70,89zm5381 2060c95,56 114,57 192,-23 -74,-75 -188,-91 -192,23zm4150 14183c1,-1 63,-22 50,-106 -19,-117 0,-36 -74,-72 -29,39 -120,103 24,178zm17482 -2004c-59,-80 -103,-72 -188,-35 18,24 -69,68 85,81 140,12 94,-39 103,-46zm-13680 -1675c142,42 170,46 247,-29 -36,-39 -44,-68 -144,-58 -99,9 -73,21 -103,87zm-8064 1221l-36 23c-90,81 -54,49 -55,120 261,25 188,-170 188,-171 -115,-6 33,-13 -60,2l-37 26c8,17 -2,42 39,48 4,1 17,-4 20,-4 4,0 15,-7 22,-10 -39,-35 7,-19 -81,-34zm8634 -1952c-7,-10 54,-79 -138,-26 10,87 13,131 91,196 33,-28 19,-1 54,-52 25,-33 -21,88 23,-39 49,-141 5,-71 -30,-79l-79 -1 26 81 53 -80zm-7088 2769c-11,-74 -89,-139 -148,-163l-44 119 162 129c15,-21 49,45 30,-85zm-7692 303l-2 61 260 28c12,-216 -160,-128 -258,-89zm39584 -10253c14,28 -26,64 64,64 136,1 28,20 79,-60 -44,-33 10,-44 -75,-38 -129,7 -19,-3 -68,34zm419 750c28,-36 34,52 41,-69 10,-164 -9,-60 -36,-101 -30,17 -56,-27 -71,73 -20,138 30,78 66,97zm3065 2473c56,-72 68,-95 66,-198 -91,-19 -135,-38 -154,50 -19,87 88,148 88,148zm2397 -3620c57,42 -53,30 105,50 159,19 21,7 114,-15 -23,-95 27,-126 -112,-132 -103,-5 -87,13 -107,97zm-719 4372l-51 -56c213,-116 93,-238 -69,-239 -152,-1 -176,38 -165,195l14 39c14,128 95,141 226,140l45 -79zm-11881 -7680c95,59 33,27 75,3l-58 -18c-2,-8 -11,11 -17,15zm-21198 15445c35,43 20,47 60,29 5,-2 14,-15 21,-22l-81 -7zm5007 1132l96 3 -5 -58c-40,-60 5,-36 -76,-65 -36,-12 -55,0 -88,1l4 117 69 2zm19325 -14421l-61 0c-35,54 -27,-70 -32,67 -4,136 12,61 31,94 73,-20 31,13 65,-51 145,-35 46,16 95,-68 -56,-55 16,-32 -98,-42zm-8429 13335c40,-6 207,-169 -164,-173 -69,95 -80,76 -101,170l265 3zm14572 -9261c47,74 56,123 127,4 -117,-124 -105,-20 -127,-4zm909 -1194c54,84 169,131 199,-23 -27,-13 -10,-39 -116,-25 -56,7 -60,9 -83,48zm271 2163c-1,-1 33,-56 -85,-37 -108,17 -29,-7 -85,61 38,68 -51,70 108,81 68,5 70,-17 106,-46 -12,-97 2,-33 -44,-59l-117 16 83 62c42,-51 19,-10 34,-78zm2226 -30c105,20 81,29 165,-11 -16,-86 -60,-131 -144,-155 -47,81 -42,60 -21,166zm-680 -1938c37,-37 13,14 35,-54 59,-183 70,-195 20,-219 -8,-1 -19,-13 -23,-4 -2,7 -74,-30 -94,109 -15,102 -7,135 62,168zm-7886 -1031c174,9 174,-15 198,-181l-195 0 -3 181zm-435 -3677c1,-132 -96,-207 -218,-204 -14,163 61,224 218,204zm9068 10670l-229 -20c16,143 33,200 192,182 19,-35 15,-15 28,-68 1,-1 7,-80 9,-94zm-11458 -8654l179 16c42,-78 53,-79 68,-188l-240 -2 -7 174zm-13484 -1787c67,65 -101,40 38,31 69,-5 24,3 66,-28 -78,-63 97,-40 -43,-30 -88,6 4,-25 -61,27zm15318 6420c8,8 -38,96 94,3 -76,-59 111,-37 -40,-27 -71,5 -10,-11 -54,24zm-4361 -5494c0,-176 44,-57 -80,-98 19,110 -24,65 80,98zm5776 11913c-52,-67 -17,-71 -103,-32 75,108 -101,-50 18,46 3,3 12,10 15,12 123,59 36,-5 70,-26zm452 -9192c-15,-105 18,-46 -63,-89 -52,58 -31,28 -37,112l0 24c138,141 57,117 171,135l18 -141 -89 -41zm1300 -184c26,36 -55,67 177,58 3,-16 69,-189 -108,-115 -67,28 -30,-2 -69,57zm-6644 5321c34,-145 -23,-89 -32,-106 -123,17 -85,-60 -108,72 -22,127 47,87 73,100 61,-30 38,56 67,-66zm12539 -5533c161,1 121,36 140,-122 -54,-35 -31,-46 -161,-30 -7,73 -19,77 21,152zm-7899 4642l158 -3 14 -151 -161 -4 -11 158zm-17041 -7038c56,111 45,115 103,4 -51,-38 -52,-32 -103,-4zm9438 14921c155,-74 35,-103 33,-108 -148,63 -33,108 -33,108zm-15491 -14617c76,-131 38,-84 -75,-106 30,146 -22,74 75,106zm2595 5412c114,-55 112,-64 4,-122 -17,20 -39,-42 -33,60 3,62 0,30 29,62zm3715 -5475c14,118 -32,80 93,86 37,-95 5,-127 -93,-86zm-11753 7101c5,-101 4,-86 -99,-104 -13,126 -1,117 99,104zm5801 -8233c54,-27 68,45 62,-68 -4,-75 -27,-45 -58,-61 -3,3 -85,14 -4,129zm-10835 7881c-39,-69 -44,-121 -135,-4 59,52 -68,23 66,40 114,15 28,-3 69,-36zm25397 -740c-39,10 -173,13 -98,95 96,105 85,-47 98,-95zm-2065 8351c-44,-72 23,-10 -75,-89 -65,-51 0,-9 -75,-42l-43 107 31 161c93,9 88,6 142,-32l20 -105zm-14954 -14214c87,52 55,48 150,6 -45,-139 -121,-139 -150,-6zm2674 8593c-160,1 -84,46 -93,80l164 16c3,-66 28,-97 -71,-96zm-12857 -11652c59,116 145,73 198,6 -89,-66 -119,-55 -198,-6zm14729 9603c9,31 -34,70 86,81 110,11 71,-13 85,-75 -84,-44 -68,-38 -171,-6zm-1945 -4959c-113,-51 -59,-57 -140,3l-3 59c93,63 166,72 228,-22 -42,-48 23,-26 -85,-40l-115 0c37,83 -28,35 82,68 55,-38 15,12 33,-68zm1792 10456c-77,-56 -141,-72 -205,23 16,12 15,56 118,47 109,-8 68,-30 87,-70zm6791 -1117c36,-46 36,70 46,-94 7,-128 -25,-68 -41,-87 -41,17 -64,-27 -70,84 -8,141 28,77 65,97zm-19845 -1831c156,4 126,-56 115,-191 -148,47 -119,6 -115,191zm28588 2560c110,14 138,-57 145,-166l-129 10c-85,158 -17,155 -16,156zm-24536 1960c-95,25 -67,45 -79,133 122,4 100,21 190,-35 -16,-82 22,-133 -111,-98zm-1339 -12555c34,-29 35,29 35,-82 -176,-85 -220,125 -198,245 232,90 139,21 169,-18l-8 -56 2 -89zm-138 10191c6,-3 15,-9 19,-11 187,-98 143,-30 160,-133 -81,-49 -97,-61 -235,49 -9,7 -15,11 -19,14l-10 -4 4 8c-6,4 -7,6 -36,41 47,43 -13,15 61,44 105,40 38,-2 56,-8zm8367 -12746l142 -3 16 -140c-106,-42 -55,-43 -174,-39 -38,108 -18,70 16,182zm13540 9864c20,-155 32,-118 -57,-201l-114 -69 -18 135c4,4 38,33 46,42 112,117 -30,57 143,93zm988 4753c45,-102 30,10 24,-135l-34 -127c-82,44 -11,4 -56,41l-47 40c-58,65 -44,-8 -43,128 63,55 64,54 156,53zm12226 -12623c69,-36 48,37 60,-51 10,-77 0,-29 -27,-74 -74,34 -47,-58 -62,47 -11,85 -16,23 29,78zm-541 532c79,-38 56,43 72,-73 18,-134 -33,-95 -37,-102 -62,28 -51,-64 -72,68 -25,164 18,78 37,107zm3249 -2408c42,-47 39,67 47,-70 10,-158 -17,-67 -42,-99 -63,25 -63,-60 -82,69 -19,138 47,86 77,100zm1303 9111c11,10 -39,54 99,53 165,-1 78,-27 103,-76 -9,-7 34,-50 -103,-50 -165,1 -71,41 -99,73zm-492 -3647c92,-9 106,-16 198,-88 -55,-161 -128,-160 -198,-171 8,167 44,104 0,259zm-24691 -3316c88,44 53,43 105,-26 -39,-27 54,-78 -66,-29 -4,1 -13,8 -16,10l-23 45zm13670 13200c-33,-129 32,-90 -103,-104 13,123 -21,66 103,104zm-2530 -391c-27,-47 15,-109 -119,-33 20,31 -50,44 47,68 133,33 20,3 72,-35zm7333 519c-75,-6 -55,36 -70,58 59,45 -67,20 67,36 128,15 17,5 68,-31 -26,-54 53,-54 -65,-63zm-2346 -1174c-21,-102 14,-11 -60,-51 -44,43 -78,-66 -39,94 19,78 18,22 64,42 41,-48 52,-4 35,-85zm-2671 1531c-126,3 -69,18 -100,67 33,26 41,98 222,6 -23,-40 -11,-75 -122,-73zm2389 -1391c-144,-10 -216,11 -140,175l167 10 -27 -185zm-13510 -6878c31,-55 21,-40 2,-86 -55,59 -65,-88 -31,42 34,130 -97,-14 29,44zm11036 7899c51,38 -7,20 62,28 135,17 -26,24 42,-24 -37,-35 3,-24 -62,-33 -162,-23 46,-43 -42,29zm-26597 -10879c61,73 10,58 96,32 -17,-44 72,-25 -24,-45 -72,-14 -22,-8 -72,13zm24876 10019c-12,-82 2,-14 -47,-36 -75,86 -25,63 27,92 24,-39 30,7 20,-56zm-13300 -13607c15,-24 58,33 23,-60 -25,-65 14,-8 -49,-43 -39,74 -64,63 26,103zm-1474 10770c72,-89 63,-70 -26,-109 -102,108 -21,86 26,109zm2490 -9768c5,-7 104,11 5,-148 -45,51 -53,-76 -44,73 5,81 3,34 39,75zm-455 582l45 -26c-21,-109 31,-69 -102,-93 -6,111 -39,73 57,119zm-2646 4875c5,-126 -2,-96 -98,-104 -10,117 -38,124 98,104zm-15387 -7335c19,35 -38,43 63,61 137,25 73,-13 97,-27 -4,-10 32,-155 -160,-34zm3776 14905c-5,113 33,83 36,87 97,-48 94,-123 6,-180 -22,28 -35,-54 -42,93zm16124 -7293c1,0 19,130 175,5 -14,-31 11,-58 -77,-63 -125,-7 -71,37 -98,58zm4168 4706c37,-17 60,39 74,-88 13,-125 -31,-79 -39,-89 -47,21 -58,-27 -69,83 -13,129 25,80 34,94zm-14149 1449c-77,-12 -83,28 -88,128l196 -3c-13,-79 -18,-110 -108,-125zm-10285 -377c145,13 156,25 271,-27 -60,-66 -141,-122 -250,-121l-21 148zm30878 -12750c85,40 49,48 105,-26 -21,-16 24,-117 -105,26zm-4545 -547c57,-82 74,-71 -26,-119 -64,93 -65,76 26,119zm6389 36c29,-122 43,-90 -83,-116 14,175 -39,72 83,116zm5155 2988c-2,44 -19,42 29,111 108,-35 40,70 81,-93 0,-4 -13,-87 -13,-87 -15,-49 -13,-36 -31,-65 -82,38 -49,-29 -66,134zm-7078 -530c164,-26 121,59 106,-100 -107,-4 -71,-31 -106,100zm1288 -911c5,-125 48,-86 -66,-119 -93,100 -38,96 66,119zm2390 12606c-127,16 -58,-23 -117,61 77,102 120,95 117,-61zm-6680 -14484c93,-49 102,-26 33,-126 -50,15 -40,-45 -66,34 -44,140 5,62 33,92zm-6631 -463c15,26 -10,57 69,75 138,31 72,-16 95,-31 -35,-82 47,-48 -66,-68 -28,-5 -93,19 -98,24zm9161 3323c39,-11 65,56 83,-82 13,-103 -26,-89 -48,-112 -98,44 -81,122 -35,194zm11122 7323c77,-41 52,57 68,-98 5,-49 -11,-64 -20,-96 -60,21 -77,-45 -93,90 -16,134 36,91 45,104zm-6944 6433l64 2c34,-109 52,-107 -11,-201 -30,8 -66,-56 -83,91 -7,64 4,48 30,108zm-9852 -12707c33,-80 15,-192 -49,-252 -81,-76 -14,-26 -117,-47 50,222 25,40 89,163 45,86 -28,59 77,136zm17007 11129c-5,-4 -12,-10 -17,-14l-83 -58c-95,106 -8,225 151,201 10,-144 18,-76 -51,-129zm-14522 -7357l6 118 177 17 4 -157c-119,-17 -94,-25 -187,22zm1633 621c122,-9 53,43 137,-39 83,-80 31,-20 54,-141l-180 10 -11 170zm4070 -5651l169 46c4,-99 16,-85 -35,-146 -85,-102 -17,-49 -154,-73l20 173zm10175 12053c-9,-85 14,-6 -49,-52 -37,41 -36,-9 -29,69 9,97 -12,14 55,43 36,-56 37,58 23,-60zm-8682 -13171c38,60 -39,38 51,53 70,12 25,3 66,-21 -12,-32 50,-133 -117,-32zm2108 1273c32,-19 113,33 33,-122 -78,35 -51,-47 -63,49 -17,128 -11,22 30,73zm-2180 -3663l-109 -3c3,174 -2,49 43,106 98,-52 42,59 66,-103zm10237 6624c21,-31 58,42 35,-72 -21,-101 16,-22 -69,-66 -51,93 -65,71 34,138zm-4681 8278c35,-113 -29,-91 -30,-94 -79,35 -36,-30 -61,65 -8,30 9,72 15,95 49,-25 47,25 76,-66zm1712 -10475c90,-11 72,27 105,-40 54,-109 -36,-81 -61,-94l-37 44c-16,57 -18,8 -7,90zm-2159 653c14,19 -15,66 80,65 117,-2 32,5 75,-60 -19,-16 33,-129 -155,-5zm-9160 -4516c140,63 103,33 149,-46 -10,-8 40,-74 -77,-47 -186,44 -11,10 -72,93zm14791 10390c-149,-15 -74,13 -105,64 54,49 -82,47 102,53 102,4 97,-46 99,-47 -19,-29 7,-60 -96,-70zm-7396 -6688c36,71 -84,46 66,62 8,-7 18,-27 22,-21l74 -89c49,-90 34,-58 -1,-120 -66,20 -27,3 -73,42 -5,4 -10,10 -14,14 -5,4 -11,10 -15,14 -4,5 -11,12 -16,16l-35 34 -8 48zm3324 -2979c19,41 -49,73 95,76 129,2 70,-21 95,-70 -7,-6 38,-52 -90,-53 -165,-1 -56,12 -100,47zm-4325 1161l115 0c20,-126 28,-84 -30,-190 -117,49 -118,87 -85,190zm5264 1728c2,-3 48,17 50,-92 4,-170 -3,-48 -44,-101 -39,16 -77,-51 -90,107 -6,77 17,64 84,86zm3736 8908c128,-174 17,-185 7,-200 -65,16 -98,-21 -96,99 2,114 50,86 89,101zm-577 -7651c-69,-84 28,-31 -83,-63 -48,-14 -90,-4 -138,1 13,162 117,193 221,62zm-10793 -2729c100,-7 113,56 202,-184l-174 3 -28 181zm11216 4612c69,-72 58,-54 46,-178l-199 -7c-4,7 -10,9 -12,18l-76 95c135,22 68,-7 161,87l80 -15zm-25273 1701c76,60 6,56 86,3 -47,-47 102,-40 -34,-29 -72,6 -2,-17 -52,26zm-2321 -4177c9,14 14,125 94,3 -55,-40 95,-28 -36,-22 -55,3 -22,-1 -58,19zm3532 6166c27,41 -52,47 54,45 61,-2 15,-6 40,-42 -34,-25 -1,-17 -55,-23 -4,0 -17,14 -19,7 -3,-7 -13,9 -20,13zm9741 -9188c141,-29 65,23 97,-75 -192,-10 -27,11 -97,75zm-816 7909c15,-101 13,5 -29,-35 -126,99 -5,94 -3,96 46,-55 21,17 32,-61zm4719 4713c-57,112 -6,24 19,71 20,-12 147,-53 24,-91 -71,-21 -31,-5 -43,20zm-8123 -431c-15,-86 16,-10 -58,-43 -82,124 13,93 35,107 35,-58 46,63 23,-64zm17490 -3007c175,100 121,20 148,5 -164,-141 -131,-17 -148,-5zm-22342 4930c132,-4 153,38 190,-77l-182 -33c-126,37 -57,-3 -107,63 47,51 -83,44 99,47zm12980 -12068c-5,14 -15,15 -17,31 -2,16 -11,19 -13,27 -5,31 -5,60 -3,92l176 14 5 -153 -148 -11zm3361 -432c36,-54 25,-37 3,-87 -60,72 -60,-95 -28,43 33,144 -77,-37 25,44zm-4106 70c11,-16 62,31 29,-65 -28,-79 21,-7 -55,-51 -27,44 -74,-63 -27,70 23,65 -11,17 53,46zm4107 -1014c83,-42 48,57 62,-54 19,-151 12,-10 -29,-78 -91,38 -53,-66 -66,49 -13,104 -23,19 33,83zm5309 -1291c25,-37 79,60 36,-83 -31,-103 26,-8 -61,-59 -32,47 -82,-71 -36,83 28,92 -21,17 61,59zm1248 6583c63,-42 37,35 61,-54 35,-132 -11,-66 -27,-91 -56,21 -40,-53 -67,42 -50,177 3,67 33,103zm-3413 -2551c58,-28 79,64 73,-75 -5,-112 -22,-47 -68,-70 -1,1 -117,-7 -5,145zm-289 -1224c21,57 -53,42 41,69 192,56 47,8 104,-36 -35,-66 27,-44 -52,-65 -126,-33 -82,23 -93,32zm-5357 -3096l-17 46c15,129 58,97 106,82 112,-34 72,-59 86,-77 -20,-13 -52,-43 -84,-53l-91 2zm8116 7257c60,-74 70,111 51,-114 -8,-94 -53,-80 -74,-93 -6,8 -48,-10 -52,93 -7,206 -24,60 75,114zm-359 -3417c72,-45 54,62 74,-83 26,-183 -17,-80 -38,-116 -80,37 -61,-87 -83,79 -26,191 21,83 47,120zm-17157 14c85,70 -121,41 43,30 71,-4 21,6 65,-27 -62,-52 98,-38 -42,-29 -76,5 -12,-10 -66,26zm5329 -1528c63,-38 47,39 53,-51 6,-102 14,-24 -50,-60 -32,49 -31,66 -3,111zm-27615 8986c78,-36 55,63 54,-88 -92,3 -46,-22 -73,28 -47,83 8,40 19,60zm32764 3684c-10,-69 0,-14 -45,-36 -107,107 -16,74 26,100 17,-35 26,-9 19,-64zm-6439 -13319c1,0 48,9 52,-33 12,-114 25,-19 -48,-68 -16,24 -89,9 -4,101zm-2549 -1315c-16,8 -169,44 -28,87 59,17 35,0 43,-20 45,-117 8,-15 -15,-67zm4501 3122c50,-16 41,47 55,-34 13,-73 3,-24 -23,-71 -53,37 -106,11 -32,105zm100 11390c59,41 -62,18 66,32 141,16 8,10 73,-28 -142,-125 -131,-11 -139,-4zm5906 333c25,-38 129,23 33,-116 -109,38 -98,26 -33,116zm-36586 -127c129,-18 2,10 67,-34 -65,-125 -157,-104 -185,25 57,9 59,18 118,9zm29127 1317c73,-31 40,-35 105,3 -33,22 15,19 -51,22 -115,5 -13,3 -54,-25zm-38 57l179 5 2 -117 -181 -1 0 113zm-23361 -15949c1,-1 61,10 45,-96 -16,-104 8,-33 -69,-78 -13,19 -64,-32 -40,103 21,124 1,35 64,71zm7078 11660c-100,8 -43,-8 -106,52 -56,54 -46,20 -62,107l175 18 -7 -177zm27110 -785c-82,-43 -51,-52 -102,26 49,34 -62,39 58,24 76,-9 15,3 44,-50zm-1210 -2487c86,-45 122,22 32,-114 -51,19 -45,-53 -58,39 -14,86 -25,15 26,75zm849 -5816c135,-10 76,32 106,-104 -166,22 -89,-39 -106,104zm-8045 -1393l58 -2c62,-97 37,-142 -52,-191 -5,7 -124,39 -6,193zm-12585 2344c3,-126 9,-31 -32,-70 -26,25 -18,13 -37,64l38 57 31 -51zm21445 8402c-173,27 -5,161 -5,161 16,-23 33,53 35,-88 2,-133 2,-16 -30,-73zm-7726 -10715c54,-16 48,46 60,-34 13,-86 8,-15 -27,-73 -49,31 -122,-28 -33,107zm2226 -437c29,-40 52,53 35,-69 -16,-105 -3,-34 -60,-73 -13,17 -57,-43 -42,72 16,126 -15,33 67,70zm-13381 14473c-109,-44 -32,2 -69,25 153,144 93,45 120,33 -37,-65 17,-30 -51,-58zm-16107 -2399c103,-46 70,-13 61,-117 -97,10 -54,-27 -90,34 -46,78 29,83 29,83zm6804 -10029c-8,-5 -18,-24 -24,-15l-96 -9c1,144 -24,145 120,124l0 -100c-90,37 -68,-13 -76,69 108,-5 13,38 76,-69zm-11049 7103c12,-112 26,21 -38,-41l7 93c60,-55 22,37 31,-52zm7 -5040c76,71 -99,41 37,30 71,-5 11,13 56,-27 -52,-44 90,-32 -35,-25 -65,4 -11,-8 -58,22zm700 2992c39,30 32,39 86,2 -102,-100 -86,-3 -86,-2zm38577 5667c152,93 87,13 102,3 -42,-43 16,-22 -58,-32l-44 29z"/>
</g>
</g>
<path class="fil3" d="M20432 0c8718,-38 15581,2266 15458,9209 -25,787 -197,745 -1206,434 -5376,-1662 -8578,-3016 -12410,-6248 -1067,-900 -1945,-1797 -2743,-2837 -478,-648 -8,-545 901,-558zm-18416 19428c1785,-4186 2803,-6725 6956,-9214 1814,-1088 3728,-1795 5744,-2401 5011,-1469 9917,-953 14495,540 1697,562 2833,1208 4123,2158 602,450 989,178 1872,-71 402,-113 1572,-529 1912,-305 933,615 -675,1120 -1291,1389 -1798,785 -1256,251 -1265,1780 -43,7030 -7302,11174 -13250,12374 -3409,688 -7029,1146 -9343,1499 -5916,900 -5060,-1185 -9071,-2819 -937,-381 -2531,-341 -2879,-1361 -223,-655 1540,-2751 1997,-3569z"/>
<path class="fil4" d="M10113 32164l548 -85c566,-87 655,-815 655,-1696 0,-1108 -152,-1559 -630,-1485l-573 89 0 3177zm-872 -3711l1541 -239c770,-120 1425,167 1425,1818l0 225c0,1013 -203,2283 -1374,2464l-1592 246 0 -4514z"/>
<path class="fil4" d="M14753 30269l-349 -2047 -421 2167 770 -120zm-922 -2527l1171 -182 1019 4358 -943 146 -216 -1141 -987 152 -274 1218 -935 145 1165 -4696z"/>
<polygon class="fil4" points="17968,31617 17096,31752 17096,27924 16014,28092 16014,27404 19050,26934 19050,27622 17968,27790 "/>
<path class="fil5" d="M24269 30700l88 -3885 -824 115 -106 4640 2357 -329 17 -755 -1532 214zm2552 396l105 -4640 -824 115 -106 4640 825 -115zm3488 -5112l-525 1811c-157,575 -298,1154 -410,1687l-17 3c-83,-518 -193,-1044 -319,-1586l-423 -1679 -1020 143 -395 4680 752 -105 124 -1798c41,-577 85,-1269 109,-1855l17 -3c87,553 207,1162 334,1668l432 1780 624 -87 566 -1950c157,-539 327,-1176 462,-1748l17 -3c-16,472 -6,1216 3,1822l37 1793 785 -109 -139 -4606 -1014 142zm2380 3547l29 -1291 1399 -196 16 -730 -1398 196 25 -1123 1483 -207 17 -748 -2307 322 -106 4640 2396 -334 17 -749 -1571 220z"/>
<path class="fil4" d="M21036 29298l-350 -2048 -420 2166 770 -118zm-923 -2529l1172 -182 1019 4359 -943 145 -217 -1141 -986 152 -274 1218 -936 145 1165 -4696z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.1.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
<style type="text/css">
.st0{fill:#555555;}
</style>
<g>
<path class="st0" d="M476.7,88.3h-88.3c0-48.8-39.5-88.3-88.3-88.3s-88.3,39.5-88.3,88.3h-88.3c-19.5,0-35.3,15.8-35.3,35.3v88.3
C39.5,211.9,0,251.4,0,300.1s39.5,88.3,88.3,88.3v88.3c0,19.5,15.8,35.3,35.3,35.3h123.6v-35.3c0-29.3,23.7-53,53-53s53,23.7,53,53
V512h123.6c19.5,0,35.3-15.8,35.3-35.3V353.1h-35.3c-29.3,0-53-23.7-53-53s23.7-53,53-53H512V123.6
C512,104.1,496.2,88.3,476.7,88.3z M388.4,300.1c0,48.8,39.5,88.3,88.3,88.3v88.3h-88.3c0-31.5-16.8-60.7-44.1-76.5
c-27.3-15.8-61-15.8-88.3,0c-27.3,15.8-44.1,44.9-44.1,76.5h-88.3V353.1H88.3c-29.3,0-53-23.7-53-53s23.7-53,53-53h35.3V123.6
h123.6V88.3c0-29.3,23.7-53,53-53s53,23.7,53,53v35.3h123.6v88.3C427.9,211.9,388.4,251.4,388.4,300.1z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1023 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 889 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
<polyline points="14 2 14 8 20 8" />
<path d="M10 12a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1" />
<path d="M14 18a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1 1 1 0 0 1-1-1v-1a1 1 0 0 0-1-1" />
</svg>

After

Width:  |  Height:  |  Size: 498 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 725 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 996 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 743 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 972 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 665 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 692 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 691 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 537 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1000 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Some files were not shown because too many files have changed in this diff Show More