资源本地化

This commit is contained in:
CN-JS-HuiBai
2026-04-07 16:01:29 +08:00
parent 307a26c0db
commit 64fc023f7b
9 changed files with 314 additions and 41 deletions

View File

@@ -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}` : ''}`;

View File

@@ -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;
}
}