feat: add password-gated config pages
This commit is contained in:
+221
-16
@@ -2,6 +2,7 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
const { execSync, exec } = require('child_process');
|
||||
|
||||
// Auto-install js-yaml if missing
|
||||
@@ -415,6 +416,13 @@ const PORT = Number.parseInt(process.env.PORT || '3000', 10);
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
const PUBLIC_BASE_URL = String(process.env.PUBLIC_BASE_URL || '').trim().replace(/\/+$/, '');
|
||||
const AUTO_OPEN = !['0', 'false', 'no', 'off'].includes(String(process.env.AUTO_OPEN || '1').toLowerCase());
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin';
|
||||
const VIEWER_PASSWORD = process.env.VIEWER_PASSWORD || 'viewer';
|
||||
const SESSION_SECRET = process.env.SESSION_SECRET || ADMIN_PASSWORD || 'sub2proxy-session-secret';
|
||||
const LINK_TOKEN_SECRET = process.env.LINK_TOKEN_SECRET || SESSION_SECRET;
|
||||
const SESSION_COOKIE = 'sub2proxy_session';
|
||||
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const sessions = new Map();
|
||||
|
||||
// Central configuration generation function
|
||||
function generateConfigs(rawClashNodes, dns = null) {
|
||||
@@ -564,22 +572,168 @@ function readJsonBody(req) {
|
||||
});
|
||||
}
|
||||
|
||||
function sendJson(res, statusCode, payload) {
|
||||
res.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function serveHtml(res, fileName) {
|
||||
const htmlPath = path.join(__dirname, 'public', fileName);
|
||||
if (fs.existsSync(htmlPath)) {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(fs.readFileSync(htmlPath));
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end(`${fileName} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
function redirect(res, location) {
|
||||
res.writeHead(302, { Location: location });
|
||||
res.end();
|
||||
}
|
||||
|
||||
function parseCookies(req) {
|
||||
const header = req.headers.cookie || '';
|
||||
const cookies = {};
|
||||
header.split(';').forEach(part => {
|
||||
const index = part.indexOf('=');
|
||||
if (index === -1) return;
|
||||
const key = part.slice(0, index).trim();
|
||||
const value = part.slice(index + 1).trim();
|
||||
if (key) {
|
||||
try {
|
||||
cookies[key] = decodeURIComponent(value);
|
||||
} catch (e) {
|
||||
cookies[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
return cookies;
|
||||
}
|
||||
|
||||
function setSessionCookie(res, token) {
|
||||
const maxAge = Math.floor(SESSION_TTL_MS / 1000);
|
||||
res.setHeader('Set-Cookie', `${SESSION_COOKIE}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`);
|
||||
}
|
||||
|
||||
function clearSessionCookie(res) {
|
||||
res.setHeader('Set-Cookie', `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`);
|
||||
}
|
||||
|
||||
function safeCompare(a, b) {
|
||||
const left = Buffer.from(String(a || ''));
|
||||
const right = Buffer.from(String(b || ''));
|
||||
if (left.length !== right.length) return false;
|
||||
return crypto.timingSafeEqual(left, right);
|
||||
}
|
||||
|
||||
function authenticatePassword(password, requestedRole = 'admin') {
|
||||
if (requestedRole === 'viewer' && safeCompare(password, VIEWER_PASSWORD)) {
|
||||
return 'viewer';
|
||||
}
|
||||
if (safeCompare(password, ADMIN_PASSWORD)) {
|
||||
return 'admin';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function createSession(role) {
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
sessions.set(token, {
|
||||
role,
|
||||
expiresAt: Date.now() + SESSION_TTL_MS
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
function getSession(req) {
|
||||
const token = parseCookies(req)[SESSION_COOKIE];
|
||||
if (!token) return null;
|
||||
const session = sessions.get(token);
|
||||
if (!session) return null;
|
||||
if (session.expiresAt < Date.now()) {
|
||||
sessions.delete(token);
|
||||
return null;
|
||||
}
|
||||
session.expiresAt = Date.now() + SESSION_TTL_MS;
|
||||
return session;
|
||||
}
|
||||
|
||||
function hasRole(req, roles) {
|
||||
const session = getSession(req);
|
||||
if (!session) return false;
|
||||
if (roles.includes(session.role)) return true;
|
||||
return roles.includes('viewer') && session.role === 'admin';
|
||||
}
|
||||
|
||||
function requireRole(req, res, roles) {
|
||||
if (hasRole(req, roles)) return true;
|
||||
sendJson(res, 401, { error: 'Unauthorized' });
|
||||
return false;
|
||||
}
|
||||
|
||||
function getHistoryList() {
|
||||
const historyJsonPath = path.join(outDir, 'history', 'history.json');
|
||||
if (!fs.existsSync(historyJsonPath)) return [];
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(historyJsonPath, 'utf-8'));
|
||||
} catch (e) {
|
||||
console.error("Failed to parse history.json:", e.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function signFileName(fileName) {
|
||||
return crypto.createHmac('sha256', LINK_TOKEN_SECRET).update(fileName).digest('hex');
|
||||
}
|
||||
|
||||
function withFileTokens(historyList) {
|
||||
return historyList.map(item => ({
|
||||
...item,
|
||||
yamlToken: signFileName(item.yamlFile),
|
||||
jsonToken: signFileName(item.jsonFile)
|
||||
}));
|
||||
}
|
||||
|
||||
function hasFileAccess(req, fileName, token) {
|
||||
if (hasRole(req, ['admin', 'viewer'])) return true;
|
||||
return Boolean(token) && safeCompare(token, signFileName(fileName));
|
||||
}
|
||||
|
||||
// HTTP Server setup
|
||||
function startServer() {
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const parsedUrl = new URL(req.url, `http://${req.headers.host}`);
|
||||
const pathname = parsedUrl.pathname;
|
||||
|
||||
// Static Index file
|
||||
if (pathname === '/login' || pathname === '/login.html') {
|
||||
serveHtml(res, 'login.html');
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === '/viewer' || pathname === '/viewer.html') {
|
||||
redirect(res, '/sub');
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === '/sub' || pathname === '/sub/') {
|
||||
serveHtml(res, 'viewer.html');
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === '/' || pathname === '/index.html') {
|
||||
const htmlPath = path.join(__dirname, 'public', 'index.html');
|
||||
if (fs.existsSync(htmlPath)) {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(fs.readFileSync(htmlPath));
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end('index.html not found');
|
||||
redirect(res, '/config');
|
||||
return;
|
||||
}
|
||||
|
||||
// Static Index file
|
||||
if (pathname === '/config' || pathname === '/config/') {
|
||||
if (!hasRole(req, ['admin'])) {
|
||||
redirect(res, '/login.html');
|
||||
return;
|
||||
}
|
||||
serveHtml(res, 'index.html');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -590,6 +744,43 @@ function startServer() {
|
||||
return;
|
||||
}
|
||||
|
||||
// API: GET /api/session
|
||||
if (pathname === '/api/session' && req.method === 'GET') {
|
||||
const session = getSession(req);
|
||||
sendJson(res, 200, { authenticated: Boolean(session), role: session ? session.role : null });
|
||||
return;
|
||||
}
|
||||
|
||||
// API: POST /api/login
|
||||
if (pathname === '/api/login' && req.method === 'POST') {
|
||||
try {
|
||||
const { password, role } = await readJsonBody(req);
|
||||
const nextRole = authenticatePassword(password, role);
|
||||
if (!nextRole) {
|
||||
sendJson(res, 401, { error: '密码不正确' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = createSession(nextRole);
|
||||
setSessionCookie(res, token);
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
role: nextRole,
|
||||
redirectTo: nextRole === 'admin' ? '/config' : '/sub'
|
||||
});
|
||||
} catch (e) {
|
||||
sendJson(res, 400, { error: e.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// API: POST /api/logout
|
||||
if (pathname === '/api/logout' && req.method === 'POST') {
|
||||
clearSessionCookie(res);
|
||||
sendJson(res, 200, { success: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// API: GET /api/app-config
|
||||
if (pathname === '/api/app-config' && req.method === 'GET') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
@@ -599,6 +790,7 @@ function startServer() {
|
||||
|
||||
// API: GET /api/subs
|
||||
if (pathname === '/api/subs' && req.method === 'GET') {
|
||||
if (!requireRole(req, res, ['admin'])) return;
|
||||
if (fs.existsSync(subsFile)) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
res.end(fs.readFileSync(subsFile));
|
||||
@@ -611,6 +803,7 @@ function startServer() {
|
||||
|
||||
// API: POST /api/subs
|
||||
if (pathname === '/api/subs' && req.method === 'POST') {
|
||||
if (!requireRole(req, res, ['admin'])) return;
|
||||
try {
|
||||
const payload = await readJsonBody(req);
|
||||
fs.writeFileSync(subsFile, JSON.stringify(payload, null, 2), 'utf-8');
|
||||
@@ -625,6 +818,7 @@ function startServer() {
|
||||
|
||||
// API: GET /api/dns
|
||||
if (pathname === '/api/dns' && req.method === 'GET') {
|
||||
if (!requireRole(req, res, ['admin'])) return;
|
||||
try {
|
||||
const { yamlTpl, jsonTpl } = detectTemplates();
|
||||
const clashConfig = yaml.load(fs.readFileSync(yamlTpl, 'utf-8')) || {};
|
||||
@@ -644,6 +838,7 @@ function startServer() {
|
||||
|
||||
// API: POST /api/fetch (Fetches and converts in-memory)
|
||||
if (pathname === '/api/fetch' && req.method === 'POST') {
|
||||
if (!requireRole(req, res, ['admin'])) return;
|
||||
try {
|
||||
if (!fs.existsSync(subsFile)) {
|
||||
res.writeHead(400);
|
||||
@@ -696,6 +891,7 @@ function startServer() {
|
||||
|
||||
// API: POST /api/generate (Generates and writes configurations)
|
||||
if (pathname === '/api/generate' && req.method === 'POST') {
|
||||
if (!requireRole(req, res, ['admin'])) return;
|
||||
try {
|
||||
const { proxies: rawClashNodes, dns } = await readJsonBody(req);
|
||||
if (!rawClashNodes || !Array.isArray(rawClashNodes) || rawClashNodes.length === 0) {
|
||||
@@ -717,20 +913,15 @@ function startServer() {
|
||||
|
||||
// API: GET /api/history
|
||||
if (pathname === '/api/history' && req.method === 'GET') {
|
||||
const historyJsonPath = path.join(outDir, 'history', 'history.json');
|
||||
if (fs.existsSync(historyJsonPath)) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
res.end(fs.readFileSync(historyJsonPath));
|
||||
} else {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify([]));
|
||||
}
|
||||
if (!requireRole(req, res, ['admin', 'viewer'])) return;
|
||||
sendJson(res, 200, withFileTokens(getHistoryList()));
|
||||
return;
|
||||
}
|
||||
|
||||
// API: GET /api/download
|
||||
if (pathname === '/api/download' && req.method === 'GET') {
|
||||
const fileName = parsedUrl.searchParams.get('file');
|
||||
const token = parsedUrl.searchParams.get('token');
|
||||
if (!fileName) {
|
||||
res.writeHead(400);
|
||||
res.end('Missing file parameter');
|
||||
@@ -745,6 +936,12 @@ function startServer() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasFileAccess(req, fileName, token)) {
|
||||
res.writeHead(401);
|
||||
res.end('Unauthorized');
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = path.join(outDir, 'history', fileName);
|
||||
if (fs.existsSync(filePath)) {
|
||||
const isYaml = fileName.endsWith('.yaml');
|
||||
@@ -764,6 +961,7 @@ function startServer() {
|
||||
// Remote import clients should receive the profile body directly, not as a browser attachment.
|
||||
if (pathname === '/api/profile' && req.method === 'GET') {
|
||||
const fileName = parsedUrl.searchParams.get('file');
|
||||
const token = parsedUrl.searchParams.get('token');
|
||||
if (!fileName) {
|
||||
res.writeHead(400);
|
||||
res.end('Missing file parameter');
|
||||
@@ -777,6 +975,12 @@ function startServer() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasFileAccess(req, fileName, token)) {
|
||||
res.writeHead(401);
|
||||
res.end('Unauthorized');
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = path.join(outDir, 'history', fileName);
|
||||
if (fs.existsSync(filePath)) {
|
||||
const isYaml = fileName.endsWith('.yaml');
|
||||
@@ -795,6 +999,7 @@ function startServer() {
|
||||
|
||||
// API: POST /api/history/delete
|
||||
if (pathname === '/api/history/delete' && req.method === 'POST') {
|
||||
if (!requireRole(req, res, ['admin'])) return;
|
||||
try {
|
||||
const { yamlFile, jsonFile } = await readJsonBody(req);
|
||||
if (!yamlFile || !jsonFile) {
|
||||
|
||||
Reference in New Issue
Block a user