Files
ipwhitelist-app/AGENT.md
T

21 KiB
Raw Blame History

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. Переменные окружения

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):

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. База данных — схема

-- Компании (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 — функции

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()

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 — интерфейс

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.jsMOCK_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. Запуск и тесты

# Установка
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.10.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 (временный)