资源本地化
This commit is contained in:
@@ -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