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
naeel 102f4279dc v0.5.147: /v2/impersonation-check — 3 GET запроса к IAM с перехватом ошибок 2026-06-17 09:09:38 +04:00
naeel b7e62f0468 v0.5.147: /v2/impersonation-check — просто текст для проверки пути 2026-06-17 09:05:33 +04:00
naeel 7f8aae0d62 Revert "v0.5.148: move impersonation-check to /v2/app?impersonation-check"
This reverts commit 6899bc40d3.
2026-06-17 09:03:32 +04:00
naeel 6899bc40d3 v0.5.148: move impersonation-check to /v2/app?impersonation-check 2026-06-17 08:58:50 +04:00
naeel 9f44281f9f v0.5.147: add /v2/impersonation-check page with IAM GET endpoints 2026-06-17 08:54:39 +04:00
naeel e68e1a483c v0.5.146: /iam_response_test, /iam_response_dev 2026-06-16 13:50:05 +04:00
naeel 4cd1ab8c8e v0.5.145: /v2/iam_response через mainAuth.fetchIamUser 2026-06-16 13:21:13 +04:00
naeel 10b890437d v0.5.144: /v2/iam_response — сырой ответ IAM 2026-06-16 13:17:16 +04:00
12 changed files with 524 additions and 61 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
+83
View File
@@ -0,0 +1,83 @@
# 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)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ipwhitelist",
"version": "0.5.143",
"version": "0.5.163",
"description": "IP WhiteList microservice for cloud provider",
"main": "server.js",
"scripts": {
+4 -3
View File
@@ -25,7 +25,8 @@ const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const https = require('https');
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';
@@ -286,7 +287,7 @@ function verifyAnyToken(token, devMode) {
*/
async function fetchIamUser(token) {
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 req = lib.request({
hostname: url.hostname,
@@ -343,7 +344,7 @@ async function fetchIamUser(token) {
async function switchProfile(token, profileId) {
const body = JSON.stringify({ profile_id: profileId });
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 req = lib.request({
hostname: url.hostname,
+2 -2
View File
@@ -13,7 +13,7 @@
// ── IAM API ───────────────────────────────────────────────────────────────────
// URL IAM-сервиса для получения профилей пользователя (GET /api/v1/auth/user)
// и переключения активной компании (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) ─────────────────────────────────────
// В продакшене этот список не используется — вход через Keycloak.
@@ -94,4 +94,4 @@ function backUrl(isAdmin, companyId, extra = {}) {
// Передаётся в шаблоны через app.locals чтобы отображаться в UI.
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 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(/\/$/, '');
// ── Парсинг файла с токенами ────────────────────────────────────────────────
+202 -3
View File
@@ -46,10 +46,85 @@ function createV2Router() {
// ── GET /v2/iam — отладка: показать IAM-данные из сессии ────────────
router.get('/iam', (req, res) => {
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));
});
// ── GET /v2/iam_response — сырой ответ IAM ─────────────────────────
router.get('/iam_response', async (req, res) => {
const token = req.session && req.session.token;
if (!token) return res.redirect('/v2/login');
try {
const mainAuth = require('../src/auth');
const iamData = await mainAuth.fetchIamUser(token);
res.set('Content-Type', 'text/html; charset=utf-8');
res.send(rawPage('IAM PROD', iamData.raw));
} catch (e) {
res.send('<h2>Ошибка IAM</h2><pre>' + e.message + '</pre>');
}
});
// ── GET /v2/iam_response_test — IAM test стенд ──────────────────────
router.get('/iam_response_test', async (req, res) => {
const token = req.session && req.session.token;
if (!token) return res.redirect('/v2/login');
try {
const raw = await iamFetch(token, config.iamApiBase + '/auth/user');
res.set('Content-Type', 'text/html; charset=utf-8');
res.send(rawPage('IAM TEST', raw));
} catch (e) {
res.send('<h2>Ошибка IAM Test</h2><pre>' + e.message + '</pre>');
}
});
// ── GET /v2/iam_response_dev — IAM dev стенд ────────────────────────
router.get('/iam_response_dev', async (req, res) => {
const token = req.session && req.session.token;
if (!token) return res.redirect('/v2/login');
try {
const raw = await iamFetch(token, config.iamApiBase + '/auth/user');
res.set('Content-Type', 'text/html; charset=utf-8');
res.send(rawPage('IAM DEV', raw));
} catch (e) {
res.send('<h2>Ошибка IAM Dev</h2><pre>' + e.message + '</pre>');
}
});
// ── GET /v2/iam-gateway-test — сравнение auth-api vs lk-api-gateway ──
router.get('/iam-gateway-test', async (req, res) => {
const token = req.session && req.session.token;
if (!token) return res.redirect('/v2/login');
const base = config.iamApiBase;
const tests = [
{
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 = {};
for (const t of tests) {
try {
const data = await iamFetch(token, t.url);
results[t.name] = { ok: true, data };
} catch (e) {
results[t.name] = { ok: false, error: e.message };
}
}
res.set('Content-Type', 'text/html; charset=utf-8');
res.send(iamComparePage(results));
});
// ── /v2/app — пользовательский CRUD (только с сессией) ──────────────
router.use('/app', enhanceImpersonation, resolveContext, createUserRouter());
@@ -70,12 +145,77 @@ function createV2Router() {
const { createExportRouter } = require('./src/export');
router.use('/export', createExportRouter());
// ── GET /v2/logout — редирект на основной выход ─────────────────────
router.get('/logout', (req, res) => res.redirect('/logout'));
// ── GET /v2/logout — выход только из whitelist (без KC SSO) ─────────
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;
}
// ── Хелпер: HTTP-запрос к IAM ────────────────────────────────────────────────
async function iamFetch(token, url) {
const https = require('https');
const u = new URL(url);
return new Promise((resolve, reject) => {
https.get({
hostname: u.hostname, port: 443, path: u.pathname,
headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' },
}, res => {
let data = '';
res.on('data', c => data += c);
res.on('end', () => {
if (res.statusCode !== 200) return reject(new Error(res.statusCode + ': ' + data.slice(0, 200)));
try { resolve(JSON.parse(data)); } catch (e) { reject(e); }
});
}).on('error', reject);
});
}
function rawPage(title, data) {
return '<!DOCTYPE html>\n<html><head><meta charset="utf-8"><title>' + title + '</title>'
+ '<style>body{font-family:monospace;background:#111;color:#eee;padding:20px}'
+ 'pre{background:#1a1a2e;padding:15px;border-radius:8px;overflow-x:auto;font-size:13px}'
+ 'h2{color:#4fc3f7}a{color:#80cbc4}</style></head><body>'
+ '<h2>Сырой ответ ' + title + '</h2>'
+ '<pre>' + JSON.stringify(data, null, 2).replace(/</g, '&lt;') + '</pre>'
+ '<p><a href="/v2/iam">← Сессия</a> | <a href="/v2/app">CRUD</a></p>'
+ '</body></html>';
}
function debugPage(u, version) {
return `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>V2 — IAM</title>
@@ -92,4 +232,63 @@ function debugPage(u, version) {
</body></html>`;
}
function iamComparePage(results) {
function fmt(data, depth) {
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 === 'number' || typeof data === 'boolean') return '<span style="color:#81c784">' + data + '</span>';
if (Array.isArray(data)) {
if (data.length === 0) return '<span style="color:#888">[]</span>';
let html = '<span style="color:#888">[</span><div style="padding-left:20px">';
for (const item of data) html += '<div>' + fmt(item, depth + 1) + ',</div>';
html += '</div><span style="color:#888">]</span>';
return html;
}
if (typeof data === 'object') {
let html = '<span style="color:#888">{</span><div style="padding-left:20px">';
for (const [k, v] of Object.entries(data)) {
html += '<div><span style="color:#82aaff">"' + k + '"</span>: ' + fmt(v, depth + 1) + '</div>';
}
html += '</div><span style="color:#888">}</span>';
return html;
}
return String(data).replace(/</g, '&lt;');
}
let html = '<!DOCTYPE html><html><head><meta charset="utf-8"><title>IAM Impersonation</title>';
html += '<style>body{font-family:monospace;background:#111;color:#eee;padding:20px;font-size:14px}';
html += 'h2{color:#4fc3f7}.ok{color:#81c784}.err{color:#ef5350}';
html += '.card{background:#1a1a2e;border-radius:8px;padding:15px;margin-bottom:15px;overflow-x:auto}';
html += '.card-title{color:#b39ddb;font-size:18px;font-weight:bold;margin-bottom:10px}';
html += 'a{color:#80cbc4;text-decoration:none}a:hover{text-decoration:underline}';
html += '.nav{display:flex;gap:15px;margin-bottom:20px;flex-wrap:wrap}';
html += '.nav a{background:#1a1a2e;padding:8px 16px;border-radius:6px;font-size:14px}';
html += '</style></head><body>';
html += '<h2>🧪 IAM Impersonation — GET эндпоинты</h2>';
html += '<div class="nav">';
html += '<a href="/v2/iam">← Сессия</a>';
html += '<a href="/v2/app">CRUD</a>';
html += '<a href="/v2/admin">Админка</a>';
html += '<a href="/v2/iam_response">IAM PROD</a>';
html += '<a href="/v2/iam_response_test">IAM TEST</a>';
html += '<a href="/v2/iam_response_dev">IAM DEV</a>';
html += '</div>';
for (const [name, r] of Object.entries(results)) {
html += '<div class="card">';
html += '<div class="card-title">GET /impersonation/' + name + '</div>';
if (r.ok) {
html += '<div style="font-family:monospace;white-space:pre-wrap;tab-size:2">';
html += fmt(r.data, 0);
html += '</div>';
} else {
html += '<div class="err">❌ ' + r.error.replace(/</g, '&lt;') + '</div>';
}
html += '</div>';
}
html += '</body></html>';
return html;
}
module.exports = { createV2Router };
+4 -4
View File
@@ -4,18 +4,18 @@
// ЭТО: все настройки в одном месте.
// ЗАЧЕМ: всё что может поменяться между средами — здесь, не размазано по коду.
//
// ПРИОРИТЕТ: process.env (V2_*) → хардкод-умолчания.
// Умолчания — для тестовой среды (VM KC на italo.kube5s.ru:8080).
// ПРИОРИТЕТ: process.env → хардкод-умолчания.
// ═══════════════════════════════════════════════════════════════════════════════
module.exports = {
version: '0.5.143',
version: '0.5.163',
// ── IAM ──────────────────────────────────────────────────────────────────
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) ──────────────────────────────────────────────────────
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',
kcClientSecret: process.env.V2_KC_CLIENT_SECRET || '',
appUrl: process.env.V2_APP_URL || 'https://whitelist.nodejsk8s.services.ngcloud.ru',
+94 -37
View File
@@ -1,49 +1,108 @@
// ═══════════════════════════════════════════════════════════════════════════════
// V2 — resolveContext middleware
//
// ЭТО: 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
// ═══════════════════════════════════════════════════════════════════════════════
const https = require('https');
const config = require('../config');
function resolveContext(req, res, next) {
/**
* Express middleware — извлекает контекст из сессии.
* При 401 от IAM — чистит сессию и редиректит на логин (SSO).
*/
async function resolveContext(req, res, next) {
const u = req.session && req.session.user;
// Нет сессии — нет доступа
if (!u) return res.redirect('/v2/login');
// Имперсонация: админ действует от имени компании
// u.originalUserEmail — реальный админ
// u.impersonatedCompanyId — W-номер компании
const token = req.session && req.session.token;
if (token) {
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);
req.email = u.email || '';
// activeClientId может переопределять impersonatedCompanyId при switchTo
req.clientId = isImpersonated
? (u.activeClientId || u.impersonatedCompanyId || u.clientId || '')
: (u.activeClientId || u.clientId || '');
req.companyName = u.companyName || req.clientId;
// Админ: пользователь с WZ01112 — всегда админ
const allCids = u.allClientIds || [u.clientId || ''];
req.canAdmin = allCids.includes('WZ01112') || u.clientId === 'WZ01112' || !!u.isAdmin || u.email === (process.env.ADMIN_EMAIL || '');
req.adminMode = req.canAdmin;
@@ -52,8 +111,6 @@ function resolveContext(req, res, next) {
req.impersonatedBy = isImpersonated ? u.originalUserEmail : null;
req.allClientIds = allCids;
req.profiles = u.profiles || [];
next();
}
module.exports = { resolveContext };
+22 -10
View File
@@ -100,23 +100,35 @@ function cidrToRange(cidr) {
// Схлопывание списка CIDR в минимальный набор (для экспорта).
// Пример: 10.0.0.0/24 + 10.0.1.0/24 → 10.0.0.0/23
function aggregateCIDRs(cidrs) {
// 1. Сортируем по start, мержим смежные
const ranges = cidrs.map(c => cidrToRange(c)).sort((a, b) => a.start - b.start);
const merged = [];
for (const r of ranges) {
const last = merged[merged.length - 1];
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 => {
let start = r.start, end = r.end, cidrs = [];
while (start <= end) {
let maxMask = 32;
while (maxMask > 0 && ((start & ((1 << (32 - maxMask)) - 1)) !== 0 || (start + (1 << (32 - maxMask)) - 1) > end)) maxMask--;
cidrs.push(numToIP(start) + '/' + maxMask);
start += (1 << (32 - maxMask));
// 2. Каждый merged range → минимальный набор CIDR
const result = [];
for (const r of merged) {
let start = r.start;
while (start <= r.end) {
// Ищем минимальную маску (самый широкий блок), подходящую под start и end.
// Идём от 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; // нашли самую широкую
}
result.push(numToIP(start) + '/' + bestM);
start += (1 << (32 - bestM));
}
return cidrs;
}).flat();
}
return result;
}
function numToIP(n) {
+31
View File
@@ -88,10 +88,16 @@
}
td:last-child { border-right: 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; }
code { background: #f1f5f9; padding: .15rem .4rem; border-radius: 3px; font-size: .9em; }
.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 {
padding: .4rem .75rem; border: 1px solid var(--border); border-radius: 6px;
@@ -256,6 +262,31 @@
<% } %>
</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>
<footer style="display:flex;justify-content:space-between;align-items:center;padding:.5rem 1.5rem;font-size:.72rem;color:var(--muted);">