474 lines
21 KiB
Markdown
474 lines
21 KiB
Markdown
# AGENT.md — Полное руководство по проекту IPWhiteList
|
||
|
||
> Этот файл предназначен для AI-агентов и разработчиков без контекста.
|
||
> Читай этот файл **первым** перед любой задачей.
|
||
> Обновляй после значимых изменений.
|
||
|
||
---
|
||
|
||
## 1. Суть проекта
|
||
|
||
**IP WhiteList** — веб-приложение для управления белыми IP-списками (CIDR) по компаниям.
|
||
|
||
- Каждая компания имеет свой список IPv4/CIDR записей.
|
||
- Есть лимит записей на компанию (по умолчанию 15, admin может менять).
|
||
- Есть одна роль admin (задаётся через env) и обычные пользователи.
|
||
- Аутентификация через JWT (RS256): в проде — внешний OIDC (Keycloak/Nubes auth-api), в dev — mock.
|
||
- Весь UI — server-side rendered (EJS). API — REST JSON.
|
||
|
||
---
|
||
|
||
## 2. Репозиторий и деплой
|
||
|
||
| Параметр | Значение |
|
||
|---|---|
|
||
| Репо (рабочий) | `https://gitea.services.ngcloud.ru/Nail/ipwhitelist-app.git` ветка `master` |
|
||
| Локальный путь | `/home/naeel/ipwhitelist-app` |
|
||
| Деплой URL | `https://white.nodejsk8s.dev.nubes.ru` |
|
||
| Платформа | Nubes k8s — редеплой через Nubes UI из Gitea после `git push` |
|
||
| Версия | `package.json` → поле `version` (сейчас `0.5.0`) |
|
||
|
||
**Правило версий:** bump `version` в `package.json` при каждом значимом изменении.
|
||
|
||
---
|
||
|
||
## 3. Стек
|
||
|
||
```
|
||
Node.js 18+ + Express 4
|
||
EJS (server-side rendering)
|
||
PostgreSQL 17 (pg pool, SSL require)
|
||
jsonwebtoken RS256
|
||
express-session + connect-pg-simple
|
||
helmet (CSP)
|
||
express-rate-limit
|
||
supertest (тесты)
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Переменные окружения
|
||
|
||
```env
|
||
NODE_ENV=development # production → отключает DEV_MODE, требует SESSION_SECRET
|
||
PORT=3000
|
||
|
||
# Auth
|
||
DEV_MODE=true # true → mock JWT, ЗАПРЕЩЕНО в production
|
||
DEV_SECRET= # опционально: пароль для /dev-login на staging
|
||
ADMIN_CLIENT_ID=WZ01112 # clientId пользователя с правами admin
|
||
JWT_ISSUER=mock-auth-api # issuer в JWT
|
||
|
||
# OIDC (если DEV_MODE=false)
|
||
KC_BASE_URL=https://keycloak.nubes.ru/realms/cloud
|
||
KC_CLIENT_ID= # пусто = mock mode
|
||
KC_CLIENT_SECRET=
|
||
APP_URL=http://localhost:3000
|
||
|
||
# База данных
|
||
DB_HOST=write.bde8229b-1381-4330-b24b-727ad73fcb44.dev.nubes.ru
|
||
DB_PORT=5432
|
||
DB_NAME=ipwhitelist
|
||
DB_USER=super
|
||
DB_PASS=<secret>
|
||
DB_SSLMODE=require
|
||
|
||
# Сессия
|
||
SESSION_SECRET=<secret> # обязателен в production (fail-fast при запуске)
|
||
|
||
# Прочее
|
||
DEFAULT_LIMIT=15 # лимит записей по умолчанию (если нет custom_limit)
|
||
API_BASE=http://localhost:3000 # UI-слой → куда идти за /api/v1/*
|
||
```
|
||
|
||
---
|
||
|
||
## 5. Структура файлов — карта ответственности
|
||
|
||
```
|
||
server.js ← точка входа: helmet, session, монтирование роутеров
|
||
src/
|
||
config.js ← MOCK_USERS, APP_VERSION, backUrl()
|
||
db.js ← pg Pool, checkConnection()
|
||
auth.js ← initAuth() → { middleware, issueMockToken, ... }
|
||
queries.js ← ВСЕ SQL-запросы — единственное место работы с БД
|
||
validators.js ← validate(cidr), overlaps(), aggregateCIDRs(), BLOCKED_RANGES
|
||
api/
|
||
index.js ← createApiRouter({auth, q}) — монтируется на /api/v1
|
||
middleware/
|
||
bearerAuth.js ← requireBearer: нет заголовка → 401 JSON
|
||
routes/
|
||
entries.js ← GET/POST/PATCH/DELETE /api/v1/entries + GET /api/v1/entries/export
|
||
admin.js ← GET /api/v1/companies, PATCH limit, GET /api/v1/audit
|
||
middleware/
|
||
rateLimit.js ← mutationLimiter, exportLimiter, authLimiter
|
||
session.js ← createSessionMiddleware(pool)
|
||
csrf.js ← initCsrf() — НЕ используется в новом UI
|
||
csp.js ← CSP_DIRECTIVES для helmet
|
||
routes/
|
||
export.js ← GET /export — требует авторизации (Bearer)
|
||
exp.js ← ⚠️ ВРЕМЕННЫЙ GET /exp — без авторизации (удалить после prod)
|
||
ui/
|
||
index.js ← createUiRouter({auth, MOCK_USERS, authLimiter})
|
||
api-client.js ← HTTP-клиент к /api/v1/* (берёт Bearer из session.token)
|
||
routes/
|
||
auth.js ← GET/POST /login, /login-token, /logout
|
||
entries.js ← /, /add, /edit/:id, /delete/:id
|
||
admin.js ← /admin, /admin/limit/:id, /audit
|
||
export.js ← /export (UI-обёртка, проксирует к /api/v1/entries/export)
|
||
views/
|
||
index.ejs ← главная: таблица записей + форма добавления
|
||
admin.ejs ← список компаний с лимитами (admin only)
|
||
audit.ejs ← журнал аудита (admin only)
|
||
ui-login.ejs ← страница входа (mock-пресеты + вставить Bearer вручную)
|
||
error.ejs ← 403/404/500
|
||
sql/
|
||
schema.sql ← DDL: CREATE TABLE IF NOT EXISTS (идемпотентно)
|
||
tests/
|
||
integration.js ← интеграционные тесты (supertest + реальный PG)
|
||
run-tests.js ← runner
|
||
```
|
||
|
||
---
|
||
|
||
## 6. Поток запроса — HTTP → БД
|
||
|
||
### 6.1 REST API запрос
|
||
|
||
```
|
||
HTTP Request
|
||
→ server.js: helmet, session, cookieParser
|
||
→ GET /exp → src/routes/exp.js (без auth, временный) ← ТОЛЬКО ЭТО без auth
|
||
→ /api/v1/* → src/api/index.js
|
||
→ requireBearer : нет заголовка Authorization: Bearer → 401 JSON
|
||
→ auth.middleware : валидация JWT → req.user = { clientId, isAdmin, email, ... }
|
||
→ /entries/* → src/api/routes/entries.js
|
||
→ resolveCompany() : admin+?company=id → getCompanyById() | иначе getOrCreateCompany()
|
||
→ q.*() : SQL через pg pool
|
||
→ JSON response
|
||
→ /companies, /audit → src/api/routes/admin.js
|
||
→ apiRequireAdmin() : !isAdmin → 403 JSON
|
||
→ q.*()
|
||
```
|
||
|
||
### 6.2 UI запрос (SSR)
|
||
|
||
```
|
||
HTTP Request
|
||
→ server.js → ui/index.js (createUiRouter)
|
||
→ createAuthRouter : /login, /login-token, /logout — публичные
|
||
→ requireToken : нет session.token → redirect /login
|
||
→ resolveUser : jwt.decode(token) → req.user (без верификации подписи)
|
||
→ createEntriesRouter, createAdminRouter, createExportRouter
|
||
→ ui/api-client.js : HTTP запрос к /api/v1/* с Bearer из session.token
|
||
→ разбирает JSON ответ
|
||
→ res.render('view.ejs', data)
|
||
→ Если API вернул 401 → destroy session → redirect /login
|
||
```
|
||
|
||
---
|
||
|
||
## 7. req.user — структура
|
||
|
||
После прохождения `auth.middleware` (API) или `resolveUser` (UI):
|
||
|
||
```js
|
||
req.user = {
|
||
clientId: 'WZ01112', // из JWT claim ClientID (заглавная!)
|
||
companyId: 'uuid-...', // из JWT claim company_id
|
||
companyName: 'Nubes Admin', // из JWT claim company_name
|
||
email: 'admin@nubes.ru', // из JWT claim email
|
||
isAdmin: true, // clientId === ADMIN_CLIENT_ID
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 8. База данных — схема
|
||
|
||
```sql
|
||
-- Компании (1 строка на WZ-номер, создаётся при первом входе)
|
||
companies (
|
||
id SERIAL PRIMARY KEY,
|
||
client_id VARCHAR(64) UNIQUE NOT NULL, -- WZ01112, WZ01325 и т.д.
|
||
name VARCHAR(255),
|
||
custom_limit INTEGER DEFAULT NULL, -- NULL = использует DEFAULT_LIMIT
|
||
created_at TIMESTAMPTZ,
|
||
updated_at TIMESTAMPTZ
|
||
)
|
||
|
||
-- Записи белого списка (soft delete через deleted_at)
|
||
whitelist_entries (
|
||
id SERIAL PRIMARY KEY,
|
||
company_id INTEGER REFERENCES companies(id),
|
||
value_cidr VARCHAR(18), -- '192.168.1.0/24'
|
||
comment VARCHAR(255),
|
||
created_by VARCHAR(255), -- email
|
||
created_at TIMESTAMPTZ,
|
||
updated_by VARCHAR(255),
|
||
updated_at TIMESTAMPTZ,
|
||
deleted_by VARCHAR(255),
|
||
deleted_at TIMESTAMPTZ -- NULL = активная запись
|
||
)
|
||
-- INDEX: uq_entries_active_cidr(company_id, value_cidr) WHERE deleted_at IS NULL
|
||
|
||
-- Аудит
|
||
audit_log (
|
||
id SERIAL PRIMARY KEY,
|
||
user_email VARCHAR(255),
|
||
company_id INTEGER,
|
||
action VARCHAR(32), -- 'CREATE' | 'UPDATE' | 'DELETE'
|
||
old_value TEXT,
|
||
new_value TEXT,
|
||
entry_id INTEGER,
|
||
created_at TIMESTAMPTZ
|
||
)
|
||
```
|
||
|
||
---
|
||
|
||
## 9. src/queries.js — все функции
|
||
|
||
| Функция | Сигнатура | Описание |
|
||
|---|---|---|
|
||
| `getOrCreateCompany` | `(clientId, companyName) → company` | UPSERT компании по clientId |
|
||
| `getCompanyById` | `(id) → company\|null` | по числовому PK |
|
||
| `getLimit` | `(company) → number` | custom_limit ?? DEFAULT_LIMIT |
|
||
| `listEntries` | `(companyId, includeDeleted=false) → []` | список записей |
|
||
| `createEntry` | `(companyId, rawCidr, comment, email) → {entry, wasNormalized}` | INSERT + audit (транзакция) |
|
||
| `updateEntry` | `(entryId, companyId, rawCidr, comment, email) → {entry, wasNormalized}` | UPDATE + audit (транзакция) |
|
||
| `deleteEntry` | `(entryId, companyId, email) → void` | soft delete + audit (транзакция) |
|
||
| `getAllCompanies` | `() → []` | все компании + active_count |
|
||
| `setLimit` | `(companyId, newLimit) → void` | custom_limit = newLimit |
|
||
| `getExportCIDRs` | `(companyId=null) → string[]` | CIDR строки; null = все компании |
|
||
| `logAudit` | `(email, companyId, action, old, new, entryId, db?) → void` | внутренняя, вызывается из транзакций |
|
||
| `getAudit` | `(companyId=null) → []` | журнал с JOIN companies |
|
||
|
||
---
|
||
|
||
## 10. src/validators.js — функции
|
||
|
||
```js
|
||
validate(rawValue)
|
||
→ { cidr: '1.2.3.0/24', wasNormalized: boolean }
|
||
→ throws Error если невалидно
|
||
// Нормализует host bits, проверяет BLOCKED_RANGES
|
||
|
||
overlaps(cidr1, cidr2) → boolean
|
||
// true если диапазоны пересекаются
|
||
|
||
aggregateCIDRs(cidrArray) → string[]
|
||
// Объединяет пересекающиеся и смежные диапазоны → минимальный набор
|
||
|
||
BLOCKED_RANGES // приватные/зарезервированные диапазоны — запрещены для добавления
|
||
```
|
||
|
||
---
|
||
|
||
## 11. src/auth.js — initAuth()
|
||
|
||
```js
|
||
const auth = await initAuth();
|
||
// Возвращает:
|
||
auth.middleware // Express middleware: валидирует Bearer JWT → req.user
|
||
auth.issueMockToken(claims) → jwtString // только в mock-режиме, для тестов
|
||
auth.jwksHandler // GET /.well-known/jwks.json (только mock)
|
||
// OIDC-режим:
|
||
auth.getAuthUrl(state, nonce) → string // redirect URL на Keycloak
|
||
auth.exchangeCode(code) → {token, user} // обмен code → JWT
|
||
```
|
||
|
||
**Логика определения режима:**
|
||
- `KC_CLIENT_ID && KC_CLIENT_SECRET` → OIDC-режим
|
||
- иначе → mock-режим (генерирует RSA-пару при старте)
|
||
|
||
---
|
||
|
||
## 12. ui/api-client.js — интерфейс
|
||
|
||
```js
|
||
api.token(req) // → Bearer string из req.session.token
|
||
api.get(path, token) // → { status, data }
|
||
api.post(path, token, body) // → { status, data }
|
||
api.patch(path, token, body) // → { status, data }
|
||
api.delete(path, token) // → { status, data }
|
||
// path примеры: '/api/v1/entries', '/api/v1/entries/42', '/api/v1/companies/5/limit'
|
||
```
|
||
|
||
---
|
||
|
||
## 13. Все HTTP эндпоинты
|
||
|
||
### Публичные (без auth)
|
||
| Метод | URL | Файл | Описание |
|
||
|---|---|---|---|
|
||
| GET | `/healthz` | server.js | k8s liveness probe |
|
||
| GET | `/.well-known/jwks.json` | server.js | mock JWKS (только DEV_MODE) |
|
||
| GET | `/login` | ui/routes/auth.js | страница входа |
|
||
| POST | `/login` | ui/routes/auth.js | выбор mock-пользователя |
|
||
| POST | `/login-token` | ui/routes/auth.js | вставить Bearer вручную |
|
||
| GET | `/logout` | ui/routes/auth.js | destroy session |
|
||
| GET | `/callback` | ui/routes/auth.js | OIDC callback |
|
||
| GET | `/exp` | src/routes/exp.js | ⚠️ ВРЕМЕННЫЙ: экспорт без auth |
|
||
|
||
### UI (требуют session.token)
|
||
| Метод | URL | Файл | Описание |
|
||
|---|---|---|---|
|
||
| GET | `/` | ui/routes/entries.js | главная страница |
|
||
| POST | `/add` | ui/routes/entries.js | добавить запись |
|
||
| POST | `/edit/:id` | ui/routes/entries.js | изменить запись |
|
||
| POST | `/delete/:id` | ui/routes/entries.js | удалить запись |
|
||
| GET | `/admin` | ui/routes/admin.js | список компаний (admin) |
|
||
| POST | `/admin/limit/:id` | ui/routes/admin.js | изменить лимит (admin) |
|
||
| GET | `/audit` | ui/routes/admin.js | журнал аудита (admin) |
|
||
| GET | `/export` | ui/routes/export.js | скачать whitelist.txt |
|
||
|
||
### REST API `/api/v1` (требуют Bearer JWT)
|
||
| Метод | URL | Файл | Кто | Описание |
|
||
|---|---|---|---|---|
|
||
| GET | `/api/v1/entries` | api/routes/entries.js | все | список + limit + used |
|
||
| GET | `/api/v1/entries?company=<id>` | api/routes/entries.js | admin | записи другой компании |
|
||
| POST | `/api/v1/entries` | api/routes/entries.js | все | создать |
|
||
| PATCH | `/api/v1/entries/:id` | api/routes/entries.js | все | обновить |
|
||
| DELETE | `/api/v1/entries/:id` | api/routes/entries.js | все | удалить |
|
||
| GET | `/api/v1/entries/export` | api/routes/entries.js | все | text/plain CIDR |
|
||
| GET | `/api/v1/companies` | api/routes/admin.js | admin | все компании |
|
||
| PATCH | `/api/v1/companies/:id/limit` | api/routes/admin.js | admin | установить лимит |
|
||
| GET | `/api/v1/audit` | api/routes/admin.js | admin | журнал аудита |
|
||
|
||
---
|
||
|
||
## 14. Rate limiting
|
||
|
||
| Лимитер | Применяется к | Лимит |
|
||
|---|---|---|
|
||
| `authLimiter` | POST /login, /login-token, GET /callback | 10 req / 5 мин |
|
||
| `exportLimiter` | GET /export, GET /exp, GET /api/v1/entries/export | 20 req / мин |
|
||
| `mutationLimiter` | POST /add, /edit/:id, /delete/:id | 30 req / мин |
|
||
|
||
---
|
||
|
||
## 15. Mock-пользователи (DEV_MODE=true)
|
||
|
||
| id | clientId | Роль | companyName | email |
|
||
|---|---|---|---|---|
|
||
| `admin` | `WZ01112` | **admin** | Nubes Admin | admin@nubes.ru |
|
||
| `test` | `WZ01325` | user | Тест | tazet@narod.ru |
|
||
| `client2` | `WZ02001` | user | Вторая Компания | user2@example.com |
|
||
|
||
Файл: `src/config.js` → `MOCK_USERS`
|
||
|
||
---
|
||
|
||
## 16. Бизнес-логика — важные правила
|
||
|
||
1. **Изоляция компаний**: везде `company_id` в WHERE — данные одной компании недоступны другой.
|
||
2. **Транзакции с FOR UPDATE**: createEntry/updateEntry/deleteEntry блокируют строку компании — защита от race condition при параллельных вставках.
|
||
3. **Soft delete**: `deleted_at IS NULL` = активная запись. Удалённые остаются в БД.
|
||
4. **Дубли/пересечения**: при создании и обновлении проверяются через `overlaps()` против всех активных записей компании.
|
||
5. **Лимит**: снижение лимита не удаляет записи — только блокирует создание новых.
|
||
6. **CIDR нормализация**: `validate()` нормализует host bits (192.168.1.5/24 → 192.168.1.0/24), возвращает `wasNormalized: true`.
|
||
7. **Агрегация при экспорте**: `aggregateCIDRs()` объединяет смежные диапазоны — минимальный набор.
|
||
|
||
---
|
||
|
||
## 17. HTTP коды ошибок API
|
||
|
||
| Код | Когда |
|
||
|---|---|
|
||
| 400 | Невалидный CIDR, некорректные параметры |
|
||
| 401 | Нет/невалидный Bearer токен |
|
||
| 403 | Не admin на admin-endpoint |
|
||
| 404 | Запись/компания не найдена |
|
||
| 409 | Дубликат CIDR, пересечение, лимит исчерпан |
|
||
| 500 | Ошибки БД и прочие |
|
||
|
||
---
|
||
|
||
## 18. Запуск и тесты
|
||
|
||
```bash
|
||
# Установка
|
||
npm install
|
||
|
||
# Запуск локально (нужен .env с DEV_MODE=true + БД)
|
||
node server.js
|
||
|
||
# API-тесты (104 штуки — CRUD, валидация, изоляция, admin, лимиты)
|
||
npm test
|
||
|
||
# Стресс-тесты (191 штука — concurrency, races, boundary IPs, masks, auth attacks)
|
||
npm run test:stress
|
||
|
||
# Старый UI-тест (неактуален, использует /dev-login + CSRF)
|
||
npm run test:integration
|
||
```
|
||
|
||
**Все тесты требуют реального PG подключения** (переменные из `.env`).
|
||
|
||
### Структура тестов (`tests/stress.js`):
|
||
|
||
| Раздел | Что проверяет |
|
||
|---|---|
|
||
| S1 | Concurrency: FOR UPDATE lock, 10-15 параллельных вставок |
|
||
| S2 | Limit enforcement под нагрузкой, setLimit(0) |
|
||
| S3 | Mixed CRUD: concurrent update+delete+create |
|
||
| S4 | Auth attacks: fake key, none algorithm, missing claims |
|
||
| S5 | Edge cases: emoji, null byte, null/''/undefined |
|
||
| S6 | Validator: overlaps, 14 blocked ranges |
|
||
| S7 | Session vs Bearer conflict (bearerMiddleware) |
|
||
| S8 | All CIDR masks /0–/33 (34 теста) |
|
||
| S9 | IP boundary values: 0.0.0.0 … 255.255.255.255 (32 теста) |
|
||
| S10 | Token manipulation: expired, wrong issuer, future, no claims |
|
||
| S11 | DB: double delete, update deleted, isolation |
|
||
| S12 | Unicode: full-width, zero-width, RTL, NFC/NFD |
|
||
| S13 | Admin privilege escalation |
|
||
| S14 | Session: login→logout, cookie reuse |
|
||
| S15 | Rate limit /exp, /login |
|
||
| S16 | Export edge cases |
|
||
| S17 | Massive concurrency: 50 createEntry, 30 delete, 20 getOrCreate |
|
||
| S18 | CIDR normalization: host bits обнуление |
|
||
| S19 | Each blocked range individually (14) |
|
||
| S20 | Sequential CRUD patterns |
|
||
| S21 | Admin cross-company CRUD |
|
||
| S22 | Weird CIDR input: пробелы, табы, \n, 256, IPv6 (20) |
|
||
|
||
---
|
||
|
||
## 19. Известные проблемы (баги) — исправлены в v0.5.0
|
||
|
||
| Проблема | Статус |
|
||
|---|---|
|
||
| `auth.middleware` читал `req.session.user` перед Bearer → Bearer-only API нарушалось | ✅ Добавлен `bearerMiddleware` |
|
||
| Mock admin `isAdmin: undefined` в сессии | ✅ `user.role === 'admin'` |
|
||
| Сброс лимита (пустое поле → NaN → 400) | ✅ `null` при пустом поле |
|
||
| `setLimit()` не проверял `rowCount` → 404 на неизвестную компанию | ✅ 404 с `e.status` |
|
||
| Версия не поднималась при правках | ✅ `0.4.1` → `0.5.0` (9 бампов) |
|
||
| CSP/ingress блокировал inline-обработчики (onclick, onchange) | ✅ Event delegation через `<script>` |
|
||
| Admin при `/` редиректился на первую компанию, но не на свою | ✅ Авто-редирект на компанию admin по `clientId` |
|
||
| Даты в UTC вместо МСК | ✅ `timeZone: 'Europe/Moscow'` |
|
||
| Фильтр аудита: `onchange` ненадёжен | ✅ Кнопка «Применить» + `Number()` сравнение |
|
||
| `/admin` → 500 из-за отсутствия `defaultLimit` в шаблоне | ✅ Передаётся в `render()` |
|
||
|
||
## 20. ⚠️ ВРЕМЕННЫЕ ЭЛЕМЕНТЫ — удалить перед продом
|
||
|
||
### GET /exp — публичный экспорт без авторизации (добавлен v0.4.9)
|
||
|
||
**Причина:** По ТЗ п. 4.7 `/export` должен быть публичным, но сейчас требует авторизацию. Создан временный `/exp` как workaround.
|
||
|
||
**Что удалить:**
|
||
1. Файл `src/routes/exp.js` — полностью
|
||
2. В `server.js` строку: `app.use(require('./src/routes/exp'));`
|
||
|
||
**Когда:** когда `/export` сделают публичным по ТЗ.
|
||
|
||
---
|
||
|
||
## 21. Расхождения с ТЗ (открытые задачи)
|
||
|
||
| Пункт ТЗ | Суть | Статус |
|
||
|---|---|---|
|
||
| 3.1 | Несколько компаний на пользователя | Не реализовано — ждём devops |
|
||
| 4.1 | Отображение soft-deleted для admin | Не реализовано |
|
||
| 4.7 | `/export` без авторизации | Обход: `/exp` (временный) |
|