Author SHA1 Message Date
naeel 9e6e22fadc v0.5.163: полосатость ярче 2026-06-22 15:08:15 +04:00
naeel 7049b5aaa9 v0.5.162: полосатость таблицы, отступы в справке 2026-06-22 15:01:41 +04:00
naeel f709959478 v0.5.161: точный текст справки из help.md + ссылка на сервис 2026-06-22 14:52:13 +04:00
naeel d2c621cf38 v0.5.160: справка под таблицей на главной 2026-06-22 14:42:47 +04:00
naeel 400908985e v0.5.159: fix aggregateCIDRs — баг с 1<<31, склеивание /32 2026-06-18 17:14:20 +04:00
naeel 2d164fb9ee v0.5.158: logout без KC, 401=destroy+redirect SSO 2026-06-17 20:51:12 +04:00
naeel c823ae8128 v0.5.157: resolveContext проверяет имперсонацию через IAM на каждый запрос 2026-06-17 16:20:59 +04:00
naeel b1d670a6bf v0.5.156: 'Нет токена' → редирект на /v2/login 2026-06-17 15:58:38 +04:00
naeel d72bb84d25 v0.5.155: src/auth.js переведён на v2/config IAM_API_BASE 2026-06-17 14:44:04 +04:00
naeel fb17c83e60 v0.5.154: fix приоритет IAM_API_URL, docs/IAM-ENV.md 2026-06-17 14:30:59 +04:00
naeel f60713f127 v0.5.153: приоритет IAM_API_BASE выше IAM_API_URL 2026-06-17 14:03:21 +04:00
naeel 74f805682f v0.5.152: fix приоритет IAM_API_URL выше IAM_API_BASE 2026-06-17 13:51:04 +04:00
naeel 060b20002c v0.5.151: IAM_API_URL → IAM_API_BASE (единая переменная) 2026-06-17 11:35:30 +04:00
naeel 24fb8cdc56 v0.5.150: IAM_API_URL dev→prod, confirm logout 2026-06-17 11:10:52 +04:00
naeel 710dcfc7c9 v0.5.149: kcBaseUrl italo → login.ngcloud.ru 2026-06-17 10:24:16 +04:00
naeel c50ef814e2 v0.5.149: удалён italo из v2/config 2026-06-17 10:15:33 +04:00
naeel 30001062d4 v0.5.149: config.iamApiBase — ENV IAM_API_BASE для переключения IAM API 2026-06-17 10:12:52 +04:00
naeel 92f0314a20 v0.5.148: /v2/iam-gateway-test — сравнение auth-api vs lk-api-gateway 2026-06-17 10:00:53 +04:00
naeel fa75f8317d v0.5.147: новый путь /v2/iam-gateway-test 2026-06-17 10:00:27 +04:00
naeel 90b6c509c0 v0.5.147: /v2/impersonation-check — сравнение auth-api vs lk-api-gateway 2026-06-17 09:46:17 +04:00
11 changed files with 305 additions and 79 deletions
+15
View File
@@ -0,0 +1,15 @@
Доверенные адреса – защита вашего трафика во время 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
@@ -0,0 +1,65 @@
# 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
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ipwhitelist", "name": "ipwhitelist",
"version": "0.5.147", "version": "0.5.163",
"description": "IP WhiteList microservice for cloud provider", "description": "IP WhiteList microservice for cloud provider",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
+4 -3
View File
@@ -25,7 +25,8 @@ const jwt = require('jsonwebtoken');
const crypto = require('crypto'); const crypto = require('crypto');
const https = require('https'); const https = require('https');
const http = require('http'); const http = require('http');
const { IAM_API_URL } = require('./config'); const { iamApiBase } = require('../v2/src/config');
const IAM_API_URL = iamApiBase;
// ── Конфигурация из окружения ──────────────────────────────────────────────── // ── Конфигурация из окружения ────────────────────────────────────────────────
const ISSUER = process.env.JWT_ISSUER || 'mock-auth-api'; const ISSUER = process.env.JWT_ISSUER || 'mock-auth-api';
@@ -286,7 +287,7 @@ function verifyAnyToken(token, devMode) {
*/ */
async function fetchIamUser(token) { async function fetchIamUser(token) {
const raw = await new Promise((resolve, reject) => { const raw = await new Promise((resolve, reject) => {
const url = new URL(IAM_API_URL + '/api/v1/auth/user'); const url = new URL(IAM_API_URL + '/auth/user');
const lib = url.protocol === 'https:' ? https : http; const lib = url.protocol === 'https:' ? https : http;
const req = lib.request({ const req = lib.request({
hostname: url.hostname, hostname: url.hostname,
@@ -343,7 +344,7 @@ async function fetchIamUser(token) {
async function switchProfile(token, profileId) { async function switchProfile(token, profileId) {
const body = JSON.stringify({ profile_id: profileId }); const body = JSON.stringify({ profile_id: profileId });
const raw = await new Promise((resolve, reject) => { const raw = await new Promise((resolve, reject) => {
const url = new URL(IAM_API_URL + '/api/v1/user/switch-profile'); const url = new URL(IAM_API_URL + '/user/switch-profile');
const lib = url.protocol === 'https:' ? https : http; const lib = url.protocol === 'https:' ? https : http;
const req = lib.request({ const req = lib.request({
hostname: url.hostname, hostname: url.hostname,
+2 -2
View File
@@ -13,7 +13,7 @@
// ── IAM API ─────────────────────────────────────────────────────────────────── // ── IAM API ───────────────────────────────────────────────────────────────────
// URL IAM-сервиса для получения профилей пользователя (GET /api/v1/auth/user) // URL IAM-сервиса для получения профилей пользователя (GET /api/v1/auth/user)
// и переключения активной компании (POST /api/v1/user/switch-profile). // и переключения активной компании (POST /api/v1/user/switch-profile).
const IAM_API_URL = (process.env.IAM_API_URL || 'https://auth-api-dev.ngcloud.ru').replace(/\/$/, ''); const IAM_API_URL = (process.env.IAM_API_URL || process.env.IAM_API_BASE || 'https://auth-api.ngcloud.ru').replace(/\/$/, '');
// ── Мок-пользователи (только dev/staging) ───────────────────────────────────── // ── Мок-пользователи (только dev/staging) ─────────────────────────────────────
// В продакшене этот список не используется — вход через Keycloak. // В продакшене этот список не используется — вход через Keycloak.
@@ -94,4 +94,4 @@ function backUrl(isAdmin, companyId, extra = {}) {
// Передаётся в шаблоны через app.locals чтобы отображаться в UI. // Передаётся в шаблоны через app.locals чтобы отображаться в UI.
const { version: APP_VERSION } = require('../package.json'); const { version: APP_VERSION } = require('../package.json');
module.exports = { MOCK_USERS, backUrl, APP_VERSION, IAM_API_URL }; module.exports = { MOCK_USERS, backUrl, APP_VERSION, IAM_API_URL: (process.env.IAM_API_URL || process.env.IAM_API_BASE || 'https://auth-api.ngcloud.ru').replace(/\/$/, '') };
+1 -1
View File
@@ -21,7 +21,7 @@ const http = require('http');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const IAM_API_URL = (process.env.IAM_API_URL || 'https://auth-api.ngcloud.ru').replace(/\/$/, ''); const IAM_API_URL = (process.env.IAM_API_BASE || 'https://auth-api.ngcloud.ru').replace(/\/$/, '');
const APP_URL = (process.env.APP_URL || 'https://italo.kube5s.ru').replace(/\/$/, ''); const APP_URL = (process.env.APP_URL || 'https://italo.kube5s.ru').replace(/\/$/, '');
// ── Парсинг файла с токенами ──────────────────────────────────────────────── // ── Парсинг файла с токенами ────────────────────────────────────────────────
+66 -21
View File
@@ -46,14 +46,14 @@ function createV2Router() {
// ── GET /v2/iam — отладка: показать IAM-данные из сессии ──────────── // ── GET /v2/iam — отладка: показать IAM-данные из сессии ────────────
router.get('/iam', (req, res) => { router.get('/iam', (req, res) => {
const u = req.session && req.session.user; const u = req.session && req.session.user;
if (!u) return res.send('<h2>Нет данных</h2><p><a href="/v2/login">Войти</a></p>'); if (!u) return res.redirect('/v2/login');
res.send(debugPage(u, config.version)); res.send(debugPage(u, config.version));
}); });
// ── GET /v2/iam_response — сырой ответ IAM ───────────────────────── // ── GET /v2/iam_response — сырой ответ IAM ─────────────────────────
router.get('/iam_response', async (req, res) => { router.get('/iam_response', async (req, res) => {
const token = req.session && req.session.token; const token = req.session && req.session.token;
if (!token) return res.send('<h2>Нет токена</h2><p><a href="/v2/login">Войти</a></p>'); if (!token) return res.redirect('/v2/login');
try { try {
const mainAuth = require('../src/auth'); const mainAuth = require('../src/auth');
const iamData = await mainAuth.fetchIamUser(token); const iamData = await mainAuth.fetchIamUser(token);
@@ -67,9 +67,9 @@ function createV2Router() {
// ── GET /v2/iam_response_test — IAM test стенд ────────────────────── // ── GET /v2/iam_response_test — IAM test стенд ──────────────────────
router.get('/iam_response_test', async (req, res) => { router.get('/iam_response_test', async (req, res) => {
const token = req.session && req.session.token; const token = req.session && req.session.token;
if (!token) return res.send('<h2>Нет токена</h2><p><a href="/v2/login">Войти</a></p>'); if (!token) return res.redirect('/v2/login');
try { try {
const raw = await iamFetch(token, 'https://auth-api-test.ngcloud.ru/api/v1/auth/user'); const raw = await iamFetch(token, config.iamApiBase + '/auth/user');
res.set('Content-Type', 'text/html; charset=utf-8'); res.set('Content-Type', 'text/html; charset=utf-8');
res.send(rawPage('IAM TEST', raw)); res.send(rawPage('IAM TEST', raw));
} catch (e) { } catch (e) {
@@ -80,9 +80,9 @@ function createV2Router() {
// ── GET /v2/iam_response_dev — IAM dev стенд ──────────────────────── // ── GET /v2/iam_response_dev — IAM dev стенд ────────────────────────
router.get('/iam_response_dev', async (req, res) => { router.get('/iam_response_dev', async (req, res) => {
const token = req.session && req.session.token; const token = req.session && req.session.token;
if (!token) return res.send('<h2>Нет токена</h2><p><a href="/v2/login">Войти</a></p>'); if (!token) return res.redirect('/v2/login');
try { try {
const raw = await iamFetch(token, 'https://auth-api-dev.ngcloud.ru/api/v1/auth/user'); const raw = await iamFetch(token, config.iamApiBase + '/auth/user');
res.set('Content-Type', 'text/html; charset=utf-8'); res.set('Content-Type', 'text/html; charset=utf-8');
res.send(rawPage('IAM DEV', raw)); res.send(rawPage('IAM DEV', raw));
} catch (e) { } catch (e) {
@@ -90,29 +90,39 @@ function createV2Router() {
} }
}); });
// ── GET /v2/impersonation-check — IAM impersonation: 3 GET эндпоинта ── // ── GET /v2/iam-gateway-test — сравнение auth-api vs lk-api-gateway ──
router.get('/impersonation-check', async (req, res) => { router.get('/iam-gateway-test', async (req, res) => {
const token = req.session && req.session.token; const token = req.session && req.session.token;
if (!token) return res.send('<h2>Нет токена</h2><p><a href="/v2/login">Войти</a></p>'); if (!token) return res.redirect('/v2/login');
const endpoints = [ const base = config.iamApiBase;
{ name: 'status', url: 'https://auth-api.ngcloud.ru/api/v1/impersonation/status' }, const tests = [
{ name: 'history', url: 'https://auth-api.ngcloud.ru/api/v1/impersonation/history' }, {
{ name: 'history/all', url: 'https://auth-api.ngcloud.ru/api/v1/impersonation/history/all' }, name: 'auth-api /auth/user',
url: base + '/auth/user',
},
{
name: 'lk-api-gateway /iam/auth/user',
url: 'https://lk-api-gateway.ngcloud.ru/api/v1/iam/auth/user',
},
{
name: 'auth-api /impersonation/status',
url: base + '/impersonation/status',
},
]; ];
const results = {}; const results = {};
for (const ep of endpoints) { for (const t of tests) {
try { try {
const data = await iamFetch(token, ep.url); const data = await iamFetch(token, t.url);
results[ep.name] = { ok: true, data }; results[t.name] = { ok: true, data };
} catch (e) { } catch (e) {
results[ep.name] = { ok: false, error: e.message }; results[t.name] = { ok: false, error: e.message };
} }
} }
res.set('Content-Type', 'text/html; charset=utf-8'); res.set('Content-Type', 'text/html; charset=utf-8');
res.send(iamStatPage(results)); res.send(iamComparePage(results));
}); });
// ── /v2/app — пользовательский CRUD (только с сессией) ────────────── // ── /v2/app — пользовательский CRUD (только с сессией) ──────────────
@@ -135,8 +145,43 @@ function createV2Router() {
const { createExportRouter } = require('./src/export'); const { createExportRouter } = require('./src/export');
router.use('/export', createExportRouter()); router.use('/export', createExportRouter());
// ── GET /v2/logout — редирект на основной выход ───────────────────── // ── GET /v2/logout — выход только из whitelist (без KC SSO) ─────────
router.get('/logout', (req, res) => res.redirect('/logout')); router.get('/logout', (req, res) => {
const ua = (req.headers['user-agent'] || '').toLowerCase();
const isBrowser = ua.includes('mozilla') || ua.includes('chrome') || ua.includes('safari');
if (!isBrowser) {
req.session.destroy(() => res.redirect('/v2/login'));
return;
}
if (req.query.confirm === 'yes') {
req.session.destroy(() => {
res.set('Content-Type', 'text/html; charset=utf-8');
res.send('<!DOCTYPE html><html><head><meta charset="utf-8"><title>Выход</title>'
+ '<style>body{font-family:sans-serif;background:#111;color:#eee;padding:60px;text-align:center}'
+ 'h2{color:#4fc3f7}p{font-size:18px;margin:20px 0}a{color:#80cbc4;font-size:16px}'
+ '</style></head><body>'
+ '<h2>Вы вышли из сервиса</h2>'
+ '<p>Сессия whitelist завершена.<br>Сессия в других сервисах Nubes сохранена.</p>'
+ '<a href="/v2/app">Войти снова</a>'
+ '</body></html>');
});
return;
}
res.set('Content-Type', 'text/html; charset=utf-8');
res.send('<!DOCTYPE html><html><head><meta charset="utf-8"><title>Выход</title>'
+ '<style>body{font-family:sans-serif;background:#111;color:#eee;padding:40px;text-align:center}'
+ 'h2{color:#4fc3f7}p{font-size:16px;margin:20px 0}'
+ '.btn{display:inline-block;padding:12px 24px;margin:0 10px;border-radius:6px;text-decoration:none;font-size:16px}'
+ '.btn-yes{background:#ef5350;color:#fff}.btn-no{background:#333;color:#eee}'
+ '</style></head><body>'
+ '<h2>Выход из сервиса</h2>'
+ '<p>Вы будете выведены только из whitelist.<br>Сессия в других сервисах Nubes сохранится.</p>'
+ '<a href="/v2/logout?confirm=yes" class="btn btn-yes">Выйти</a>'
+ '<a href="/v2/app" class="btn btn-no">Отмена</a>'
+ '</body></html>');
});
return router; return router;
} }
@@ -187,7 +232,7 @@ function debugPage(u, version) {
</body></html>`; </body></html>`;
} }
function iamStatPage(results) { function iamComparePage(results) {
function fmt(data, depth) { function fmt(data, depth) {
if (data === null || data === undefined) return '<span style="color:#888">null</span>'; if (data === null || data === undefined) return '<span style="color:#888">null</span>';
if (typeof data === 'string') return '<span style="color:#ce93d8">"' + data.replace(/</g, '&lt;') + '"</span>'; if (typeof data === 'string') return '<span style="color:#ce93d8">"' + data.replace(/</g, '&lt;') + '"</span>';
+4 -4
View File
@@ -4,18 +4,18 @@
// ЭТО: все настройки в одном месте. // ЭТО: все настройки в одном месте.
// ЗАЧЕМ: всё что может поменяться между средами — здесь, не размазано по коду. // ЗАЧЕМ: всё что может поменяться между средами — здесь, не размазано по коду.
// //
// ПРИОРИТЕТ: process.env (V2_*) → хардкод-умолчания. // ПРИОРИТЕТ: process.env → хардкод-умолчания.
// Умолчания — для тестовой среды (VM KC на italo.kube5s.ru:8080).
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
module.exports = { module.exports = {
version: '0.5.146', version: '0.5.163',
// ── IAM ────────────────────────────────────────────────────────────────── // ── IAM ──────────────────────────────────────────────────────────────────
iamUrl: process.env.V2_IAM_URL || 'https://auth-api.ngcloud.ru/api/v1/auth/user', iamUrl: process.env.V2_IAM_URL || 'https://auth-api.ngcloud.ru/api/v1/auth/user',
iamApiBase: process.env.IAM_API_BASE || 'https://auth-api.ngcloud.ru/api/v1',
// ── OIDC (Keycloak) ────────────────────────────────────────────────────── // ── OIDC (Keycloak) ──────────────────────────────────────────────────────
kcBaseUrl: process.env.V2_KC_BASE_URL || 'https://italo.kube5s.ru:8080/realms/ipwhitelist', kcBaseUrl: process.env.V2_KC_BASE_URL || 'https://login.ngcloud.ru/realms/ipwhitelist',
kcClientId: process.env.V2_KC_CLIENT_ID || 'ipwhitelist-app', kcClientId: process.env.V2_KC_CLIENT_ID || 'ipwhitelist-app',
kcClientSecret: process.env.V2_KC_CLIENT_SECRET || '', kcClientSecret: process.env.V2_KC_CLIENT_SECRET || '',
appUrl: process.env.V2_APP_URL || 'https://whitelist.nodejsk8s.services.ngcloud.ru', appUrl: process.env.V2_APP_URL || 'https://whitelist.nodejsk8s.services.ngcloud.ru',
+94 -37
View File
@@ -1,49 +1,108 @@
// ═══════════════════════════════════════════════════════════════════════════════ const https = require('https');
// V2 — resolveContext middleware const config = require('../config');
//
// ЭТО: Express middleware — извлекает контекст из сессии.
// НЕ: БД, бизнес-логика.
//
// ЗАЧЕМ:
// 1. Единственное место где сессия превращается в req.* поля.
// 2. user/ и admin/ не парсят сессию сами — получают готовые req.email, req.clientId.
// 3. Поддержка имперсонации (админ действует от имени компании).
//
// ПОРЯДОК:
// 1. Нет сессии → 302 /v2/login
// 2. Обычный юзер → req.email = session.user.email, req.clientId = activeClientId
// 3. Админ → req.isAdmin = true (если adminMode в сессии)
// 4. Имперсонация → req.email = originalUserEmail, req.impersonatedBy = админ
//
// ВЫХОД (поля на req):
// req.email — почта (при имперсонации — originalUserEmail)
// req.clientId — W-номер текущей компании
// req.companyName — название компании
// req.isAdmin — флаг админа
// req.isImpersonated — флаг имперсонации
// req.impersonatedBy — почта админа (null если нет имперсонации)
// req.allClientIds — все W-номера юзера
// req.profiles — профили из IAM
// ═══════════════════════════════════════════════════════════════════════════════
function resolveContext(req, res, next) { /**
* Express middleware — извлекает контекст из сессии.
* При 401 от IAM — чистит сессию и редиректит на логин (SSO).
*/
async function resolveContext(req, res, next) {
const u = req.session && req.session.user; const u = req.session && req.session.user;
// Нет сессии — нет доступа
if (!u) return res.redirect('/v2/login'); if (!u) return res.redirect('/v2/login');
// Имперсонация: админ действует от имени компании const token = req.session && req.session.token;
// u.originalUserEmail — реальный админ if (token) {
// u.impersonatedCompanyId — W-номер компании try {
await checkImpersonation(token, req, u);
} catch (e) {
const msg = (e.message || '');
// 401 — токен протух, чистим сессию → SSO даст свежий
if (msg.startsWith('401')) {
req.session.destroy(() => res.redirect('/v2/login'));
return;
}
// Другие ошибки IAM — игнорируем, используем старые данные
}
}
applyContext(req, u);
next();
}
/**
* Запрашивает IAM и обновляет сессию если имперсонация изменилась.
* Бросает ошибку при 401 — вызывающий обработает.
*/
async function checkImpersonation(token, req, u) {
const url = config.iamApiBase + '/auth/user';
const uObj = new URL(url);
const data = await new Promise((resolve, reject) => {
https.get({
hostname: uObj.hostname, port: 443, path: uObj.pathname,
headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' },
}, res => {
let d = '';
res.on('data', c => d += c);
res.on('end', () => {
if (res.statusCode !== 200) return reject(new Error(res.statusCode + ': ' + d.slice(0, 200)));
try { resolve(JSON.parse(d)); } catch (e) { reject(e); }
});
}).on('error', reject);
});
const imp = data.impersonation || {};
const newIsImp = !!(imp.is_impersonated);
const oldIsImp = !!(u.originalUserEmail && u.impersonatedCompanyId);
// Статус не изменился — ничего не делаем
if (newIsImp === oldIsImp) return;
if (newIsImp) {
// Имперсонация началась — обновляем сессию данными таргета
const profiles = data.profiles || [];
const activeProfile = profiles.find(p => p.is_active_profile) || profiles[0] || {};
const ui = data.userInfo || {};
req.session.user = {
...u,
email: ui.email || '',
clientId: ui.clientID || activeProfile.client_id || '',
allClientIds: profiles.map(p => p.client_id).filter(Boolean),
activeClientId: ui.clientID || activeProfile.client_id || '',
activeProfileId: activeProfile.id || null,
companyId: ui.companyId || '',
companyName: ui.company || activeProfile.company_name || '',
isAdmin: !!ui.isAdmin,
isImpersonated: true,
impersonationType: imp.type || null,
impersonatedCompanyId: imp.impersonatedCompanyId || '',
originalUserEmail: imp.originalUserEmail || u.originalUserEmail || '',
originalUserFullName: imp.originalUserFullName || u.originalUserFullName || '',
originalUserCompany: imp.originalUserCompany || u.originalUserCompany || '',
fio: ui.fio || null,
profiles,
};
} else {
// Имперсонация завершилась — убираем флаги, восстанавливаем оригинальные данные
req.session.user = {
...u,
isImpersonated: false,
impersonationType: null,
impersonatedCompanyId: null,
originalUserEmail: null,
originalUserFullName: null,
originalUserCompany: null,
};
}
}
function applyContext(req, u) {
const isImpersonated = !!(u.originalUserEmail && u.impersonatedCompanyId); const isImpersonated = !!(u.originalUserEmail && u.impersonatedCompanyId);
req.email = u.email || ''; req.email = u.email || '';
// activeClientId может переопределять impersonatedCompanyId при switchTo
req.clientId = isImpersonated req.clientId = isImpersonated
? (u.activeClientId || u.impersonatedCompanyId || u.clientId || '') ? (u.activeClientId || u.impersonatedCompanyId || u.clientId || '')
: (u.activeClientId || u.clientId || ''); : (u.activeClientId || u.clientId || '');
req.companyName = u.companyName || req.clientId; req.companyName = u.companyName || req.clientId;
// Админ: пользователь с WZ01112 — всегда админ
const allCids = u.allClientIds || [u.clientId || '']; const allCids = u.allClientIds || [u.clientId || ''];
req.canAdmin = allCids.includes('WZ01112') || u.clientId === 'WZ01112' || !!u.isAdmin || u.email === (process.env.ADMIN_EMAIL || ''); req.canAdmin = allCids.includes('WZ01112') || u.clientId === 'WZ01112' || !!u.isAdmin || u.email === (process.env.ADMIN_EMAIL || '');
req.adminMode = req.canAdmin; req.adminMode = req.canAdmin;
@@ -52,8 +111,6 @@ function resolveContext(req, res, next) {
req.impersonatedBy = isImpersonated ? u.originalUserEmail : null; req.impersonatedBy = isImpersonated ? u.originalUserEmail : null;
req.allClientIds = allCids; req.allClientIds = allCids;
req.profiles = u.profiles || []; req.profiles = u.profiles || [];
next();
} }
module.exports = { resolveContext }; module.exports = { resolveContext };
+22 -10
View File
@@ -100,23 +100,35 @@ function cidrToRange(cidr) {
// Схлопывание списка CIDR в минимальный набор (для экспорта). // Схлопывание списка CIDR в минимальный набор (для экспорта).
// Пример: 10.0.0.0/24 + 10.0.1.0/24 → 10.0.0.0/23 // Пример: 10.0.0.0/24 + 10.0.1.0/24 → 10.0.0.0/23
function aggregateCIDRs(cidrs) { function aggregateCIDRs(cidrs) {
// 1. Сортируем по start, мержим смежные
const ranges = cidrs.map(c => cidrToRange(c)).sort((a, b) => a.start - b.start); const ranges = cidrs.map(c => cidrToRange(c)).sort((a, b) => a.start - b.start);
const merged = []; const merged = [];
for (const r of ranges) { for (const r of ranges) {
const last = merged[merged.length - 1]; const last = merged[merged.length - 1];
if (last && r.start <= last.end + 1) last.end = Math.max(last.end, r.end); if (last && r.start <= last.end + 1) last.end = Math.max(last.end, r.end);
else merged.push({ ...r }); else merged.push({ start: r.start, end: r.end });
} }
return merged.map(r => { // 2. Каждый merged range → минимальный набор CIDR
let start = r.start, end = r.end, cidrs = []; const result = [];
while (start <= end) { for (const r of merged) {
let maxMask = 32; let start = r.start;
while (maxMask > 0 && ((start & ((1 << (32 - maxMask)) - 1)) !== 0 || (start + (1 << (32 - maxMask)) - 1) > end)) maxMask--; while (start <= r.end) {
cidrs.push(numToIP(start) + '/' + maxMask); // Ищем минимальную маску (самый широкий блок), подходящую под start и end.
start += (1 << (32 - maxMask)); // Идём от 1 до 32, запоминаем минимальную (самую широкую) подходящую.
let bestM = 32;
for (let m = 1; m <= 32; m++) {
if ((1 << (32 - m)) <= 0) continue;
const size = 1 << (32 - m);
if ((start & (size - 1)) !== 0) continue; // не выровнен
if (start + size - 1 > r.end) continue; // выходит за range
bestM = m;
break; // нашли самую широкую
} }
return cidrs; result.push(numToIP(start) + '/' + bestM);
}).flat(); start += (1 << (32 - bestM));
}
}
return result;
} }
function numToIP(n) { function numToIP(n) {
+31
View File
@@ -88,10 +88,16 @@
} }
td:last-child { border-right: none; } td:last-child { border-right: none; }
tr:last-child td { border-bottom: none; } tr:last-child td { border-bottom: none; }
tr:nth-child(even) td { background: #f1f5f9; }
tr:nth-child(even):hover td { background: #e2e8f0; }
tr:hover td { background: #f8fafc; } tr:hover td { background: #f8fafc; }
code { background: #f1f5f9; padding: .15rem .4rem; border-radius: 3px; font-size: .9em; } code { background: #f1f5f9; padding: .15rem .4rem; border-radius: 3px; font-size: .9em; }
.empty { text-align: center; color: var(--muted); padding: 2rem; } .empty { text-align: center; color: var(--muted); padding: 2rem; }
/* Help */
.help p, .help ul { margin-top: .5rem; }
.help ul li { margin-top: .15rem; }
/* Select */ /* Select */
select { select {
padding: .4rem .75rem; border: 1px solid var(--border); border-radius: 6px; padding: .4rem .75rem; border: 1px solid var(--border); border-radius: 6px;
@@ -256,6 +262,31 @@
<% } %> <% } %>
</div> </div>
<!-- Справка -->
<details class="help" style="background:var(--card);border:1px solid var(--border);border-radius:12px;padding:1rem;margin-bottom:1rem;">
<summary style="cursor:pointer;font-weight:600;font-size:.95rem;color:var(--blue);">Доверенные адреса — защита вашего трафика во время DDoS-атак</summary>
<div style="margin-top:.75rem;font-size:.85rem;line-height:1.6;color:var(--text);">
<p>Когда на инфраструктуру провайдера идёт DDoS-атака, системы защиты автоматически фильтруют подозрительный трафик. Иногда под фильтрацию попадает и легитимный трафик, например, запросы из вашего офиса или от ваших партнёров.</p>
<p>Чтобы этого не происходило, вы можете заранее указать адреса, которые провайдер гарантированно не заблокирует. Это называется списком доверенных адресов.</p>
<p style="font-weight:600;margin-top:.75rem;">Как пользоваться</p>
<p><a href="https://whitelist.nodejsk8s.services.ngcloud.ru/" target="_blank">https://whitelist.nodejsk8s.services.ngcloud.ru/</a></p>
<p>Зайти в сервис необходимо с учетной записью keycloak.</p>
<p><strong>Добавить адрес.</strong> Нажмите «Добавить», введите IPv4-адрес или подсеть в формате CIDR и при желании — комментарий, чтобы не забыть, что это за адрес. Например: 203.0.113.10 или 203.0.113.0/24. Нажмите «Сохранить». Если вы вводите адрес хоста с маской подсети (например 203.0.113.5/24 вместо 203.0.113.0/24), сервис автоматически нормализует его до адреса подсети и сообщит вам об этом.</p>
<p>Добавляйте комментарии к записям — через несколько месяцев вы скажете себе спасибо: будет сразу понятно, чей это адрес и зачем он добавлен.</p>
<p><strong>Редактировать или удалить.</strong> Рядом с каждой записью есть кнопки редактирования и удаления. Удалённые записи перестают действовать через 5 минут.</p>
<p style="font-weight:600;margin-top:.75rem;">Ограничения</p>
<ul style="padding-left:1.2rem;margin:.25rem 0;">
<li>Только публичные IPv4-адреса. Серые адреса (192.168.x.x, 10.x.x.x, 172.1631.x.x и другие служебные диапазоны) добавить нельзя — они не маршрутизируются в интернете и смысла в их добавлении нет.</li>
<li>Только IPv4. IPv6-адреса и доменные имена не поддерживаются.</li>
<li>Максимальный размер подсети — /22. Добавить префикс шире /22 (то есть /21, /20 и т.д.) нельзя.</li>
<li>До 15 записей на компанию. Если вам нужно больше — обратитесь в поддержку.</li>
<li>Нет дублей и пересечений. Если вы попытаетесь добавить адрес, который уже покрывается существующей записью (или наоборот), сервис предупредит об этом.</li>
</ul>
</div>
</details>
</div> </div>
<footer style="display:flex;justify-content:space-between;align-items:center;padding:.5rem 1.5rem;font-size:.72rem;color:var(--muted);"> <footer style="display:flex;justify-content:space-between;align-items:center;padding:.5rem 1.5rem;font-size:.72rem;color:var(--muted);">