v0.5.12: доделки перед сдачей заказчику
§4.7: агрегация CIDR в публичном /export (aggregateCIDRs) §5: клиентская JS-валидация (запрещённые диапазоны + нормализация) §4.1: фильтр «показать удалённые» для admin (API + UI) §5: уведомление о нормализации (уже работало, проверено) Файлы: - server.js: +aggregateCIDRs в /export - views/index.ejs: JS-валидация + чек-бокс удалённых - src/api/routes/entries.js: ?includeDeleted=true (admin only) - ui/routes/entries.js: проброс includeDeleted - docs/PLAN-predsdachey.md: план доделок
This commit is contained in:
@@ -0,0 +1,56 @@
|
|||||||
|
# План доделок перед сдачей заказчику
|
||||||
|
|
||||||
|
> 2026-06-02
|
||||||
|
|
||||||
|
## 1. §4.7 — Агрегация CIDR в публичном /export
|
||||||
|
|
||||||
|
**Проблема:** публичный `/export` отдаёт сырой список, без агрегации.
|
||||||
|
ТЗ: «подсети суммаризируются (агрегируются в минимальный набор CIDR)».
|
||||||
|
|
||||||
|
**Решение:** добавить `aggregateCIDRs()` в обработчик `server.js`.
|
||||||
|
|
||||||
|
**Файлы:** `server.js` — одна строка.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. §4.1 — Фильтр «показать удалённые» для admin
|
||||||
|
|
||||||
|
**Проблема:** админ не может посмотреть soft-deleted записи.
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
- API: поддержать `?includeDeleted=true` в `GET /api/v1/entries`
|
||||||
|
- UI: добавить чек-бокс «показать удалённые» на странице admin
|
||||||
|
|
||||||
|
**Файлы:** `src/api/routes/entries.js`, `views/index.ejs`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. §5 — Клиентская JS-валидация
|
||||||
|
|
||||||
|
**Проблема:** только HTML5 `pattern`, нет проверки приватных диапазонов и нормализации на клиенте.
|
||||||
|
|
||||||
|
**Решение:** добавить JS-функцию `validateClientIP()` в EJS-шаблон:
|
||||||
|
- Проверка приватных/запрещённых диапазонов (показывать ошибку сразу)
|
||||||
|
- Предпросмотр нормализации («будет сохранено как X.X.X.0/24»)
|
||||||
|
|
||||||
|
**Файлы:** `views/index.ejs`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. §5 — Уведомление о нормализации в UI
|
||||||
|
|
||||||
|
**Проблема:** API возвращает `wasNormalized`, но UI может не показывать.
|
||||||
|
|
||||||
|
**Решение:** проверить `ui/routes/entries.js` — передаёт ли `wasNormalized` в шаблон.
|
||||||
|
Если нет — добавить. В шаблоне показать жёлтый alert.
|
||||||
|
|
||||||
|
**Файлы:** `ui/routes/entries.js`, `views/index.ejs`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Порядок:
|
||||||
|
1. §4.7 — самая простая, одна строка
|
||||||
|
2. §5 — уведомление о нормализации
|
||||||
|
3. §5 — клиентская валидация
|
||||||
|
4. §4.1 — фильтр удалённых (самая объёмная)
|
||||||
|
5. Везде: тесты, VERSION++, коммит
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ipwhitelist",
|
"name": "ipwhitelist",
|
||||||
"version": "0.5.11",
|
"version": "0.5.12",
|
||||||
"description": "IP WhiteList microservice for cloud provider",
|
"description": "IP WhiteList microservice for cloud provider",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const q = require('./src/queries');
|
|||||||
const { MOCK_USERS, APP_VERSION } = require('./src/config');
|
const { MOCK_USERS, APP_VERSION } = require('./src/config');
|
||||||
const { authLimiter } = require('./src/middleware/rateLimit');
|
const { authLimiter } = require('./src/middleware/rateLimit');
|
||||||
const { createSessionMiddleware } = require('./src/middleware/session');
|
const { createSessionMiddleware } = require('./src/middleware/session');
|
||||||
|
const { aggregateCIDRs } = require('./src/validators');
|
||||||
const { CSP_DIRECTIVES } = require('./src/middleware/csp');
|
const { CSP_DIRECTIVES } = require('./src/middleware/csp');
|
||||||
const { createUiRouter } = require('./ui/index');
|
const { createUiRouter } = require('./ui/index');
|
||||||
|
|
||||||
@@ -83,7 +84,8 @@ async function start() {
|
|||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const cidrs = await q.getExportCIDRs();
|
const cidrs = await q.getExportCIDRs();
|
||||||
const text = cidrs.join('\n') + (cidrs.length ? '\n' : '');
|
const aggregated = aggregateCIDRs(cidrs);
|
||||||
|
const text = aggregated.join('\n') + (aggregated.length ? '\n' : '');
|
||||||
res.set('Content-Type', 'text/plain; charset=utf-8');
|
res.set('Content-Type', 'text/plain; charset=utf-8');
|
||||||
const fname = req.query.filename || 'white-list.txt';
|
const fname = req.query.filename || 'white-list.txt';
|
||||||
const disp = req.query.view === '1' ? 'inline' : 'attachment';
|
const disp = req.query.view === '1' ? 'inline' : 'attachment';
|
||||||
|
|||||||
@@ -62,11 +62,13 @@ function createEntriesRouter({ q }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/v1/entries — список записей + лимит
|
// GET /api/v1/entries — список записей + лимит
|
||||||
|
// ?includeDeleted=true — показать soft-deleted (только admin)
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const company = await resolveCompany(req);
|
const company = await resolveCompany(req);
|
||||||
|
const includeDeleted = req.user.isAdmin && req.query.includeDeleted === 'true';
|
||||||
const [entries, limit] = await Promise.all([
|
const [entries, limit] = await Promise.all([
|
||||||
q.listEntries(company.id),
|
q.listEntries(company.id, includeDeleted),
|
||||||
q.getLimit(company),
|
q.getLimit(company),
|
||||||
]);
|
]);
|
||||||
res.json({ entries, limit, used: entries.length });
|
res.json({ entries, limit, used: entries.length });
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ function createRouter() {
|
|||||||
|
|
||||||
selectedCompany = companies.find(c => c.id === companyId) || null;
|
selectedCompany = companies.find(c => c.id === companyId) || null;
|
||||||
if (selectedCompany) {
|
if (selectedCompany) {
|
||||||
const er = await api.get('/api/v1/entries?company=' + companyId, token);
|
const includeDeleted = req.query.includeDeleted === 'true' ? '&includeDeleted=true' : '';
|
||||||
|
const er = await api.get('/api/v1/entries?company=' + companyId + includeDeleted, token);
|
||||||
entries = er.data.entries || [];
|
entries = er.data.entries || [];
|
||||||
limit = er.data.limit || 15;
|
limit = er.data.limit || 15;
|
||||||
}
|
}
|
||||||
@@ -98,6 +99,7 @@ function createRouter() {
|
|||||||
success: req.query.success || null,
|
success: req.query.success || null,
|
||||||
lastValue: req.query.lastValue || '',
|
lastValue: req.query.lastValue || '',
|
||||||
lastComment: req.query.lastComment || '',
|
lastComment: req.query.lastComment || '',
|
||||||
|
includeDeleted: req.query.includeDeleted === 'true',
|
||||||
csrfToken: '',
|
csrfToken: '',
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -107,7 +109,8 @@ function createRouter() {
|
|||||||
companies: [], selectedCompany: null,
|
companies: [], selectedCompany: null,
|
||||||
allClientIds: null, activeClientId: null,
|
allClientIds: null, activeClientId: null,
|
||||||
error: 'Ошибка загрузки: ' + e.message,
|
error: 'Ошибка загрузки: ' + e.message,
|
||||||
success: null, lastValue: '', lastComment: '', csrfToken: '',
|
success: null, lastValue: '', lastComment: '',
|
||||||
|
includeDeleted: false, csrfToken: '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+103
@@ -198,6 +198,11 @@
|
|||||||
style="font-size:.82rem;color:var(--muted);text-decoration:none;">→ Аудит этой компании</a>
|
style="font-size:.82rem;color:var(--muted);text-decoration:none;">→ Аудит этой компании</a>
|
||||||
<a href="/admin"
|
<a href="/admin"
|
||||||
style="font-size:.82rem;color:var(--muted);text-decoration:none;">→ Лимиты</a>
|
style="font-size:.82rem;color:var(--muted);text-decoration:none;">→ Лимиты</a>
|
||||||
|
<label style="font-size:.82rem;color:var(--muted);cursor:pointer;display:flex;align-items:center;gap:.3rem;">
|
||||||
|
<input type="checkbox" name="includeDeleted" value="true" <%= (typeof includeDeleted !== 'undefined' && includeDeleted) ? 'checked' : '' %>
|
||||||
|
onchange="this.form.submit()">
|
||||||
|
Показать удалённые
|
||||||
|
</label>
|
||||||
<span style="margin-left:auto;font-size:.8rem;color:var(--muted);">
|
<span style="margin-left:auto;font-size:.8rem;color:var(--muted);">
|
||||||
Режим: <strong style="color:var(--amber);">Администратор</strong>
|
Режим: <strong style="color:var(--amber);">Администратор</strong>
|
||||||
</span>
|
</span>
|
||||||
@@ -437,6 +442,104 @@
|
|||||||
document.addEventListener('keydown', function (e) {
|
document.addEventListener('keydown', function (e) {
|
||||||
if (e.key === 'Escape') closeModal();
|
if (e.key === 'Escape') closeModal();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Клиентская валидация IP (ТЗ §5) ──────────────────────
|
||||||
|
var BLOCKED = [
|
||||||
|
{ mask:'10.0.0.0/8', name:'Private (RFC1918)' },
|
||||||
|
{ mask:'172.16.0.0/12', name:'Private (RFC1918)' },
|
||||||
|
{ mask:'192.168.0.0/16', name:'Private (RFC1918)' },
|
||||||
|
{ mask:'100.64.0.0/10', name:'CGNAT' },
|
||||||
|
{ mask:'127.0.0.0/8', name:'Loopback' },
|
||||||
|
{ mask:'169.254.0.0/16', name:'Link-local' },
|
||||||
|
{ mask:'192.0.0.0/24', name:'IANA reserved' },
|
||||||
|
{ mask:'192.0.2.0/24', name:'TEST-NET-1' },
|
||||||
|
{ mask:'198.51.100.0/24', name:'TEST-NET-2' },
|
||||||
|
{ mask:'203.0.113.0/24', name:'TEST-NET-3' },
|
||||||
|
{ mask:'198.18.0.0/15', name:'Benchmarking' },
|
||||||
|
{ mask:'224.0.0.0/4', name:'Multicast' },
|
||||||
|
{ mask:'240.0.0.0/4', name:'Reserved' },
|
||||||
|
{ mask:'255.255.255.255/32',name:'Broadcast' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function ipToNum(ip) {
|
||||||
|
var parts = ip.split('.');
|
||||||
|
return ((+parts[0] * 256 + +parts[1]) * 256 + +parts[2]) * 256 + +parts[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCIDR(val) {
|
||||||
|
var m = val.match(/^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?:\/(\d{1,2}))?$/);
|
||||||
|
if (!m) return null;
|
||||||
|
var ip = m[1];
|
||||||
|
var mask = m[2] ? parseInt(m[2], 10) : 32;
|
||||||
|
if (mask < 0 || mask > 32) return null;
|
||||||
|
var parts = ip.split('.');
|
||||||
|
if (parts.some(function(p) { return +p > 255; })) return null;
|
||||||
|
return { ip: ip, mask: mask, num: ipToNum(ip) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkBlocked(info) {
|
||||||
|
for (var i = 0; i < BLOCKED.length; i++) {
|
||||||
|
var b = parseCIDR(BLOCKED[i].mask);
|
||||||
|
if (!b) continue;
|
||||||
|
var maskNum = -1 << (32 - b.mask);
|
||||||
|
if ((info.num & maskNum) === (b.num & maskNum)) {
|
||||||
|
return BLOCKED[i].name + ' (' + BLOCKED[i].mask + ')';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePreview(info) {
|
||||||
|
if (info.mask === 32) return null;
|
||||||
|
var maskNum = -1 << (32 - info.mask);
|
||||||
|
var netNum = info.num & maskNum;
|
||||||
|
var o0 = (netNum >>> 24) & 255;
|
||||||
|
var o1 = (netNum >>> 16) & 255;
|
||||||
|
var o2 = (netNum >>> 8) & 255;
|
||||||
|
var o3 = netNum & 255;
|
||||||
|
var expected = o0 + '.' + o1 + '.' + o2 + '.' + o3 + '/' + info.mask;
|
||||||
|
if (expected !== info.ip + '/' + info.mask) {
|
||||||
|
return expected;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var addForm = document.querySelector('form[action="/add"]');
|
||||||
|
if (addForm) {
|
||||||
|
addForm.addEventListener('submit', function(e) {
|
||||||
|
var input = addForm.querySelector('input[name="value"]');
|
||||||
|
var val = input.value.trim();
|
||||||
|
if (!val) return;
|
||||||
|
|
||||||
|
var info = parseCIDR(val);
|
||||||
|
if (!info) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert('Неверный формат. Ожидается: IPv4 или CIDR (например 203.0.113.10 или 203.0.113.0/24)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.mask < 22) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert('Маска /' + info.mask + ' не допускается. Минимальная маска: /22');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var blocked = checkBlocked(info);
|
||||||
|
if (blocked) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert('Адрес принадлежит запрещённому диапазону:\n' + blocked + '\n\nДобавление заблокировано.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var norm = normalizePreview(info);
|
||||||
|
if (norm) {
|
||||||
|
if (!confirm('Вы ввели адрес из хостовой части подсети.\n\n' +
|
||||||
|
'Будет сохранено: ' + norm + '\n\nПродолжить?')) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}());
|
}());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user