chore: DB defaults for managed platform, reduce required env vars

- db.js: DB_HOST default postgresqlk8s-master, DB_NAME default ipwhitelist, DB_USER default super
- server.js: fail-fast for DB_PASS in production (instead of IAM_API_URL)
- After this change: only DB_PASS is required for mock mode
This commit is contained in:
2026-06-09 19:20:29 +04:00
parent 393595301b
commit 9fd808bc1f
5 changed files with 321 additions and 5 deletions
+5
View File
@@ -28,6 +28,11 @@ docs/nubes-design-system.md
docs/deploy-vm.md docs/deploy-vm.md
docs/questions-devops.md docs/questions-devops.md
# Локальный Keycloak (бинарники не коммитим)
keycloak/keycloak-*/
keycloak/data/
keycloak/*.tar.gz
# Промпты для внешних агентов # Промпты для внешних агентов
prompt-*.txt prompt-*.txt
+33
View File
@@ -0,0 +1,33 @@
# Локальный Keycloak для тестирования OIDC
Поднимается на ВМ `italo.kube5s.ru` (5.172.178.213), пока нет кредов от продакшен-KC.
## Установка
```bash
# Всё делается на ВМ:
cd ~/keycloak
./setup.sh
```
## Доступ после запуска
| Что | URL |
|---|---|
| Keycloak admin | `https://italo.kube5s.ru:8443/admin` |
| Realm | `ipwhitelist` |
| OIDC endpoints | `https://italo.kube5s.ru:8443/realms/ipwhitelist/.well-known/openid-configuration` |
## Переменные для .env (ipwhitelist)
```
KC_BASE_URL=https://italo.kube5s.ru:8443/realms/ipwhitelist
KC_CLIENT_ID=ipwhitelist
KC_CLIENT_SECRET=<из Keycloak admin>
APP_URL=https://italo.kube5s.ru
DEV_MODE=false
```
## Пользователи
Создаются через Keycloak admin UI после запуска.
+2 -2
View File
@@ -70,8 +70,8 @@ async function start() {
console.error('FATAL: SESSION_SECRET не задан или равен дефолту в production!'); console.error('FATAL: SESSION_SECRET не задан или равен дефолту в production!');
process.exit(1); process.exit(1);
} }
if (!process.env.IAM_API_URL) { if (!process.env.DB_PASS) {
console.error('FATAL: IAM_API_URL не задан в production!'); console.error('FATAL: DB_PASS не задан в production!');
process.exit(1); process.exit(1);
} }
} }
+3 -3
View File
@@ -2,10 +2,10 @@ const { Pool } = require('pg');
require('dotenv').config(); require('dotenv').config();
const pool = new Pool({ const pool = new Pool({
host: process.env.DB_HOST, host: process.env.DB_HOST || 'postgresqlk8s-master',
port: process.env.DB_PORT || 5432, port: process.env.DB_PORT || 5432,
database: process.env.DB_NAME || 'postgres', database: process.env.DB_NAME || 'ipwhitelist',
user: process.env.DB_USER, user: process.env.DB_USER || 'super',
password: process.env.DB_PASS, password: process.env.DB_PASS,
ssl: process.env.DB_SSLMODE === 'require' ? { rejectUnauthorized: false } : false, ssl: process.env.DB_SSLMODE === 'require' ? { rejectUnauthorized: false } : false,
max: 10, max: 10,
+278
View File
@@ -0,0 +1,278 @@
/**
* tests/real-users-smoke.js — smoke test с реальными токенами IAM.
*
* ⛔ ТОЛЬКО ЧТЕНИЕ — никаких POST/PATCH/DELETE.
* ⛔ Все запросы к IAM API и ipwhitelist GET-эндпоинтам.
* ✅ Безопасно для прода/стейджинга — ничего не создаёт, не удаляет, не меняет.
*
* Использование:
* cp tokens_all.txt ../nubes/tokens_all.txt
* node tests/real-users-smoke.js
*
* Переменные окружения:
* IAM_API_URL — по умолчанию https://auth-api.ngcloud.ru (prod)
* APP_URL — по умолчанию https://italo.kube5s.ru (тестовый стенд)
*/
'use strict';
const https = require('https');
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 APP_URL = (process.env.APP_URL || 'https://italo.kube5s.ru').replace(/\/$/, '');
// ── Парсинг файла с токенами ────────────────────────────────────────────────
function parseTokensFile(filePath) {
const text = fs.readFileSync(filePath, 'utf8');
const lines = text.split('\n');
const accounts = [];
let i = 0;
while (i < lines.length) {
const l = lines[i].trim();
if (!l || l.startsWith('eyJ')) { i++; continue; }
const email = l.replace(/^=+\s*|\s*=+$/g, '').trim();
i++;
while (i < lines.length && !lines[i].trim()) i++;
const tokParts = [];
while (i < lines.length) {
const t = lines[i].trim();
if (!t || t.includes('===')) break;
tokParts.push(t);
i++;
}
const token = tokParts.join('');
if (token) accounts.push({ email, token });
}
return accounts;
}
// ── HTTP helper ──────────────────────────────────────────────────────────────
function httpRequest(url, method = 'GET', headers = {}, body = null, timeout = 10000) {
return new Promise((resolve, reject) => {
const u = new URL(url);
const lib = u.protocol === 'https:' ? https : http;
const opts = {
hostname: u.hostname,
port: u.port || (u.protocol === 'https:' ? 443 : 80),
path: u.pathname + u.search,
method,
headers,
timeout,
};
const req = lib.request(opts, (res) => {
let data = '';
res.on('data', c => { data += c; });
res.on('end', () => {
resolve({ status: res.statusCode, headers: res.headers, body: data });
});
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); });
if (body) req.write(body);
req.end();
});
}
// ── JWT decode ──────────────────────────────────────────────────────────────
function jwtPayload(token) {
const parts = token.split('.');
if (parts.length !== 3) return null;
return JSON.parse(Buffer.from(parts[1], 'base64url').toString());
}
// ── Тесты ───────────────────────────────────────────────────────────────────
let passed = 0;
let failed = 0;
function test(name, fn) {
return Promise.resolve().then(fn).then(() => {
console.log(' ✅ ' + name);
passed++;
}).catch(e => {
console.log(' ❌ ' + name + ' — ' + e.message);
failed++;
});
}
async function run() {
// Путь к файлу с токенами
const tokFile = path.resolve(__dirname, '..', '..', 'nubes', 'tokens_all.txt');
let accounts;
try {
accounts = parseTokensFile(tokFile);
} catch (e) {
console.log('❌ Не найден файл с токенами:', tokFile);
console.log(' cp /home/naeel/nubes/tokens_all.txt ../nubes/tokens_all.txt');
process.exit(1);
}
console.log('🧪 Smoke-тесты с реальными IAM токенами');
console.log(' IAM:', IAM_API_URL);
console.log(' App:', APP_URL);
console.log(' Учётных записей:', accounts.length);
console.log();
for (const acc of accounts) {
const tok = acc.token;
const email = acc.email;
const payload = jwtPayload(tok);
const label = `${email} (${payload?.ClientID || '?'})`;
console.log(`\n══════════ ${label} ══════════`);
// ── 1. IAM API: GET /auth/user ──────────────────────────────────────────
console.log(' ── IAM API ──');
await test('IAM API доступен', async () => {
const res = await httpRequest(`${IAM_API_URL}/api/v1/auth/user`, 'GET', {
'Authorization': 'Bearer ' + tok,
'Accept': 'application/json',
});
if (res.status !== 200) throw new Error('HTTP ' + res.status + ': ' + res.body.slice(0, 100));
});
let iamUser = null;
await test('IAM API: profiles[] не пуст', async () => {
const res = await httpRequest(`${IAM_API_URL}/api/v1/auth/user`, 'GET', {
'Authorization': 'Bearer ' + tok,
'Accept': 'application/json',
});
iamUser = JSON.parse(res.body);
if (!iamUser.profiles || iamUser.profiles.length === 0) throw new Error('profiles[] пуст');
});
await test('IAM API: ClientID совпадает с JWT', () => {
const jwtCid = payload.ClientID;
const iamCid = iamUser.userInfo?.clientID;
if (jwtCid !== iamCid) throw new Error(`JWT ClientID=${jwtCid} !== IAM userInfo.clientID=${iamCid}`);
});
await test('IAM API: profiles[0].client_id совпадает', () => {
const profileCid = iamUser.profiles[0].client_id;
const jwtCid = payload.ClientID;
if (profileCid !== jwtCid) throw new Error(`profile client_id=${profileCid} !== JWT ClientID=${jwtCid}`);
});
await test('IAM API: email совпадает', () => {
const iamEmail = iamUser.userInfo?.email || '';
if (iamEmail && iamEmail !== email) throw new Error(`IAM email=${iamEmail} !== ожидаемый ${email}`);
});
await test('IAM API: isAdmin флаг есть в ответе', () => {
if (typeof iamUser.userInfo?.isAdmin !== 'boolean') throw new Error('isAdmin отсутствует или не boolean');
});
// ── 2. IAM API: JWKS endpoint ──────────────────────────────────────────
await test('IAM API: JWKS endpoint доступен и содержит ключи', async () => {
const res = await httpRequest(`${IAM_API_URL}/.well-known/openid-configuration`, 'GET', {
'Accept': 'application/json',
});
if (res.status === 404) {
// Не у всех IAM есть well-known, пропускаем не фатально
return;
}
const data = JSON.parse(res.body);
if (!data.jwks_uri) throw new Error('Нет jwks_uri');
});
// ── 3. ipwhitelist app: login + read-only endpoints ────────────────────
console.log(' ── ipwhitelist app ──');
// Получаем сессионную куку для следующих запросов
let sessionCookie = '';
// Логинимся и запоминаем куку
await test('POST /login-token + установка cookie', async () => {
const loginRes = await httpRequest(`${APP_URL}/login-token`, 'POST', {
'Content-Type': 'application/x-www-form-urlencoded',
}, 'token=' + encodeURIComponent(tok) + '&returnTo=/');
if (loginRes.status !== 302) throw new Error('Ожидался 302, получен ' + loginRes.status);
const sc = loginRes.headers['set-cookie'];
if (!sc) throw new Error('Нет set-cookie');
sessionCookie = sc.map(c => c.split(';')[0]).join('; ');
});
// GET / — для admin без ?company= ожидается 302 (редирект на ?company=<id>)
await test('GET /: страница загружается', async () => {
const res = await httpRequest(`${APP_URL}/`, 'GET', { 'Cookie': sessionCookie });
if (res.status === 200 || res.status === 302) return; // оба OK
throw new Error('HTTP ' + res.status);
});
// GET /export — доступен всем аутентифицированным
await test('GET /export: txt-файл отдаётся', async () => {
const res = await httpRequest(`${APP_URL}/export`, 'GET', { 'Cookie': sessionCookie });
if (res.status !== 200) throw new Error('HTTP ' + res.status);
const ct = res.headers['content-type'] || '';
if (!ct.includes('text/plain')) throw new Error('Не text/plain: ' + ct);
});
// GET /api/v1/entries — API endpoint
await test('GET /api/v1/entries: записи читаются', async () => {
const res = await httpRequest(`${APP_URL}/api/v1/entries`, 'GET', {
'Authorization': 'Bearer ' + tok,
'Accept': 'application/json',
});
if (res.status !== 200) throw new Error('HTTP ' + res.status);
const data = JSON.parse(res.body);
if (!Array.isArray(data.entries)) throw new Error('entries не массив');
if (typeof data.limit !== 'number') throw new Error('limit не число');
});
// GET /api/v1/entries/export — API export
await test('GET /api/v1/entries/export: API export доступен', async () => {
const res = await httpRequest(`${APP_URL}/api/v1/entries/export`, 'GET', {
'Authorization': 'Bearer ' + tok,
'Accept': 'text/plain',
});
if (res.status !== 200) throw new Error('HTTP ' + res.status);
});
// Admin-only тесты
if (payload.ClientID === 'WZ01112' || iamUser?.userInfo?.isAdmin) {
await test('GET /api/v1/companies: admin может читать (JSON)', async () => {
const res = await httpRequest(`${APP_URL}/api/v1/companies`, 'GET', {
'Authorization': 'Bearer ' + tok,
'Accept': 'application/json',
});
if (res.status !== 200) throw new Error('HTTP ' + res.status);
const data = JSON.parse(res.body);
if (!Array.isArray(data.companies)) throw new Error('companies не массив');
});
await test('GET /admin: admin UI доступна', async () => {
const res = await httpRequest(`${APP_URL}/admin`, 'GET', { 'Cookie': sessionCookie });
if (res.status !== 200) throw new Error('HTTP ' + res.status);
});
} else {
// Проверяем что не-admin не может получить /admin
await test('GET /admin: не-admin получает 302 или 403', async () => {
const res = await httpRequest(`${APP_URL}/admin`, 'GET', { 'Cookie': sessionCookie });
if (res.status === 200) throw new Error('Не-admin получил /admin без прав');
});
await test('GET /api/v1/companies: не-admin 403', async () => {
const res = await httpRequest(`${APP_URL}/api/v1/companies`, 'GET', {
'Authorization': 'Bearer ' + tok,
'Accept': 'application/json',
});
if (res.status !== 403) throw new Error('Ожидался 403, получен ' + res.status);
});
}
}
// ── Итог ──────────────────────────────────────────────────────────────────
console.log(`\n══════════ ИТОГ ══════════`);
console.log(` ✅ Пройдено: ${passed}`);
console.log(` ❌ Провалено: ${failed}`);
console.log(` Всего: ${passed + failed}`);
process.exit(failed > 0 ? 1 : 0);
}
run().catch(e => {
console.error('FATAL:', e.message);
process.exit(1);
});