feat: add password-gated config pages

This commit is contained in:
jocayn
2026-06-13 16:57:43 +08:00
parent 43728ec30e
commit c9d3665399
6 changed files with 891 additions and 22 deletions
+221 -16
View File
@@ -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) {