Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eea3aa752a |
@@ -7,10 +7,6 @@ __pycache__/
|
||||
.vscode/
|
||||
.idea/
|
||||
*.har
|
||||
.venv/
|
||||
|
||||
# Вложенный отдельный git-репозиторий (свой remote: gitea.services.ngcloud.ru/Nail/getpgdata.git)
|
||||
getpgdata/
|
||||
|
||||
# Креды, токены, конфиги
|
||||
token*.*
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
# Инцидент 2026-07-08: PostgreSQL OOM + EPERM
|
||||
|
||||
## Итоговая цепочка
|
||||
|
||||
```
|
||||
0.1.13 деплой
|
||||
→ fs.readdirSync throws ENOENT (sql/migrations/ не в образе)
|
||||
→ process.exit(1)
|
||||
→ k8s CrashLoopBackoff, rapid restarts
|
||||
→ каждый рестарт: новый pool + тяжёлый DO$$ блок на PG
|
||||
→ 20-30 concurrent connections + ALTER locks
|
||||
→ PG 512 MB → 134% OOM → PG крашится
|
||||
→ NodeJS теряет PG → дальнейшие рестарты уже без PG
|
||||
→ после recovery PG: stale iptables на ноде NodeJS
|
||||
→ EPERM до полного обновления kube-proxy
|
||||
→ редеплой NodeJS → чистый pod → работает
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Детальный разбор
|
||||
|
||||
### 1. Почему PG ушёл в OOM?
|
||||
|
||||
**512 MB = катастрофически мало для PG 17 с этой нагрузкой.** Zalando operator выставляет `shared_buffers = 25% RAM = 128 MB`. Каждое подключение стоит ~5–10 MB.
|
||||
|
||||
Триггеры при деплое 0.1.13:
|
||||
|
||||
**a) Rolling update → двойное число подключений.** Пока k8s поднимал новый под, старый ещё жил. Новый под открывает pool (10 соединений) плюс старый не успел корректно закрыть свой (`process.exit(1)` без `pool.end()`). PG видел 20–30 соединений → +100-200 MB.
|
||||
|
||||
**b) DO $$ блок в `schema.js`.** При каждом старте делал `ALTER TABLE ... DROP CONSTRAINT` + `ADD CONSTRAINT` (AccessExclusiveLock + rewrite). Несколько подов подряд → очередь блокировок → спайк памяти.
|
||||
|
||||
**c) connect-pg-simple + `saveUninitialized: true`.** Health-check'и создавали пустые сессии (вторичный фактор).
|
||||
|
||||
### 2. EPERM после recovery
|
||||
|
||||
`EPERM` на TCP-коннекте — ошибка **ядра** на уровне iptables/netfilter.
|
||||
|
||||
После recovery PG-под получил новый Pod IP. k8s Service IP (`10.102.125.70`) не менялся, но `kube-proxy` должен был обновить iptables-правила — процесс асинхронный.
|
||||
|
||||
NodeJS делал `connect(10.102.125.70:5432)`, iptables пытался перенаправить на старый Pod IP PG (уже не существующий) → ядро возвращало `EPERM`.
|
||||
|
||||
DNS здесь ни при чём — `svc.cluster.local` резолвился правильно.
|
||||
|
||||
### 3. Как правильно делать readiness probe?
|
||||
|
||||
**`/ready` всегда 200 — правильное решение.** Если `/ready` возвращает 503:
|
||||
1. Все поды → NotReady
|
||||
2. nginx → 503
|
||||
3. k8s перезапускает поды
|
||||
4. Новые поды создают соединения к нагруженному PG
|
||||
5. **Каскадный сбой**
|
||||
|
||||
Модель:
|
||||
```
|
||||
/healthz → всегда 200 (liveness)
|
||||
/ready → всегда 200 (readiness)
|
||||
+ страницы показывают user-friendly ошибку
|
||||
```
|
||||
|
||||
### 4. 6202 записей session — нормально ли?
|
||||
|
||||
Да. 6202 / 8 часов = ~775/час = ~13/минута. Для многокомпанийного сервиса разумно.
|
||||
|
||||
`saveUninitialized: true` создаёт сессии от ботов/сканеров. Для PG не критично: 6202 × ~5 KB ≈ 31 MB.
|
||||
|
||||
### 5. `fs.readdirSync` в managed k8s
|
||||
|
||||
`fs` доступен, но SQL-файлы должны быть в Docker-образе. Если `.dockerignore` их исключил → `ENOENT` → crash → k8s рестарты → OOM PG.
|
||||
|
||||
---
|
||||
|
||||
## Что починили
|
||||
|
||||
1. `process.exit(1)` убран везде — приложение не падает
|
||||
2. `/ready` всегда 200 — nginx не блокирует трафик
|
||||
3. Ошибки показываются на странице
|
||||
4. PG: 512 MB → 2048 MB
|
||||
|
||||
---
|
||||
|
||||
## Рекомендации
|
||||
|
||||
1. **DO $$ DROP+ADD CONSTRAINT** — запускать один раз, проверять через `pg_constraint`
|
||||
2. **PG memory** — минимум 1 GB для production
|
||||
3. **`saveUninitialized: false`** для публичных роутов
|
||||
4. **SQL-миграции** — включить в образ явно или встроить в js-строки
|
||||
5. **`pool.end()`** — уже реализован в SIGTERM, но при `process.exit(1)` без SIGTERM этого не было
|
||||
@@ -1,86 +0,0 @@
|
||||
# Kuma / алерты на сбой БД — сессия 2026-08-22
|
||||
|
||||
> Итог: задача (оповещения о сбое связи с БД) **поставлена на паузу**. Ниже — полная история, находки, причины и варианты. Kuma на ВМ 213 **откачена**.
|
||||
|
||||
## 1. Задача
|
||||
|
||||
Нужно личное оповещение (почта / Telegram) при сбое связи приложения `whitelist.nodejsk8s.services.ngcloud.ru` с БД. Рассматривался Uptime Kuma как мониторинг.
|
||||
|
||||
## 2. Что было развёрнуто и где
|
||||
|
||||
### Kuma на кластере `example-timasbxz` (уже существовала, не я)
|
||||
- URL: `https://kum.containerk8s.dev.nubes.ru`
|
||||
- Контейнер-сервис Nubes: deployment `containerk8s`, образ `louislam/uptime-kuma:1`
|
||||
- namespace `5f9245f9-b813-4886-9997-1dd0a85de7e8`, под `containerk8s-595c6c8c5-hsctn`
|
||||
- БД: managed MariaDB `mariak8s.5e053e2c-795d-4dfb-a872-0f98cc65d455.svc.cluster.local`
|
||||
- user `kum`, db `kumbase`, пароль в Nubes UI (`kumpasswd` — тестовый)
|
||||
- Монитор `IPWhitelist DB`: `HTTP(s) - Слово`, URL `https://whitelist.nodejsk8s.services.ngcloud.ru/v2/db-status`, слово `Connected` → **работает (UP)**
|
||||
|
||||
## 3. Найденные проблемы (факты)
|
||||
|
||||
### 3.1 Telegram заблокирован в сети Nubes/провайдера
|
||||
- Из пода Kuma лог: `connect ETIMEDOUT 149.154.166.110:443` (api.telegram.org).
|
||||
- `api.telegram.org` резолвится в `149.154.166.110` — **заблокирован** (timeout).
|
||||
- Рабочий IP Telegram: **`149.154.167.220`** — открыт (curl `--resolve` → HTTP 302).
|
||||
- Блокировка **выборочная по IP-диапазонам Telegram** (149.154.x частично, 91.108.x), не весь egress.
|
||||
- С личной машины (WSL) Telegram доступен; из кластеров Nubes и ВМ 213 — нет.
|
||||
|
||||
### 3.2 Кластеры Nubes — нестабильный канал до API
|
||||
- `example-timasbxz` (185.247.187.234) и `iot-naeel` (185.247.187.149) — оба:
|
||||
- обрезают большие ответы API на ~21–30 KB (JSON неполный);
|
||||
- HTTP/2 падает (`http2: client connection lost`), `kubectl`/`exec` зависают;
|
||||
- малые ответы (metrics, отдельные ноды) стабильны (~0.2 с).
|
||||
- Это общий сетевой слой Nubes (обе машины в подсети 185.247.187.x) — не конкретный кластер.
|
||||
- Ресурсы кластера example в порядке: 16 CPU / 32 GB, занято ~11% CPU / ~29% RAM, ноды Ready.
|
||||
|
||||
### 3.3 egress (исходящий) в кластерах
|
||||
- example: NetworkPolicy/CiliumNP/CCNP = **0** (нет политик). Egress к whitelist работает (монитор UP), к Telegram — нет.
|
||||
- iot-naeel: `deny-cross-tenant` — только `policyTypes: ["Ingress"]`, egress не ограничивает; образы из интернета тянутся.
|
||||
|
||||
## 4. Инфраструктура (обследовано)
|
||||
|
||||
### ВМ 213 = `5.172.178.213` (naeel-vm, отладочная)
|
||||
- SSH: `ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213`
|
||||
- На 3001 крутится `node /home/naeel/ipwhitelist/server.js` (whitelist-приложение).
|
||||
- Docker есть (29.1.3); контейнеры: `keycloak` (8080), `llmui` (8888).
|
||||
- nginx на 80/443 (server_name 5.172.178.213 → proxy llmui:8888; домены check.kube5s.ru, obdai.ru).
|
||||
- Telegram: **недоступен** (таймаут). Доступны: google, whitelist, sms.ru, smsc.ru, SMTP (smtp.yandex.ru/gmail/mail.ru).
|
||||
|
||||
### VULTR (Германия) = `95.179.252.111` (NaeelVultr) — КРИТИЧНЫЙ (VPN)
|
||||
- SSH: `ssh -i ~/.ssh/vultr_openssh root@95.179.252.111` (исходный ключ `C:\SOFT\vultr.ppk`, сконвертирован в `~/.ssh/vultr_openssh`).
|
||||
- **VPN = Xray** (слушает 8443 и 127.0.0.1:45987) + nginx (80/443) + gunicorn/python (8765/8766).
|
||||
- **Telegram доступен нативно**: `https://api.telegram.org/` → HTTP 302 за 0.09 с.
|
||||
- Ubuntu 22.04, Docker НЕТ, 951 MB RAM, 12 GB свободно.
|
||||
|
||||
## 5. Каналы алертов — сравнение
|
||||
|
||||
| Канал | Доступность из сети | Цена | Примечание |
|
||||
|---|---|---|---|
|
||||
| Telegram | ❌ из Nubes/ВМ 213; ✅ из VULTR и личной машины | 0 ₽ | нужен рабочий IP 149.154.167.220 или выход через VULTR |
|
||||
| Email (SMTP) | ✅ smtp.yandex.ru:465/587 | 0 ₽ | ящик `tazet@narod.ru` + app password |
|
||||
| SMS (smsc.ru) | ✅ 200 за 0.06 с | Мегафон 11.10 ₽, Т2 9.80 ₽, «остальные» 0.50 ₽ | sms.ru тоже доступен |
|
||||
| SMS (междунар. Twilio/46elks/Plivo/ClickSend) | ✅ (Vonage ❌) | платно | для зарубежных номеров |
|
||||
|
||||
## 6. Креды (СЕКРЕТЫ — не коммитить в публичные репо)
|
||||
|
||||
- Telegram-бот: token `8690845859:AAFxjaWe55c1XfeB-hAjeKR3DIsXMQe67lA`, chat_id `378620327`, бот `@wl314159bot`.
|
||||
- Почта (Yandex/narod): `tazet@narod.ru`, app password `msxaxwmjgrbfbqlq`.
|
||||
- SSH: `~/.ssh/naeel_vm_id_ed25519` (ВМ 213), `~/.ssh/vultr_openssh` (VULTR).
|
||||
- Номер получателя: Т2, зарегистрирован в Ульяновске (точный номер не зафиксирован в этой сессии).
|
||||
|
||||
## 7. Что сделано и откачено
|
||||
|
||||
- Kuma была развёрнута на ВМ 213 (Docker): контейнер `uptime-kuma`, порт `3002→3001`, том `/opt/uptime-kuma`.
|
||||
- **ОТКАЧЕНО**: контейнер остановлен и удалён, том удалён, образ `louislam/uptime-kuma:1` удалён, правило ufw 3002 удалено. Порт 3002 свободен. Другие сервисы ВМ 213 (whitelist:3001, keycloak, llmui, nginx) НЕ тронуты.
|
||||
|
||||
## 8. Выводы и варианты на будущее
|
||||
|
||||
1. **Telegram** — рабочий путь: Kuma на VULTR (Германия), там Telegram нативно работает. Но VULTR критичен (VPN), установка требует аккуратности (без Docker).
|
||||
2. **Email** — самый простой и безопасный: Kuma где угодно с доступом к smtp.yandex.ru (например, ВМ 213), бесплатно.
|
||||
3. **SMS** — платно, но надёжно: smsc.ru на Т2 (~9.80 ₽/СМС).
|
||||
4. Проблема обрезки API кластеров Nubes (~21–30 KB) — на уровне сетевого слоя, адресовать девопсам.
|
||||
|
||||
## 9. Ключевые технические детали (для повторного захода)
|
||||
|
||||
- Монитор whitelist: `HTTP(s) - Слово`, URL `https://whitelist.nodejsk8s.services.ngcloud.ru/v2/db-status`, слово **`Connected`** (не `PG Diagnostic OK` — в HTML между ними тег `<span>`).
|
||||
- Kuma на кластере уже настроена и работает (монитор UP), осталось заменить канал уведомления.
|
||||
@@ -1,33 +0,0 @@
|
||||
# Консультация с Соннетом: инцидент 2026-07-08 (продолжение)
|
||||
|
||||
## Вопрос 1: Может ли PG быть повреждён после OOM при нормальных метриках?
|
||||
|
||||
Да, может. Сценарий:
|
||||
- OOM при активной записи → WAL-запись оборвана на середине
|
||||
- PG поднялся, прошёл recovery по WAL, считает себя здоровым
|
||||
- Grafana показывает метрики процесса PG — они нормальные
|
||||
- Но на уровне отдельных relation-файлов могут быть битые страницы, которые проявляются только при обращении к конкретной таблице/индексу
|
||||
|
||||
EPERM при `connect()` — это не PG-уровневая ошибка, это OS/сеть. PG повреждение дало бы `ECONNRESET`, `53300` (too many connections), или ошибку внутри сессии. Если EPERM именно на `connect()` — смотри в сторону сети/платформы, не PG internals.
|
||||
|
||||
## Вопрос 2: Достаточно ли разницы PG для объяснения?
|
||||
|
||||
Нет. EPERM на `connect()` — это не симптом повреждённого PG. Повреждённый PG дал бы:
|
||||
- `FATAL: could not open file`
|
||||
- `ERROR: invalid page in block`
|
||||
- Или просто отклонял бы соединения с ошибкой pg-level
|
||||
|
||||
EPERM — это ядро Linux отказывает в системном вызове `connect()`. PG здесь ни при чём. Ищи причину на стороне Node.js процесса или платформы.
|
||||
|
||||
## Вопрос 3: Мог ли managed-сервис перегенерировать credentials при recovery?
|
||||
|
||||
Да, это распространённая практика у managed PG — при `recovery` (особенно из backup/snapshot) могут:
|
||||
- Сброситься пароли к дефолтным
|
||||
- Создаться новый пользователь вместо старого
|
||||
- Измениться SSL CA-сертификат
|
||||
|
||||
Как проверить без прямого доступа — через `/v2/db-status` (уже есть в коде)
|
||||
|
||||
## Ключевой вывод
|
||||
|
||||
EPERM на connect() — проблема сети/платформы, не PG и не нашего кода.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ipwhitelist",
|
||||
"version": "0.1.26",
|
||||
"version": "0.1.13",
|
||||
"description": "IP WhiteList microservice for cloud provider",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -57,45 +57,27 @@ app.use(cookieParser());
|
||||
async function start() {
|
||||
const auth = await initAuth();
|
||||
|
||||
// ── Стоп-кран: если запрос висит > 10 сек — проверяем PG и показываем детали
|
||||
app.use((req, res, next) => {
|
||||
const timer = setTimeout(async () => {
|
||||
if (!res.headersSent) {
|
||||
let diag = '';
|
||||
try {
|
||||
const { pool } = require('./src/db');
|
||||
const start = Date.now();
|
||||
await pool.query('SELECT 1');
|
||||
diag = 'PG OK (' + (Date.now() - start) + 'ms)';
|
||||
} catch (e) {
|
||||
diag = 'PG ERROR: ' + e.message;
|
||||
}
|
||||
res.status(503).type('html').send(
|
||||
'<h2>Сервис временно недоступен</h2>'
|
||||
+ '<p>Таймаут запроса: 10 сек</p>'
|
||||
+ '<p>URL: ' + req.method + ' ' + req.url + '</p>'
|
||||
+ '<p>' + diag + '</p>'
|
||||
);
|
||||
}
|
||||
}, 10000);
|
||||
res.on('finish', () => clearTimeout(timer));
|
||||
next();
|
||||
});
|
||||
|
||||
// ── Сессии ────────────────────────────────────────────────────────────────
|
||||
const { pool } = require('./src/db');
|
||||
app.use(createSessionMiddleware(pool));
|
||||
|
||||
// ── Fail-fast в production (без exit — сообщение в лог) ────────────────────
|
||||
const DEFAULT_SESSION = 'dev-session-secret-change-me';
|
||||
let fatalError = null;
|
||||
// ── Fail-fast в production ────────────────────────────────────────────────
|
||||
// Если секреты не заданы — приложение в проде немедленно падает.
|
||||
// Дефолтные значения — только для локальной разработки.
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (!process.env.SESSION_SECRET || process.env.SESSION_SECRET === DEFAULT_SESSION)
|
||||
fatalError = 'SESSION_SECRET не задан или равен дефолту в production';
|
||||
else if (!process.env.DB_PASS)
|
||||
fatalError = 'DB_PASS не задан в production';
|
||||
else if (!process.env.CSRF_SECRET || process.env.CSRF_SECRET === 'dev-csrf-secret-change-in-prod')
|
||||
fatalError = 'CSRF_SECRET не задан или равен дефолту в production';
|
||||
const DEFAULT_SESSION = 'dev-session-secret-change-me';
|
||||
if (!process.env.SESSION_SECRET || process.env.SESSION_SECRET === DEFAULT_SESSION) {
|
||||
console.error('FATAL: SESSION_SECRET не задан или равен дефолту в production!');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!process.env.DB_PASS) {
|
||||
console.error('FATAL: DB_PASS не задан в production!');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!process.env.CSRF_SECRET || process.env.CSRF_SECRET === 'dev-csrf-secret-change-in-prod') {
|
||||
console.error('FATAL: CSRF_SECRET не задан или равен дефолту в production!');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// k8s liveness + readiness probes
|
||||
@@ -103,46 +85,12 @@ async function start() {
|
||||
app.get('/ready', async (req, res) => {
|
||||
try {
|
||||
await pool.query('SELECT 1');
|
||||
res.send('OK');
|
||||
} catch (e) {
|
||||
// БД не отвечает — но приложение открыто, покажет ошибку на страницах
|
||||
}
|
||||
res.send('OK');
|
||||
});
|
||||
|
||||
// Честный health-check для системы алертов: реальная проверка БД.
|
||||
// Отдаёт максимум диагностической информации (без секретов).
|
||||
app.get('/health', async (req, res) => {
|
||||
const start = Date.now();
|
||||
const base = {
|
||||
version: APP_VERSION,
|
||||
uptimeSec: Math.round(process.uptime()),
|
||||
pid: process.pid,
|
||||
node: process.version,
|
||||
env: process.env.NODE_ENV || 'development',
|
||||
now: new Date().toISOString(),
|
||||
latencyMs: 0,
|
||||
db: 'up'
|
||||
};
|
||||
try {
|
||||
await pool.query('SELECT 1');
|
||||
base.latencyMs = Date.now() - start;
|
||||
res.status(200).json({ status: 'ok', ...base });
|
||||
} catch (e) {
|
||||
base.latencyMs = Date.now() - start;
|
||||
base.db = 'down';
|
||||
res.status(503).json({ status: 'error', ...base, error: e.message });
|
||||
res.status(503).send('DB not ready');
|
||||
}
|
||||
});
|
||||
|
||||
// Если фатальная ошибка конфигурации — показываем на всех страницах сообщение
|
||||
if (fatalError) {
|
||||
console.error(fatalError);
|
||||
app.use((req, res) => {
|
||||
res.status(503).type('html').send('<html><head><meta charset="utf-8"><title>Авария</title><style>body{font-family:sans-serif;padding:40px;text-align:center}h1{color:#c00}</style></head><body><h1>⚙️ Ошибка конфигурации</h1><p>' + fatalError + '</p></body></html>');
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
// JWKS endpoint для валидации токенов внешними сервисами (только в mock-режиме)
|
||||
if (auth.jwksHandler) app.get('/.well-known/jwks.json', auth.jwksHandler);
|
||||
|
||||
@@ -173,7 +121,7 @@ async function start() {
|
||||
? (err.stack || err.message || String(err))
|
||||
: JSON.stringify(err);
|
||||
console.error('Unhandled error:', ctx, detail);
|
||||
res.status(500).send('<h2>Ошибка</h2><pre>' + detail + '</pre>');
|
||||
res.status(500).send('Внутренняя ошибка сервера');
|
||||
});
|
||||
|
||||
// ── Старт сервера ─────────────────────────────────────────────────────────────
|
||||
@@ -188,24 +136,20 @@ async function start() {
|
||||
if (require.main === module) {
|
||||
start()
|
||||
.then(a => {
|
||||
a.listen(PORT, () => console.log('Server on port ' + PORT));
|
||||
const server = a.listen(PORT, () => console.log('Server on port ' + PORT));
|
||||
// Graceful shutdown по SIGTERM (k8s)
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('SIGTERM — shutting down...');
|
||||
const { pool } = require('./src/db');
|
||||
pool.end().then(() => { console.log('DB pool closed'); process.exit(0); });
|
||||
server.close(() => {
|
||||
console.log('HTTP closed');
|
||||
const { pool } = require('./src/db');
|
||||
pool.end().then(() => { console.log('DB pool closed'); process.exit(0); });
|
||||
});
|
||||
setTimeout(() => { console.log('Forced exit'); process.exit(0); }, 10000);
|
||||
});
|
||||
return server;
|
||||
})
|
||||
.catch(e => {
|
||||
console.error('Startup error:', e.message);
|
||||
// Не падаем — запускаем HTTP с сообщением об ошибке
|
||||
const errorApp = require('express')();
|
||||
errorApp.use((req, res) => {
|
||||
res.status(503).type('html').send('<html><head><meta charset="utf-8"><title>Авария</title></head><body style="font-family:sans-serif;padding:40px;text-align:center"><h1>⚙️ Сервис временно недоступен</h1><p>Причина: ' + (e.message || 'неизвестна') + '</p><p>Попробуйте обновить страницу через минуту</p></body></html>');
|
||||
});
|
||||
errorApp.listen(PORT, () => console.log('Server on port ' + PORT + ' (ERROR MODE)'));
|
||||
});
|
||||
.catch(e => { console.error('Startup error:', e); process.exit(1); });
|
||||
}
|
||||
|
||||
module.exports = { start };
|
||||
|
||||
@@ -9,8 +9,7 @@ const pool = new Pool({
|
||||
password: process.env.DB_PASS,
|
||||
ssl: process.env.DB_SSLMODE === 'require' ? { rejectUnauthorized: false } : false,
|
||||
max: 10,
|
||||
connectionTimeoutMillis: 5000,
|
||||
idleTimeoutMillis: 10000,
|
||||
idleTimeoutMillis: 30000,
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
|
||||
-156
@@ -65,101 +65,6 @@ function createV2Router({ generateCsrfToken, doubleCsrfProtection } = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /v2/db-status — диагностика подключения к PG ─────────────
|
||||
router.get('/db-status', async (req, res) => {
|
||||
const dns = require('dns').promises;
|
||||
const { pool } = require('./src/db');
|
||||
const host = process.env.DB_HOST || 'localhost';
|
||||
const port = process.env.DB_PORT || 5432;
|
||||
const db = process.env.DB_NAME || 'ipwhitelist';
|
||||
const user = process.env.DB_USER || 'super';
|
||||
|
||||
const info = {
|
||||
env: {
|
||||
DB_HOST: host,
|
||||
DB_PORT: port,
|
||||
DB_NAME: db,
|
||||
DB_USER: user,
|
||||
DB_PASS: process.env.DB_PASS ? '***' : 'NOT SET',
|
||||
APP_ENV: process.env.APP_ENV || 'production',
|
||||
},
|
||||
dns: { host, ok: false, ips: [] },
|
||||
connect: { ok: false, ms: 0, error: null },
|
||||
query: { ok: false, ms: 0, error: null },
|
||||
pool: { total: pool.totalCount, idle: pool.idleCount, waiting: pool.waitingCount },
|
||||
pg_version: null,
|
||||
tables: [],
|
||||
};
|
||||
|
||||
// DNS
|
||||
try {
|
||||
info.dns.ips = await dns.resolve4(host, { ttl: true });
|
||||
info.dns.ok = info.dns.ips.length > 0;
|
||||
} catch (e) {
|
||||
info.dns.error = e.message;
|
||||
}
|
||||
|
||||
// Connect + query
|
||||
try {
|
||||
const start = Date.now();
|
||||
const r = await pool.query('SELECT version() AS v, current_timestamp AS ts');
|
||||
info.connect = { ok: true, ms: Date.now() - start };
|
||||
info.pg_version = r.rows[0].v;
|
||||
info.server_time = r.rows[0].ts;
|
||||
} catch (e) {
|
||||
info.connect = { ok: false, ms: Date.now() - start, error: e.message };
|
||||
}
|
||||
|
||||
// Tables
|
||||
if (info.connect.ok) {
|
||||
try {
|
||||
const tables = await pool.query(`
|
||||
SELECT table_name,
|
||||
(SELECT COUNT(*) FROM information_schema.columns WHERE table_name = t.table_name) AS cols
|
||||
FROM information_schema.tables t
|
||||
WHERE table_schema='public' AND table_type='BASE TABLE'
|
||||
ORDER BY table_name
|
||||
`);
|
||||
for (const t of tables.rows) {
|
||||
const cnt = await pool.query('SELECT COUNT(*)::int AS c FROM ' + t.table_name);
|
||||
info.tables.push({ table: t.table_name, columns: t.cols, rows: cnt.rows[0].c });
|
||||
}
|
||||
} catch (e) {
|
||||
info.tables = [{ error: e.message }];
|
||||
}
|
||||
}
|
||||
|
||||
// Pool stats
|
||||
info.pool = { total: pool.totalCount, idle: pool.idleCount, waiting: pool.waitingCount };
|
||||
|
||||
res.set('Content-Type', 'text/html; charset=utf-8');
|
||||
res.send(renderDbStatus(info));
|
||||
});
|
||||
|
||||
// ── GET /v2/db/query — SQL-запросы к БД (только ADMIN_EMAIL) ─────
|
||||
router.get('/db/query', resolveContext, async (req, res) => {
|
||||
if (!req.canQueryDB) return res.status(403).send('Доступ запрещён — только ADMIN_EMAIL');
|
||||
const sqlParam = req.query.sql || '';
|
||||
const { pool } = require('./src/db');
|
||||
let result = null;
|
||||
let error = null;
|
||||
let elapsed = 0;
|
||||
|
||||
if (sqlParam) {
|
||||
try {
|
||||
const start = Date.now();
|
||||
const r = await pool.query('SELECT ' + sqlParam);
|
||||
elapsed = Date.now() - start;
|
||||
result = { rows: r.rows, rowCount: r.rowCount, fields: r.fields.map(f => f.name) };
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
res.set('Content-Type', 'text/html; charset=utf-8');
|
||||
res.send(renderDbQuery(sqlParam, result, error, elapsed));
|
||||
});
|
||||
|
||||
// ── GET /v2/iam-gateway-test — сравнение API (только админ) ────────
|
||||
router.get('/iam-gateway-test', resolveContext, async (req, res) => {
|
||||
if (!req.canAdmin) return res.status(403).send('Доступ запрещён');
|
||||
@@ -367,65 +272,4 @@ function iamComparePage(results) {
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderDbStatus(info) {
|
||||
const ok = info.connect.ok;
|
||||
return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>PG Status</title>'
|
||||
+ '<style>body{font-family:monospace;background:#111;color:#eee;padding:20px}'
|
||||
+ 'h2{color:#4fc3f7}h3{color:#81c784;margin:15px 0 5px}'
|
||||
+ '.ok{color:#81c784}.err{color:#ef5350;word-break:break-all}'
|
||||
+ 'pre{background:#1a1a2e;padding:10px;border-radius:6px;overflow-x:auto;font-size:12px}'
|
||||
+ 'table{border-collapse:collapse;margin:10px 0;font-size:12px}'
|
||||
+ 'td,th{border:1px solid #333;padding:4px 10px;text-align:left}'
|
||||
+ 'th{color:#888}.label{color:#888}</style></head><body>'
|
||||
+ '<h2>⚡ PG Diagnostic <span class="' + (ok ? 'ok' : 'err') + '">' + (ok ? 'OK' : 'FAIL') + '</span></h2>'
|
||||
|
||||
// Status
|
||||
+ '<h3>🔌 Connection</h3>'
|
||||
+ '<p><span class="label">Host:</span> ' + info.env.DB_HOST + ':' + info.env.DB_PORT + '</p>'
|
||||
+ '<p><span class="label">DB:</span> ' + info.env.DB_NAME + ' / ' + info.env.DB_USER + '</p>'
|
||||
+ '<p><span class="label">APP_ENV:</span> ' + info.env.APP_ENV + '</p>'
|
||||
+ '<p><span class="' + (ok ? 'ok' : 'err') + '">' + (ok ? '✅ Connected (' + info.connect.ms + 'ms)' : '❌ ' + info.connect.error) + '</span></p>'
|
||||
|
||||
// DNS
|
||||
+ '<h3>🌐 DNS</h3>'
|
||||
+ '<p>' + info.dns.ips.map(ip => typeof ip === 'object' ? ip.address : ip).join(', ') + (info.dns.error ? ' <span class="err">' + info.dns.error + '</span>' : '') + '</p>'
|
||||
|
||||
// Pool
|
||||
+ '<h3>🏊 Pool</h3>'
|
||||
+ '<p>total=' + info.pool.total + ' idle=' + info.pool.idle + ' waiting=' + info.pool.waiting + '</p>'
|
||||
|
||||
// PG Info
|
||||
+ (info.pg_version ? '<h3>🐘 PostgreSQL</h3><p>' + info.pg_version + '</p><p>Server time: ' + info.server_time + '</p>' : '')
|
||||
|
||||
// Tables
|
||||
+ '<h3>📊 Tables</h3>'
|
||||
+ (info.tables.length > 0 && !info.tables[0].error
|
||||
? '<table><tr><th>Table</th><th>Cols</th><th>Rows</th></tr>'
|
||||
+ info.tables.map(t => '<tr><td>' + t.table + '</td><td>' + t.columns + '</td><td>' + t.rows + '</td></tr>').join('')
|
||||
+ '</table>'
|
||||
: '<p class="err">' + (info.tables[0] && info.tables[0].error || 'no data') + '</p>')
|
||||
|
||||
+ '</body></html>';
|
||||
}
|
||||
|
||||
function renderDbQuery(sql, result, error, elapsed) {
|
||||
return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>SQL Query</title>'
|
||||
+ '<style>body{font-family:monospace;background:#111;color:#eee;padding:20px}'
|
||||
+ 'h2{color:#4fc3f7}input{width:600px;padding:10px;font-family:monospace;background:#1a1a2e;color:#eee;border:1px solid #333;border-radius:6px;font-size:14px}'
|
||||
+ 'button{padding:10px 20px;background:#4fc3f7;color:#000;border:none;border-radius:6px;cursor:pointer;font-weight:bold;font-size:14px}'
|
||||
+ '.err{color:#ef5350}.ok{color:#81c784}'
|
||||
+ 'pre{background:#1a1a2e;padding:10px;border-radius:6px;overflow-x:auto;max-height:600px;font-size:12px}'
|
||||
+ 'table{border-collapse:collapse;margin:10px 0;font-size:12px}td,th{border:1px solid #333;padding:4px 10px;text-align:left}th{background:#1a1a2e;color:#4fc3f7}'
|
||||
+ '</style></head><body>'
|
||||
+ '<h2>🔍 SQL Query <span style="font-size:14px;color:#888">(SELECT prefix)</span></h2>'
|
||||
+ '<form method="get"><input name="sql" value="' + (sql || '').replace(/"/g,'"') + '" placeholder="* FROM companies"><button>Execute</button></form>'
|
||||
+ (error ? '<p class="err" style="margin-top:10px">❌ ' + error + '</p>' : '')
|
||||
+ (result ? '<p style="margin-top:10px"><span class="ok">✅ ' + result.rowCount + ' rows</span> (' + elapsed + 'ms)</p>'
|
||||
+ '<table><tr>' + result.fields.map(f => '<th>' + f + '</th>').join('') + '</tr>'
|
||||
+ result.rows.map(r => '<tr>' + result.fields.map(f => '<td>' + (r[f] === null ? '<i>null</i>' : String(r[f])) + '</td>').join('') + '</tr>').join('')
|
||||
+ '</table>' : '')
|
||||
+ '<p style="margin-top:20px"><a href="/v2/db-status" style="color:#4fc3f7">📊 PG Diagnostic</a></p>'
|
||||
+ '</body></html>';
|
||||
}
|
||||
|
||||
module.exports = { createV2Router };
|
||||
|
||||
+1
-2
@@ -19,8 +19,7 @@ const pool = new Pool({
|
||||
password: process.env.DB_PASS || '',
|
||||
ssl: process.env.DB_SSLMODE === 'require' ? { rejectUnauthorized: false } : false,
|
||||
max: 10,
|
||||
connectionTimeoutMillis: 5000, // 5 сек на подключение → не виснуть
|
||||
idleTimeoutMillis: 10000, // 10 сек idle → закрыть
|
||||
idleTimeoutMillis: 30000,
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
|
||||
+37
-56
@@ -1,30 +1,20 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// V2 — инициализация схемы БД + миграции
|
||||
// V2 — инициализация схемы БД
|
||||
//
|
||||
// ЭТО: CREATE TABLE IF NOT EXISTS (идемпотентно) + applyMigrations().
|
||||
// ЗАЧЕМ: приложение само создаёт таблицы и применяет новые миграции при старте.
|
||||
// ЭТО: CREATE TABLE IF NOT EXISTS — безопасный повторный запуск.
|
||||
// ЗАЧЕМ:
|
||||
// 1. Приложение само создаёт таблицы при старте.
|
||||
// 2. Безопасно для production: IF NOT EXISTS.
|
||||
//
|
||||
// МИГРАЦИИ: встроенные SQL-строки, не из файлов.
|
||||
// Каждая в транзакции (BEGIN/COMMIT), ошибка логируется и пропускается.
|
||||
// Без fs, path, process.exit.
|
||||
// ТАБЛИЦЫ:
|
||||
// companies — client_id (W-номер), name, custom_limit
|
||||
// whitelist_entries — CIDR, комментарий, кто создал/изменил/удалил
|
||||
// audit_log — все действия: CREATE/UPDATE/DELETE/RESTORE, с impersonated_by
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ── Миграции (упорядочены, выполняются один раз) ──────────────────────────────
|
||||
const MIGRATIONS = [
|
||||
{
|
||||
name: '001_add_impersonated_by',
|
||||
sql: `ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS impersonated_by VARCHAR(255) DEFAULT NULL`,
|
||||
},
|
||||
{
|
||||
name: '002_add_audit_restore',
|
||||
sql: `ALTER TABLE audit_log DROP CONSTRAINT IF EXISTS audit_log_action_check;
|
||||
ALTER TABLE audit_log ADD CONSTRAINT audit_log_action_check CHECK (action IN ('CREATE','UPDATE','DELETE','RESTORE'))`,
|
||||
},
|
||||
];
|
||||
|
||||
async function ensureSchema(pool) {
|
||||
// 1. Таблицы
|
||||
await pool.query(`
|
||||
-- Компании: W-номер уникален, upsert через ON CONFLICT
|
||||
CREATE TABLE IF NOT EXISTS companies (
|
||||
id SERIAL PRIMARY KEY,
|
||||
client_id VARCHAR(64) NOT NULL UNIQUE,
|
||||
@@ -34,6 +24,7 @@ async function ensureSchema(pool) {
|
||||
updated_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Записи: CIDR + метаданные, soft delete
|
||||
CREATE TABLE IF NOT EXISTS whitelist_entries (
|
||||
id SERIAL PRIMARY KEY,
|
||||
company_id INTEGER NOT NULL,
|
||||
@@ -47,15 +38,17 @@ async function ensureSchema(pool) {
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Защита от дублей: уникальный CIDR в компании среди активных записей
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_whitelist_entries_active_cidr
|
||||
ON whitelist_entries(company_id, value_cidr)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
-- Аудит: каждое действие с возможностью отследить имперсонацию
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_email VARCHAR(255) NOT NULL,
|
||||
company_id INTEGER NOT NULL,
|
||||
action VARCHAR(32) NOT NULL,
|
||||
action VARCHAR(32) NOT NULL CHECK (action IN ('CREATE','UPDATE','DELETE','RESTORE')),
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
entry_id INTEGER,
|
||||
@@ -65,44 +58,32 @@ async function ensureSchema(pool) {
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_company_time
|
||||
ON audit_log(company_id, created_at DESC);
|
||||
|
||||
-- Таблица учёта выполненных миграций
|
||||
CREATE TABLE IF NOT EXISTS _migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) UNIQUE NOT NULL,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
// Миграция: impersonated_by (если колонки ещё нет)
|
||||
await pool.query(`
|
||||
ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS impersonated_by VARCHAR(255) DEFAULT NULL
|
||||
`).catch(() => {});
|
||||
|
||||
// Миграция: добавить RESTORE в CHECK (если ещё нет)
|
||||
await pool.query(`
|
||||
DO $$
|
||||
DECLARE
|
||||
cn text;
|
||||
BEGIN
|
||||
SELECT con.conname INTO cn
|
||||
FROM pg_constraint con
|
||||
JOIN pg_class rel ON rel.oid = con.conrelid
|
||||
WHERE rel.relname = 'audit_log' AND con.contype = 'c';
|
||||
IF cn IS NOT NULL THEN
|
||||
EXECUTE 'ALTER TABLE audit_log DROP CONSTRAINT ' || cn;
|
||||
END IF;
|
||||
EXECUTE 'ALTER TABLE audit_log ADD CONSTRAINT audit_log_action_check CHECK (action IN (''CREATE'',''UPDATE'',''DELETE'',''RESTORE''))';
|
||||
END;
|
||||
$$;
|
||||
`).catch(() => {});
|
||||
|
||||
console.log('[db] Schema ensured');
|
||||
|
||||
// 2. Миграции
|
||||
await applyMigrations(pool);
|
||||
}
|
||||
|
||||
async function applyMigrations(pool) {
|
||||
try {
|
||||
const done = new Set(
|
||||
(await pool.query('SELECT name FROM _migrations')).rows.map(r => r.name)
|
||||
);
|
||||
|
||||
for (const m of MIGRATIONS) {
|
||||
if (done.has(m.name)) continue;
|
||||
try {
|
||||
await pool.query('BEGIN');
|
||||
await pool.query(m.sql);
|
||||
await pool.query('INSERT INTO _migrations (name) VALUES ($1)', [m.name]);
|
||||
await pool.query('COMMIT');
|
||||
console.log('[db] Migration applied:', m.name);
|
||||
} catch (e) {
|
||||
await pool.query('ROLLBACK').catch(() => {});
|
||||
console.error('[db] Migration failed:', m.name, e.message);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[db] applyMigrations error:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ensureSchema };
|
||||
module.exports = { ensureSchema };
|
||||
|
||||
@@ -107,7 +107,6 @@ function applyContext(req, u) {
|
||||
req.companyName = u.companyName || req.clientId;
|
||||
const allCids = u.allClientIds || [u.clientId || ''];
|
||||
req.canAdmin = allCids.includes('WZ01112') || u.clientId === 'WZ01112' || !!u.isAdmin || u.email === (process.env.ADMIN_EMAIL || '');
|
||||
req.canQueryDB = u.email === (process.env.ADMIN_EMAIL || ''); // SQL-доступ только для ADMIN_EMAIL
|
||||
req.adminMode = req.canAdmin;
|
||||
req.isAdmin = req.canAdmin;
|
||||
req.isImpersonated = isImpersonated;
|
||||
|
||||
Reference in New Issue
Block a user