feat(evobgp): прокси /api/evobgp на бэкенде, URL и токен из env Docker
Publish Docker image / build-and-push (push) Successful in 1m39s
Publish Docker image / build-and-push (push) Successful in 1m39s
- Express проксирует на EVOBGP_API_URL с EVOBGP_API_TOKEN (без CORS) - фронт по умолчанию baseURL /api/evobgp; прямой режим через VITE или localStorage - Communities: подсказка про переменные контейнера; очистка URL включает прокси - DOCKER.md и Dockerfile.fast: документация переменных Made-with: Cursor
This commit is contained in:
@@ -75,6 +75,9 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY} # Обязательно! Используйте .env файл или secrets
|
- ENCRYPTION_KEY=${ENCRYPTION_KEY} # Обязательно! Используйте .env файл или secrets
|
||||||
|
# Прокси EvoBGP для UI (Communities / Domains / …) — без CORS в браузере:
|
||||||
|
# - EVOBGP_API_URL=http://evobgp:8080
|
||||||
|
# - EVOBGP_API_TOKEN=...
|
||||||
|
|
||||||
s3-lists-manager-tabler:
|
s3-lists-manager-tabler:
|
||||||
image: git.shts.su/[repository]:tabler
|
image: git.shts.su/[repository]:tabler
|
||||||
@@ -104,6 +107,8 @@ docker-compose up -d
|
|||||||
| `NODE_ENV` | Окружение Node.js | `production` | Нет |
|
| `NODE_ENV` | Окружение Node.js | `production` | Нет |
|
||||||
| `PORT` | Порт для запуска сервера | `3001` | Нет |
|
| `PORT` | Порт для запуска сервера | `3001` | Нет |
|
||||||
| `ENCRYPTION_KEY` | Ключ шифрования для IPSec паролей (64 hex символа) | - | **Да** |
|
| `ENCRYPTION_KEY` | Ключ шифрования для IPSec паролей (64 hex символа) | - | **Да** |
|
||||||
|
| `EVOBGP_API_URL` | Базовый URL API EvoBGP для серверного прокси `/api/evobgp` (например `http://evobgp:8080` или `http://10.0.0.5:8080`; при необходимости к пути добавляется `/v1`) | - | Да, если используете интеграцию EvoBGP в UI |
|
||||||
|
| `EVOBGP_API_TOKEN` | Bearer-токен к API EvoBGP (только на сервере, не в браузере) | - | Да, если используете интеграцию EvoBGP в UI |
|
||||||
|
|
||||||
### ENCRYPTION_KEY
|
### ENCRYPTION_KEY
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ COPY backend/services ./services
|
|||||||
COPY backend/utils ./utils
|
COPY backend/utils ./utils
|
||||||
COPY --from=frontend-builder /app/frontend/dist ./public
|
COPY --from=frontend-builder /app/frontend/dist ./public
|
||||||
|
|
||||||
|
# EvoBGP (справочник community / router-lists через прокси /api/evobgp, без CORS в браузере):
|
||||||
|
# EVOBGP_API_URL — например http://evobgp:8080 или http://192.168.1.10:8080 (к /v1 приведётся автоматически при необходимости)
|
||||||
|
# EVOBGP_API_TOKEN — Bearer-токен API EvoBGP
|
||||||
|
#
|
||||||
# ENCRYPTION_KEY должен быть установлен через переменные окружения при запуске контейнера
|
# ENCRYPTION_KEY должен быть установлен через переменные окружения при запуске контейнера
|
||||||
# Это обязательный параметр для работы с IPSec паролями
|
# Это обязательный параметр для работы с IPSec паролями
|
||||||
# Пример: docker run -e ENCRYPTION_KEY=your-64-hex-character-key ...
|
# Пример: docker run -e ENCRYPTION_KEY=your-64-hex-character-key ...
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
const http = require('http');
|
||||||
|
const https = require('https');
|
||||||
|
const { URL } = require('url');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Нормализует базовый URL API EvoBGP до вида …/v1 (без завершающего слэша).
|
||||||
|
*/
|
||||||
|
function normalizeEvobgpBaseUrl(raw) {
|
||||||
|
if (!raw || !String(raw).trim()) return '';
|
||||||
|
const input = String(raw).trim();
|
||||||
|
try {
|
||||||
|
const u = new URL(input);
|
||||||
|
let path = (u.pathname || '').replace(/\/+$/, '');
|
||||||
|
if (!path || path === '/') {
|
||||||
|
u.pathname = '/v1';
|
||||||
|
} else if (!/\/v1$/i.test(path)) {
|
||||||
|
u.pathname = `${path}/v1`;
|
||||||
|
} else {
|
||||||
|
u.pathname = path;
|
||||||
|
}
|
||||||
|
return `${u.origin}${u.pathname}`.replace(/\/+$/, '');
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Прокси EvoBGP: браузер бьёт в тот же origin (/api/evobgp/…), сервер добавляет Bearer и ходит в EvoBGP по сети Docker (без CORS).
|
||||||
|
*/
|
||||||
|
function createEvobgpProxy({ logger = console } = {}) {
|
||||||
|
return function evobgpProxy(req, res) {
|
||||||
|
const base = normalizeEvobgpBaseUrl(process.env.EVOBGP_API_URL || '');
|
||||||
|
const token = String(process.env.EVOBGP_API_TOKEN || '').trim();
|
||||||
|
|
||||||
|
if (!base) {
|
||||||
|
return res.status(503).json({
|
||||||
|
code: 'E_EVOBGP_PROXY_NOT_CONFIGURED',
|
||||||
|
message:
|
||||||
|
'Прокси EvoBGP не настроен: задайте EVOBGP_API_URL (и при необходимости EVOBGP_API_TOKEN) в окружении контейнера.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
return res.status(503).json({
|
||||||
|
code: 'E_EVOBGP_PROXY_NO_TOKEN',
|
||||||
|
message: 'Прокси EvoBGP: не задан EVOBGP_API_TOKEN в окружении сервера.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const suffix = req.url && req.url.length ? req.url : '/';
|
||||||
|
const targetUrl = `${base}${suffix.startsWith('/') ? suffix : `/${suffix}`}`;
|
||||||
|
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = new URL(targetUrl);
|
||||||
|
} catch {
|
||||||
|
return res.status(500).json({
|
||||||
|
code: 'E_EVOBGP_PROXY_BAD_URL',
|
||||||
|
message: 'Некорректный EVOBGP_API_URL',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const lib = parsed.protocol === 'https:' ? https : http;
|
||||||
|
const headers = { ...req.headers };
|
||||||
|
delete headers.host;
|
||||||
|
delete headers.connection;
|
||||||
|
delete headers['content-length'];
|
||||||
|
delete headers.cookie;
|
||||||
|
headers.authorization = `Bearer ${token}`;
|
||||||
|
headers.host = parsed.host;
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
hostname: parsed.hostname,
|
||||||
|
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
||||||
|
path: parsed.pathname + parsed.search,
|
||||||
|
method: req.method,
|
||||||
|
headers,
|
||||||
|
timeout: Math.min(120000, Number(process.env.EVOBGP_PROXY_TIMEOUT_MS) || 60000),
|
||||||
|
};
|
||||||
|
|
||||||
|
const clientReq = lib.request(opts, (clientRes) => {
|
||||||
|
res.status(clientRes.statusCode);
|
||||||
|
const skip = new Set(['connection', 'transfer-encoding', 'content-encoding']);
|
||||||
|
Object.keys(clientRes.headers).forEach((k) => {
|
||||||
|
if (!skip.has(k.toLowerCase())) {
|
||||||
|
res.setHeader(k, clientRes.headers[k]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
clientRes.pipe(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
clientReq.on('error', (err) => {
|
||||||
|
try {
|
||||||
|
(req.log || logger).error({ err: err.message, target: targetUrl }, 'evobgp proxy');
|
||||||
|
} catch {
|
||||||
|
/* no-op */
|
||||||
|
}
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(502).json({
|
||||||
|
code: 'E_EVOBGP_PROXY_UPSTREAM',
|
||||||
|
message: err.message || 'Не удалось связаться с EvoBGP',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
clientReq.on('timeout', () => {
|
||||||
|
clientReq.destroy();
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(504).json({
|
||||||
|
code: 'E_EVOBGP_PROXY_TIMEOUT',
|
||||||
|
message: 'Таймаут запроса к EvoBGP',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.method === 'OPTIONS' || req.method === 'GET' || req.method === 'HEAD') {
|
||||||
|
clientReq.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'DELETE') {
|
||||||
|
clientReq.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body =
|
||||||
|
req.body !== undefined && req.body !== null && typeof req.body === 'object'
|
||||||
|
? JSON.stringify(req.body)
|
||||||
|
: typeof req.body === 'string'
|
||||||
|
? req.body
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
const buf = Buffer.from(body, 'utf8');
|
||||||
|
clientReq.setHeader('Content-Type', req.headers['content-type'] || 'application/json');
|
||||||
|
clientReq.setHeader('Content-Length', buf.length);
|
||||||
|
clientReq.end(buf);
|
||||||
|
} else {
|
||||||
|
clientReq.end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createEvobgpProxy,
|
||||||
|
normalizeEvobgpBaseUrl,
|
||||||
|
};
|
||||||
@@ -38,6 +38,7 @@ const { initNetworkMapScheduler } = require('./services/networkMapScheduler');
|
|||||||
const { initPingServicesScheduler } = require('./services/pingServicesScheduler');
|
const { initPingServicesScheduler } = require('./services/pingServicesScheduler');
|
||||||
const { initUptimeMonitorScheduler } = require('./services/uptimeMonitorScheduler');
|
const { initUptimeMonitorScheduler } = require('./services/uptimeMonitorScheduler');
|
||||||
const schedulerRoutes = require('./routes/schedulerRoutes');
|
const schedulerRoutes = require('./routes/schedulerRoutes');
|
||||||
|
const { createEvobgpProxy } = require('./routes/evobgpProxyRoutes');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = Number(process.env.PORT) || 3001;
|
const port = Number(process.env.PORT) || 3001;
|
||||||
@@ -156,6 +157,9 @@ app.get('/api/version', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// === EvoBGP reverse proxy (токен и URL только на сервере; без CORS в браузере) ===
|
||||||
|
app.use('/api/evobgp', createEvobgpProxy({ logger }));
|
||||||
|
|
||||||
// === Cache TTL ===
|
// === Cache TTL ===
|
||||||
const DEFAULT_CACHE_TTL = Math.max(0, Math.min(300, Number(process.env.CACHE_TTL_SECONDS) || 30));
|
const DEFAULT_CACHE_TTL = Math.max(0, Math.min(300, Number(process.env.CACHE_TTL_SECONDS) || 30));
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
|
|||||||
@@ -67,9 +67,9 @@ function CommunitiesManager() {
|
|||||||
const [evobgpSettingsOpen, setEvobgpSettingsOpen] = useState(() => {
|
const [evobgpSettingsOpen, setEvobgpSettingsOpen] = useState(() => {
|
||||||
if (import.meta.env?.VITE_EVOBGP_API_URL) return false;
|
if (import.meta.env?.VITE_EVOBGP_API_URL) return false;
|
||||||
try {
|
try {
|
||||||
return !window.localStorage?.getItem('evobgp_api_url');
|
return Boolean(window.localStorage?.getItem('evobgp_api_url'));
|
||||||
} catch {
|
} catch {
|
||||||
return true;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -293,9 +293,26 @@ function CommunitiesManager() {
|
|||||||
const handleSave = () => saveAll(items);
|
const handleSave = () => saveAll(items);
|
||||||
|
|
||||||
const saveEvobgpConnection = () => {
|
const saveEvobgpConnection = () => {
|
||||||
const norm = normalizeEvobgpApiBaseUrl(evobgpUrlDraft);
|
const trimmedUrl = String(evobgpUrlDraft || '').trim();
|
||||||
|
if (!trimmedUrl) {
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem('evobgp_api_url');
|
||||||
|
window.localStorage.removeItem('evobgp_api_token');
|
||||||
|
} catch (_e) {
|
||||||
|
setError('Не удалось очистить настройки в localStorage.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEvobgpUrlDraft('');
|
||||||
|
setEvobgpTokenDraft('');
|
||||||
|
setError('');
|
||||||
|
setSuccess('Включён серверный прокси /api/evobgp (EVOBGP_API_URL и EVOBGP_API_TOKEN в контейнере).');
|
||||||
|
fetchItems();
|
||||||
|
setTimeout(() => setSuccess(''), 5000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const norm = normalizeEvobgpApiBaseUrl(trimmedUrl);
|
||||||
if (!norm) {
|
if (!norm) {
|
||||||
setError('Укажите корректный URL EvoBGP (например http://192.168.0.1:8080). Порт должен совпадать с API EvoBGP, не с этим интерфейсом.');
|
setError('Укажите корректный URL EvoBGP (например http://192.168.0.1:8080). Порт — у API EvoBGP, не у этого интерфейса.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -307,7 +324,7 @@ function CommunitiesManager() {
|
|||||||
}
|
}
|
||||||
setEvobgpUrlDraft(norm);
|
setEvobgpUrlDraft(norm);
|
||||||
setError('');
|
setError('');
|
||||||
setSuccess('Подключение к EvoBGP сохранено. Загружаем данные…');
|
setSuccess('Прямое подключение к EvoBGP из браузера сохранено. Загружаем данные…');
|
||||||
fetchItems();
|
fetchItems();
|
||||||
setTimeout(() => setSuccess(''), 4000);
|
setTimeout(() => setSuccess(''), 4000);
|
||||||
};
|
};
|
||||||
@@ -370,24 +387,25 @@ function CommunitiesManager() {
|
|||||||
{evobgpSettingsOpen && (
|
{evobgpSettingsOpen && (
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<p className="text-secondary small mb-3">
|
<p className="text-secondary small mb-3">
|
||||||
Укажите <strong>прямой URL сервера EvoBGP</strong> (схема + хост + порт API), например{' '}
|
<strong>По умолчанию</strong> запросы идут на внутренний прокси <code>/api/evobgp</code>: URL и токен задаются в{' '}
|
||||||
<code>http://192.168.100.67:8080</code> — не адрес этого веб-интерфейса. К пути будет добавлено{' '}
|
<strong>переменных окружения контейнера</strong> — <code>EVOBGP_API_URL</code> и <code>EVOBGP_API_TOKEN</code>{' '}
|
||||||
<code>/v1</code> автоматически. Токен — Bearer из EvoBGP.
|
(без CORS). Ниже — только <strong>опциональное</strong> прямое подключение из браузера к EvoBGP; оставьте URL пустым и
|
||||||
|
нажмите «Сохранить», чтобы снова использовать прокси.
|
||||||
</p>
|
</p>
|
||||||
<div className="row g-2 align-items-end">
|
<div className="row g-2 align-items-end">
|
||||||
<div className="col-12 col-md-6">
|
<div className="col-12 col-md-6">
|
||||||
<label className="form-label">Base URL EvoBGP</label>
|
<label className="form-label">Прямой Base URL EvoBGP (необязательно)</label>
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
className="form-control"
|
className="form-control"
|
||||||
placeholder="http://192.168.100.67:8080"
|
placeholder="оставьте пустым для прокси"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
value={evobgpUrlDraft}
|
value={evobgpUrlDraft}
|
||||||
onChange={(e) => setEvobgpUrlDraft(e.target.value)}
|
onChange={(e) => setEvobgpUrlDraft(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-12 col-md-4">
|
<div className="col-12 col-md-4">
|
||||||
<label className="form-label">API token</label>
|
<label className="form-label">Токен (только для прямого режима)</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
className="form-control"
|
className="form-control"
|
||||||
|
|||||||
@@ -29,25 +29,30 @@ export function normalizeEvobgpApiBaseUrl(input) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isProxyBase(base) {
|
||||||
|
return base === '/api/evobgp' || (typeof base === 'string' && base.startsWith('/api/evobgp'));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* База EvoBGP API: VITE_EVOBGP_API_URL → localStorage → в dev только proxy /v1.
|
* База EvoBGP API:
|
||||||
* Не используем относительный /v1 в production без настройки — запросы уходили бы на хост SPA, а не на EvoBGP.
|
* 1) VITE_EVOBGP_API_URL — прямой URL (обход прокси, для dev/особых случаев)
|
||||||
|
* 2) localStorage evobgp_api_url — прямой URL из браузера
|
||||||
|
* 3) /api/evobgp — прокси router-lists-ui → EvoBGP (токен и URL только на сервере, без CORS)
|
||||||
*/
|
*/
|
||||||
export function getEvobgpApiBaseUrl() {
|
export function getEvobgpApiBaseUrl() {
|
||||||
const envUrl = import.meta?.env?.VITE_EVOBGP_API_URL;
|
const envUrl = import.meta?.env?.VITE_EVOBGP_API_URL;
|
||||||
if (envUrl && String(envUrl).trim()) {
|
if (envUrl && String(envUrl).trim()) {
|
||||||
return normalizeEvobgpApiBaseUrl(String(envUrl).trim());
|
const n = normalizeEvobgpApiBaseUrl(String(envUrl).trim());
|
||||||
|
if (n) return n;
|
||||||
}
|
}
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const stored = window.localStorage?.getItem(LS_KEY_URL);
|
const stored = window.localStorage?.getItem(LS_KEY_URL);
|
||||||
if (stored && String(stored).trim()) {
|
if (stored && String(stored).trim()) {
|
||||||
return normalizeEvobgpApiBaseUrl(String(stored).trim());
|
const n = normalizeEvobgpApiBaseUrl(String(stored).trim());
|
||||||
|
if (n) return n;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (import.meta?.env?.DEV) {
|
return '/api/evobgp';
|
||||||
return '/v1';
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAuthToken() {
|
function getAuthToken() {
|
||||||
@@ -66,16 +71,18 @@ evobgpApi.interceptors.request.use((config) => {
|
|||||||
if (!base) {
|
if (!base) {
|
||||||
return Promise.reject(
|
return Promise.reject(
|
||||||
new Error(
|
new Error(
|
||||||
'EvoBGP API URL не задан. Укажите полный URL API (например http://192.168.0.1:8080) в разделе Communities или задайте VITE_EVOBGP_API_URL при сборке.',
|
'EvoBGP API URL не задан или некорректен. Проверьте VITE_EVOBGP_API_URL или URL в настройках Communities.',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
config.baseURL = base;
|
config.baseURL = base;
|
||||||
const token = getAuthToken();
|
if (!isProxyBase(base)) {
|
||||||
if (token) {
|
const token = getAuthToken();
|
||||||
config.headers = config.headers || {};
|
if (token) {
|
||||||
if (!config.headers.Authorization) {
|
config.headers = config.headers || {};
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
if (!config.headers.Authorization) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
|
|||||||
Reference in New Issue
Block a user