资源本地化
This commit is contained in:
10
.env.example
10
.env.example
@@ -7,3 +7,13 @@ PORT=3000
|
||||
|
||||
# Aggregation interval in milliseconds (default 5s)
|
||||
REFRESH_INTERVAL=5000
|
||||
|
||||
# Security
|
||||
# Keep remote setup disabled unless you explicitly need to initialize from another host.
|
||||
ALLOW_REMOTE_SETUP=false
|
||||
COOKIE_SECURE=false
|
||||
SESSION_TTL_SECONDS=86400
|
||||
PASSWORD_ITERATIONS=210000
|
||||
|
||||
# Runtime external data providers
|
||||
ENABLE_EXTERNAL_GEO_LOOKUP=false
|
||||
|
||||
@@ -51,8 +51,8 @@
|
||||
--radius-xl: 20px;
|
||||
|
||||
/* Typography */
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
--font-sans: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--font-mono: 'Cascadia Mono', 'Consolas', 'Liberation Mono', monospace;
|
||||
}
|
||||
|
||||
:root.light-theme {
|
||||
|
||||
@@ -7,13 +7,8 @@
|
||||
<meta name="description" content="LDNET-GA">
|
||||
<title></title>
|
||||
<link rel="icon" id="siteFavicon" href="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||
rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
|
||||
<script src="/vendor/echarts.min.js"></script>
|
||||
<script>
|
||||
// Prevent theme flicker
|
||||
(function () {
|
||||
@@ -406,7 +401,7 @@
|
||||
<div class="copyright">© <span id="copyrightYear"></span> LDNET-GA-Service. All rights reserved.</div>
|
||||
<div class="filings">
|
||||
<a href="http://www.beian.gov.cn/portal/registerSystemInfo" target="_blank" id="psFilingDisplay" style="display: none;">
|
||||
<img src="https://www.beian.gov.cn/img/ghs.png" alt="公安备案" style="width: 14px; height: 14px; vertical-align: middle; margin-right: 2px;">
|
||||
<img src="data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%230b1220'/%3E%3Cpath d='M32 10l18 8v12c0 11.6-7.2 21.9-18 26-10.8-4.1-18-14.4-18-26V18l18-8z' fill='%2310b981'/%3E%3Cpath d='M32 18l10 4.6v7.1c0 7-4.1 13.4-10 16.1-5.9-2.7-10-9.1-10-16.1v-7.1L32 18z' fill='%23ecfdf5'/%3E%3Cpath d='M28 31.5l3 3 6-6' fill='none' stroke='%2310b981' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E" alt="公安备案" style="width: 14px; height: 14px; vertical-align: middle; margin-right: 2px;">
|
||||
<span id="psFilingText"></span>
|
||||
</a>
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank" id="icpFilingDisplay" style="display: none;"></a>
|
||||
|
||||
@@ -4,9 +4,6 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>系统初始化 - 数据可视化展示大屏</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
body {
|
||||
|
||||
@@ -153,6 +153,24 @@
|
||||
let myMap2D = null;
|
||||
let editingRouteId = null;
|
||||
|
||||
async function fetchJsonWithFallback(urls) {
|
||||
let lastError = null;
|
||||
|
||||
for (const url of urls) {
|
||||
try {
|
||||
const response = await fetch(url, { cache: 'force-cache' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('All JSON sources failed');
|
||||
}
|
||||
|
||||
// ---- Initialize ----
|
||||
function init() {
|
||||
// Resource Gauges Time
|
||||
@@ -752,9 +770,10 @@
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch map data
|
||||
const resp = await fetch('https://cdn.jsdelivr.net/npm/echarts@4.9.0/map/json/world.json');
|
||||
const worldJSON = await resp.json();
|
||||
// Fetch map data with CDN fallback so restricted networks degrade more gracefully.
|
||||
const worldJSON = await fetchJsonWithFallback([
|
||||
'/vendor/world.json'
|
||||
]);
|
||||
|
||||
// Transform to Pacific-centered correctly
|
||||
const transformCoords = (coords) => {
|
||||
@@ -865,6 +884,8 @@
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Map2D] Initialization failed:', err);
|
||||
dom.globeContainer.innerHTML = '<div class="chart-empty">Map data unavailable</div>';
|
||||
myMap2D = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
45
public/vendor/echarts.min.js
vendored
Normal file
45
public/vendor/echarts.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
public/vendor/world.json
vendored
Normal file
1
public/vendor/world.json
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -10,6 +10,7 @@ const db = require('./db');
|
||||
*/
|
||||
|
||||
const ipInfoToken = process.env.IPINFO_TOKEN;
|
||||
const enableExternalGeoLookup = process.env.ENABLE_EXTERNAL_GEO_LOOKUP === 'true';
|
||||
|
||||
/**
|
||||
* Normalizes geo data for consistent display
|
||||
@@ -74,6 +75,10 @@ async function getLocation(target) {
|
||||
}
|
||||
|
||||
// 4. Resolve via ipinfo.io (LAST RESORT)
|
||||
if (!enableExternalGeoLookup) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[Geo Service] API lookup (ipinfo.io) for: ${cleanIp}`);
|
||||
const url = `https://ipinfo.io/${cleanIp}/json${ipInfoToken ? `?token=${ipInfoToken}` : ''}`;
|
||||
|
||||
247
server/index.js
247
server/index.js
@@ -10,6 +10,8 @@ const latencyService = require('./latency-service');
|
||||
const checkAndFixDatabase = require('./db-integrity-check');
|
||||
const http = require('http');
|
||||
const WebSocket = require('ws');
|
||||
const dns = require('dns').promises;
|
||||
const net = require('net');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
@@ -21,13 +23,164 @@ const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
|
||||
let isDbInitialized = false;
|
||||
const sessions = new Map(); // Simple session store: sessionId -> {userId, username}
|
||||
const sessions = new Map(); // Fallback session store when Valkey is unavailable
|
||||
const SESSION_TTL_SECONDS = parseInt(process.env.SESSION_TTL_SECONDS, 10) || 86400;
|
||||
const PASSWORD_ITERATIONS = parseInt(process.env.PASSWORD_ITERATIONS, 10) || 210000;
|
||||
const ALLOW_REMOTE_SETUP = process.env.ALLOW_REMOTE_SETUP === 'true';
|
||||
const COOKIE_SECURE = process.env.COOKIE_SECURE === 'true';
|
||||
|
||||
function normalizeIp(ip) {
|
||||
if (!ip) return '';
|
||||
if (ip.startsWith('::ffff:')) return ip.substring(7);
|
||||
return ip;
|
||||
}
|
||||
|
||||
function isPrivateOrLoopbackIp(ip) {
|
||||
const normalized = normalizeIp(ip);
|
||||
|
||||
if (!normalized) return false;
|
||||
if (normalized === '::1' || normalized === '127.0.0.1' || normalized === 'localhost') return true;
|
||||
|
||||
if (net.isIPv4(normalized)) {
|
||||
return (
|
||||
normalized.startsWith('10.') ||
|
||||
normalized.startsWith('127.') ||
|
||||
normalized.startsWith('192.168.') ||
|
||||
/^172\.(1[6-9]|2\d|3[0-1])\./.test(normalized) ||
|
||||
normalized.startsWith('169.254.')
|
||||
);
|
||||
}
|
||||
|
||||
if (net.isIPv6(normalized)) {
|
||||
const lower = normalized.toLowerCase();
|
||||
return lower === '::1' || lower.startsWith('fc') || lower.startsWith('fd') || lower.startsWith('fe80:');
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isLocalRequest(req) {
|
||||
const forwardedFor = req.headers['x-forwarded-for'];
|
||||
const candidate = Array.isArray(forwardedFor)
|
||||
? forwardedFor[0]
|
||||
: (typeof forwardedFor === 'string' ? forwardedFor.split(',')[0].trim() : '');
|
||||
const ip = normalizeIp(candidate || req.ip || req.socket.remoteAddress || '');
|
||||
return isPrivateOrLoopbackIp(ip);
|
||||
}
|
||||
|
||||
function getCookieOptions(req, maxAgeSeconds) {
|
||||
const options = ['Path=/', 'HttpOnly', 'SameSite=Strict'];
|
||||
if (typeof maxAgeSeconds === 'number') {
|
||||
options.push(`Max-Age=${maxAgeSeconds}`);
|
||||
}
|
||||
if (COOKIE_SECURE || req.secure || req.headers['x-forwarded-proto'] === 'https') {
|
||||
options.push('Secure');
|
||||
}
|
||||
return options.join('; ');
|
||||
}
|
||||
|
||||
function setSessionCookie(req, res, sessionId, maxAgeSeconds = SESSION_TTL_SECONDS) {
|
||||
res.setHeader('Set-Cookie', `session_id=${encodeURIComponent(sessionId)}; ${getCookieOptions(req, maxAgeSeconds)}`);
|
||||
}
|
||||
|
||||
function clearSessionCookie(req, res) {
|
||||
res.setHeader('Set-Cookie', `session_id=; ${getCookieOptions(req, 0)}`);
|
||||
}
|
||||
|
||||
function createPasswordHash(password, salt, iterations = PASSWORD_ITERATIONS) {
|
||||
const hash = crypto.pbkdf2Sync(password, salt, iterations, 64, 'sha512').toString('hex');
|
||||
return `pbkdf2$sha512$${iterations}$${hash}`;
|
||||
}
|
||||
|
||||
function parsePasswordHash(storedPassword) {
|
||||
if (typeof storedPassword !== 'string') {
|
||||
return { iterations: 1000, hash: '' };
|
||||
}
|
||||
|
||||
if (storedPassword.startsWith('pbkdf2$')) {
|
||||
const parts = storedPassword.split('$');
|
||||
if (parts.length === 4 && parts[1] === 'sha512') {
|
||||
return {
|
||||
iterations: parseInt(parts[2], 10) || 1000,
|
||||
hash: parts[3]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { iterations: 1000, hash: storedPassword };
|
||||
}
|
||||
|
||||
function verifyPassword(password, user) {
|
||||
const parsed = parsePasswordHash(user.password);
|
||||
const computed = crypto.pbkdf2Sync(password, user.salt, parsed.iterations, 64, 'sha512').toString('hex');
|
||||
|
||||
try {
|
||||
return crypto.timingSafeEqual(Buffer.from(computed, 'hex'), Buffer.from(parsed.hash, 'hex'));
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldUpgradePasswordHash(user) {
|
||||
const parsed = parsePasswordHash(user.password);
|
||||
return !String(user.password || '').startsWith('pbkdf2$') || parsed.iterations < PASSWORD_ITERATIONS;
|
||||
}
|
||||
|
||||
async function persistSession(sessionId, sessionData) {
|
||||
sessions.set(sessionId, {
|
||||
...sessionData,
|
||||
expiresAt: Date.now() + SESSION_TTL_SECONDS * 1000
|
||||
});
|
||||
await cache.set(`session:${sessionId}`, sessionData, SESSION_TTL_SECONDS);
|
||||
}
|
||||
|
||||
async function getSession(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
|
||||
const cachedSession = await cache.get(`session:${sessionId}`);
|
||||
if (cachedSession) {
|
||||
sessions.set(sessionId, {
|
||||
...cachedSession,
|
||||
expiresAt: Date.now() + SESSION_TTL_SECONDS * 1000
|
||||
});
|
||||
return cachedSession;
|
||||
}
|
||||
|
||||
const fallback = sessions.get(sessionId);
|
||||
if (!fallback) return null;
|
||||
if (fallback.expiresAt && fallback.expiresAt <= Date.now()) {
|
||||
sessions.delete(sessionId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: fallback.id,
|
||||
username: fallback.username
|
||||
};
|
||||
}
|
||||
|
||||
async function destroySession(sessionId) {
|
||||
if (!sessionId) return;
|
||||
sessions.delete(sessionId);
|
||||
await cache.del(`session:${sessionId}`);
|
||||
}
|
||||
|
||||
async function ensureSetupAccess(req, res, next) {
|
||||
if (ALLOW_REMOTE_SETUP || isLocalRequest(req)) {
|
||||
return next();
|
||||
}
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Remote setup is disabled. Use a local request or set ALLOW_REMOTE_SETUP=true.'
|
||||
});
|
||||
}
|
||||
|
||||
// Middleware: Check Auth
|
||||
function requireAuth(req, res, next) {
|
||||
async function requireAuth(req, res, next) {
|
||||
const sessionId = getCookie(req, 'session_id');
|
||||
if (sessionId && sessions.has(sessionId)) {
|
||||
req.user = sessions.get(sessionId);
|
||||
const session = await getSession(sessionId);
|
||||
if (session) {
|
||||
req.user = session;
|
||||
return next();
|
||||
}
|
||||
res.status(401).json({ error: 'Auth required' });
|
||||
@@ -106,12 +259,15 @@ app.post('/api/auth/login', async (req, res) => {
|
||||
if (rows.length === 0) return res.status(401).json({ error: 'Invalid credentials' });
|
||||
|
||||
const user = rows[0];
|
||||
const hash = crypto.pbkdf2Sync(password, user.salt, 1000, 64, 'sha512').toString('hex');
|
||||
|
||||
if (hash === user.password) {
|
||||
if (verifyPassword(password, user)) {
|
||||
if (shouldUpgradePasswordHash(user)) {
|
||||
await db.query('UPDATE users SET password = ? WHERE id = ?', [createPasswordHash(password, user.salt), user.id]);
|
||||
}
|
||||
|
||||
const sessionId = crypto.randomBytes(32).toString('hex');
|
||||
sessions.set(sessionId, { id: user.id, username: user.username });
|
||||
res.setHeader('Set-Cookie', `session_id=${sessionId}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`);
|
||||
await persistSession(sessionId, { id: user.id, username: user.username });
|
||||
setSessionCookie(req, res, sessionId);
|
||||
res.json({ success: true, username: user.username });
|
||||
} else {
|
||||
res.status(401).json({ error: 'Invalid credentials' });
|
||||
@@ -123,8 +279,8 @@ app.post('/api/auth/login', async (req, res) => {
|
||||
|
||||
app.post('/api/auth/logout', (req, res) => {
|
||||
const sessionId = getCookie(req, 'session_id');
|
||||
if (sessionId) sessions.delete(sessionId);
|
||||
res.setHeader('Set-Cookie', 'session_id=; Path=/; HttpOnly; Max-Age=0');
|
||||
destroySession(sessionId).catch(() => {});
|
||||
clearSessionCookie(req, res);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
@@ -139,14 +295,14 @@ app.post('/api/auth/change-password', requireAuth, async (req, res) => {
|
||||
if (rows.length === 0) return res.status(404).json({ error: '用户不存在' });
|
||||
|
||||
const user = rows[0];
|
||||
const oldHash = crypto.pbkdf2Sync(oldPassword, user.salt, 1000, 64, 'sha512').toString('hex');
|
||||
const passwordMatches = verifyPassword(oldPassword, user);
|
||||
|
||||
if (oldHash !== user.password) {
|
||||
if (!passwordMatches) {
|
||||
return res.status(401).json({ error: '旧密码输入错误' });
|
||||
}
|
||||
|
||||
const newSalt = crypto.randomBytes(16).toString('hex');
|
||||
const newHash = crypto.pbkdf2Sync(newPassword, newSalt, 1000, 64, 'sha512').toString('hex');
|
||||
const newHash = createPasswordHash(newPassword, newSalt);
|
||||
|
||||
await db.query('UPDATE users SET password = ?, salt = ? WHERE id = ?', [newHash, newSalt, user.id]);
|
||||
res.json({ success: true, message: '密码修改成功' });
|
||||
@@ -156,17 +312,18 @@ app.post('/api/auth/change-password', requireAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/auth/status', (req, res) => {
|
||||
app.get('/api/auth/status', async (req, res) => {
|
||||
const sessionId = getCookie(req, 'session_id');
|
||||
if (sessionId && sessions.has(sessionId)) {
|
||||
res.json({ authenticated: true, username: sessions.get(sessionId).username });
|
||||
const session = await getSession(sessionId);
|
||||
if (session) {
|
||||
res.json({ authenticated: true, username: session.username });
|
||||
} else {
|
||||
res.json({ authenticated: false });
|
||||
}
|
||||
});
|
||||
|
||||
// Setup API Routes
|
||||
app.post('/api/setup/test', async (req, res) => {
|
||||
app.post('/api/setup/test', ensureSetupAccess, async (req, res) => {
|
||||
const { host, port, user, password } = req.body;
|
||||
try {
|
||||
const mysql = require('mysql2/promise');
|
||||
@@ -184,7 +341,7 @@ app.post('/api/setup/test', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/setup/test-valkey', async (req, res) => {
|
||||
app.post('/api/setup/test-valkey', ensureSetupAccess, async (req, res) => {
|
||||
const { host, port, password } = req.body;
|
||||
try {
|
||||
const Redis = require('ioredis');
|
||||
@@ -205,9 +362,13 @@ app.post('/api/setup/test-valkey', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/setup/init', async (req, res) => {
|
||||
app.post('/api/setup/init', ensureSetupAccess, async (req, res) => {
|
||||
const { host, port, user, password, database, vHost, vPort, vPassword } = req.body;
|
||||
try {
|
||||
if (isDbInitialized) {
|
||||
return res.status(409).json({ success: false, error: 'System is already initialized' });
|
||||
}
|
||||
|
||||
const mysql = require('mysql2/promise');
|
||||
const connection = await mysql.createConnection({
|
||||
host: host || 'localhost',
|
||||
@@ -281,6 +442,34 @@ app.post('/api/setup/init', async (req, res) => {
|
||||
VALUES (1, '数据可视化展示大屏', '数据可视化展示大屏', 'dark', 0, 'tx')
|
||||
`);
|
||||
|
||||
// Ensure the first-run schema matches the runtime expectations without requiring a restart migration.
|
||||
await connection.query("ALTER TABLE prometheus_sources ADD COLUMN IF NOT EXISTS is_server_source TINYINT(1) DEFAULT 1 AFTER description");
|
||||
await connection.query("ALTER TABLE prometheus_sources ADD COLUMN IF NOT EXISTS type VARCHAR(50) DEFAULT 'prometheus' AFTER is_server_source");
|
||||
await connection.query("ALTER TABLE site_settings ADD COLUMN IF NOT EXISTS show_page_name TINYINT(1) DEFAULT 1 AFTER page_name");
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS latency_routes (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
source_id INT NOT NULL,
|
||||
latency_source VARCHAR(100) NOT NULL,
|
||||
latency_dest VARCHAR(100) NOT NULL,
|
||||
latency_target VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS server_locations (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
ip VARCHAR(255) NOT NULL UNIQUE,
|
||||
country CHAR(2),
|
||||
country_name VARCHAR(100),
|
||||
region VARCHAR(100),
|
||||
city VARCHAR(100),
|
||||
latitude DOUBLE,
|
||||
longitude DOUBLE,
|
||||
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await connection.end();
|
||||
|
||||
// Save to .env
|
||||
@@ -295,6 +484,11 @@ VALKEY_PASSWORD=${vPassword || ''}
|
||||
PORT=${process.env.PORT || 3000}
|
||||
HOST=${process.env.HOST || '0.0.0.0'}
|
||||
REFRESH_INTERVAL=${process.env.REFRESH_INTERVAL || 5000}
|
||||
ALLOW_REMOTE_SETUP=${process.env.ALLOW_REMOTE_SETUP || 'false'}
|
||||
COOKIE_SECURE=${process.env.COOKIE_SECURE || 'false'}
|
||||
SESSION_TTL_SECONDS=${process.env.SESSION_TTL_SECONDS || SESSION_TTL_SECONDS}
|
||||
PASSWORD_ITERATIONS=${process.env.PASSWORD_ITERATIONS || PASSWORD_ITERATIONS}
|
||||
ENABLE_EXTERNAL_GEO_LOOKUP=${process.env.ENABLE_EXTERNAL_GEO_LOOKUP || 'false'}
|
||||
`;
|
||||
fs.writeFileSync(path.join(__dirname, '..', '.env'), envContent);
|
||||
|
||||
@@ -338,7 +532,7 @@ app.get('/api/setup/status', async (req, res) => {
|
||||
});
|
||||
|
||||
// Create First Admin
|
||||
app.post('/api/setup/admin', async (req, res) => {
|
||||
app.post('/api/setup/admin', ensureSetupAccess, async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
if (!username || !password) return res.status(400).json({ error: 'Username and password are required' });
|
||||
|
||||
@@ -347,7 +541,7 @@ app.post('/api/setup/admin', async (req, res) => {
|
||||
if (rows[0].count > 0) return res.status(403).json({ error: 'Admin already exists' });
|
||||
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const hash = crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex');
|
||||
const hash = createPasswordHash(password, salt);
|
||||
|
||||
await db.query('INSERT INTO users (username, password, salt) VALUES (?, ?, ?)', [username, hash, salt]);
|
||||
const [userRows] = await db.query('SELECT id, username FROM users WHERE username = ?', [username]);
|
||||
@@ -355,8 +549,8 @@ app.post('/api/setup/admin', async (req, res) => {
|
||||
|
||||
// Auto-login after creation so the next setup steps (like adding Prometheus) work without 401
|
||||
const sessionId = crypto.randomBytes(32).toString('hex');
|
||||
sessions.set(sessionId, { id: user.id, username: user.username });
|
||||
res.setHeader('Set-Cookie', `session_id=${sessionId}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`);
|
||||
await persistSession(sessionId, { id: user.id, username: user.username });
|
||||
setSessionCookie(req, res, sessionId);
|
||||
|
||||
res.json({ success: true, message: 'Admin account created and logged in' });
|
||||
} catch (err) {
|
||||
@@ -539,7 +733,7 @@ app.delete('/api/sources/:id', requireAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Test connection to a Prometheus source
|
||||
app.post('/api/sources/test', async (req, res) => {
|
||||
app.post('/api/sources/test', requireAuth, async (req, res) => {
|
||||
let { url, type } = req.body;
|
||||
if (url && !/^https?:\/\//i.test(url)) url = 'http://' + url;
|
||||
try {
|
||||
@@ -1012,6 +1206,7 @@ app.get('/api/metrics/latency', async (req, res) => {
|
||||
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocket.Server({ server });
|
||||
let isBroadcastRunning = false;
|
||||
|
||||
function broadcast(data) {
|
||||
const message = JSON.stringify(data);
|
||||
@@ -1024,6 +1219,8 @@ function broadcast(data) {
|
||||
|
||||
// Broadcast loop
|
||||
async function broadcastMetrics() {
|
||||
if (isBroadcastRunning) return;
|
||||
isBroadcastRunning = true;
|
||||
try {
|
||||
const overview = await getOverview();
|
||||
|
||||
@@ -1056,6 +1253,8 @@ async function broadcastMetrics() {
|
||||
});
|
||||
} catch (err) {
|
||||
// console.error('WS Broadcast error:', err.message);
|
||||
} finally {
|
||||
isBroadcastRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user