1478 lines
49 KiB
JavaScript
1478 lines
49 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* OrangePi Mihomo UI v2 — Complete Management Server
|
|
* ZERO external dependencies. Uses only Node.js built-in modules.
|
|
*
|
|
* Features:
|
|
* - JSON file database (/etc/mihomo/ui-data.json)
|
|
* - JWT-like token auth (scrypt + HMAC)
|
|
* - Full mihomo REST API proxy
|
|
* - Subscription CRUD with import/push
|
|
* - Transparent proxy iptables management
|
|
* - Network interface discovery
|
|
* - SSE traffic stream
|
|
* - Static file server for public/ and yacd-meta/
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const http = require('http');
|
|
const https = require('https');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const { URL } = require('url');
|
|
const { exec, execSync } = require('child_process');
|
|
const os = require('os');
|
|
|
|
// ============================================================
|
|
// Configuration
|
|
// ============================================================
|
|
const CONFIG = {
|
|
port: parseInt(process.env.PORT || '8899', 10),
|
|
mihomoApi: process.env.MIHOMO_API || 'http://127.0.0.1:9097',
|
|
mihomoSecret: process.env.MIHOMO_SECRET || 'orangepi',
|
|
mihomoConfig: process.env.MIHOMO_CONFIG || '/etc/mihomo/config.yaml',
|
|
dbPath: process.env.DB_PATH || '/etc/mihomo/ui-data.json',
|
|
yacdPath: process.env.YACD_PATH || path.join(__dirname, 'yacd-meta'),
|
|
publicPath: process.env.PUBLIC_PATH || path.join(__dirname, 'public'),
|
|
tokenSecret: process.env.TOKEN_SECRET || crypto.randomBytes(32).toString('hex'),
|
|
tokenExpiry: parseInt(process.env.TOKEN_EXPIRY || '86400', 10), // seconds
|
|
};
|
|
|
|
// ============================================================
|
|
// Database — simple JSON file at /etc/mihomo/ui-data.json
|
|
// ============================================================
|
|
const DB = {
|
|
_data: null,
|
|
_dirty: false,
|
|
_writeTimer: null,
|
|
|
|
_defaults() {
|
|
return {
|
|
users: [],
|
|
subscriptions: [],
|
|
settings: {
|
|
transparentProxy: { interfaces: [] },
|
|
selfProxy: true,
|
|
proxyMode: 'rule',
|
|
dns: { enable: true, listen: '0.0.0.0:1053', fakeIpRange: '198.18.0.0/16' },
|
|
ports: { mixed: 7890, socks: 7891, http: 7892, redir: 7893, tproxy: 7894 },
|
|
},
|
|
};
|
|
},
|
|
|
|
load() {
|
|
try {
|
|
const raw = fs.readFileSync(CONFIG.dbPath, 'utf-8');
|
|
this._data = JSON.parse(raw);
|
|
// Ensure all top-level keys exist
|
|
const defaults = this._defaults();
|
|
for (const key of Object.keys(defaults)) {
|
|
if (!(key in this._data)) this._data[key] = defaults[key];
|
|
}
|
|
} catch {
|
|
this._data = this._defaults();
|
|
this.save(true);
|
|
}
|
|
return this._data;
|
|
},
|
|
|
|
save(immediate = false) {
|
|
this._dirty = true;
|
|
if (immediate) {
|
|
this._flush();
|
|
return;
|
|
}
|
|
// Debounced write — max once per 500ms
|
|
if (this._writeTimer) return;
|
|
this._writeTimer = setTimeout(() => {
|
|
this._writeTimer = null;
|
|
this._flush();
|
|
}, 500);
|
|
},
|
|
|
|
_flush() {
|
|
if (!this._dirty) return;
|
|
this._dirty = false;
|
|
try {
|
|
const dir = path.dirname(CONFIG.dbPath);
|
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(CONFIG.dbPath, JSON.stringify(this._data, null, 2), 'utf-8');
|
|
} catch (e) {
|
|
console.error('[DB] Write error:', e.message);
|
|
}
|
|
},
|
|
|
|
get data() {
|
|
if (!this._data) this.load();
|
|
return this._data;
|
|
},
|
|
|
|
// Convenience accessors
|
|
get users() { return this.data.users; },
|
|
get subscriptions() { return this.data.subscriptions; },
|
|
get settings() { return this.data.settings; },
|
|
};
|
|
|
|
// ============================================================
|
|
// Auth — scrypt password hashing + HMAC-SHA256 tokens
|
|
// ============================================================
|
|
const Auth = {
|
|
_scrypt(password, salt) {
|
|
return crypto.scryptSync(password, salt, 64).toString('hex');
|
|
},
|
|
|
|
hashPassword(password) {
|
|
const salt = crypto.randomBytes(16).toString('hex');
|
|
const hash = this._scrypt(password, salt);
|
|
return { hash, salt };
|
|
},
|
|
|
|
verifyPassword(password, hash, salt) {
|
|
const computed = this._scrypt(password, salt);
|
|
return crypto.timingSafeEqual(Buffer.from(computed, 'hex'), Buffer.from(hash, 'hex'));
|
|
},
|
|
|
|
createToken(user) {
|
|
const payload = {
|
|
id: user.id,
|
|
username: user.username,
|
|
iat: Math.floor(Date.now() / 1000),
|
|
exp: Math.floor(Date.now() / 1000) + CONFIG.tokenExpiry,
|
|
};
|
|
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
|
|
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
const sig = crypto.createHmac('sha256', CONFIG.tokenSecret).update(`${header}.${body}`).digest('base64url');
|
|
return `${header}.${body}.${sig}`;
|
|
},
|
|
|
|
verifyToken(token) {
|
|
if (!token) return null;
|
|
const parts = token.split('.');
|
|
if (parts.length !== 3) return null;
|
|
const [header, body, sig] = parts;
|
|
const expected = crypto.createHmac('sha256', CONFIG.tokenSecret).update(`${header}.${body}`).digest('base64url');
|
|
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
|
|
try {
|
|
const payload = JSON.parse(Buffer.from(body, 'base64url').toString());
|
|
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) return null;
|
|
return payload;
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
|
|
extractToken(req) {
|
|
// Cookie
|
|
const cookies = parseCookies(req);
|
|
if (cookies.token) return cookies.token;
|
|
// Authorization header
|
|
const auth = req.headers.authorization || '';
|
|
if (auth.startsWith('Bearer ')) return auth.slice(7).trim();
|
|
// Query string fallback
|
|
try {
|
|
const url = new URL(req.url, 'http://localhost');
|
|
if (url.searchParams.has('token')) return url.searchParams.get('token');
|
|
} catch {}
|
|
return null;
|
|
},
|
|
|
|
authenticate(req) {
|
|
const token = this.extractToken(req);
|
|
return this.verifyToken(token);
|
|
},
|
|
|
|
ensureAuth(req, res) {
|
|
const user = this.authenticate(req);
|
|
if (!user) {
|
|
sendJson(res, { error: 'Unauthorized' }, 401);
|
|
return null;
|
|
}
|
|
return user;
|
|
},
|
|
};
|
|
|
|
function parseCookies(req) {
|
|
const cookies = {};
|
|
const header = req.headers.cookie || '';
|
|
for (const part of header.split(';')) {
|
|
const [name, ...rest] = part.trim().split('=');
|
|
if (name) cookies[name.trim()] = decodeURIComponent(rest.join('=').trim());
|
|
}
|
|
return cookies;
|
|
}
|
|
|
|
// ============================================================
|
|
// Utility helpers
|
|
// ============================================================
|
|
function fetchUrl(url, options = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const mod = url.startsWith('https') ? https : http;
|
|
const req = mod.get(url, { timeout: 15000, ...options }, (res) => {
|
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
return fetchUrl(res.headers.location, options).then(resolve).catch(reject);
|
|
}
|
|
const chunks = [];
|
|
res.on('data', (c) => chunks.push(c));
|
|
res.on('end', () => resolve({
|
|
status: res.statusCode,
|
|
headers: res.headers,
|
|
body: Buffer.concat(chunks),
|
|
}));
|
|
});
|
|
req.on('error', reject);
|
|
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
|
|
});
|
|
}
|
|
|
|
function httpRequest(urlStr, method, body, extraHeaders = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const url = new URL(urlStr);
|
|
const postData = body ? (typeof body === 'string' ? body : JSON.stringify(body)) : null;
|
|
const opts = {
|
|
method,
|
|
hostname: url.hostname,
|
|
port: url.port,
|
|
path: url.pathname + url.search,
|
|
headers: {
|
|
'Authorization': `Bearer ${CONFIG.mihomoSecret}`,
|
|
'Content-Type': 'application/json',
|
|
...extraHeaders,
|
|
},
|
|
timeout: 10000,
|
|
};
|
|
const mod = url.protocol === 'https:' ? https : http;
|
|
const req = mod.request(opts, (res) => {
|
|
const chunks = [];
|
|
res.on('data', (c) => chunks.push(c));
|
|
res.on('end', () => {
|
|
const raw = Buffer.concat(chunks).toString();
|
|
try { resolve({ status: res.statusCode, headers: res.headers, data: JSON.parse(raw) }); }
|
|
catch { resolve({ status: res.statusCode, headers: res.headers, data: raw }); }
|
|
});
|
|
});
|
|
req.on('error', reject);
|
|
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
|
|
if (postData) req.write(postData);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
function mihomoApi(method, apiPath, body) {
|
|
return httpRequest(`${CONFIG.mihomoApi}${apiPath}`, method, body);
|
|
}
|
|
|
|
function readBody(req) {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks = [];
|
|
req.on('data', (c) => chunks.push(c));
|
|
req.on('end', () => resolve(Buffer.concat(chunks).toString()));
|
|
req.on('error', reject);
|
|
});
|
|
}
|
|
|
|
function sendJson(res, data, status = 200) {
|
|
const body = JSON.stringify(data);
|
|
res.writeHead(status, {
|
|
'Content-Type': 'application/json; charset=utf-8',
|
|
'Content-Length': Buffer.byteLength(body),
|
|
});
|
|
res.end(body);
|
|
}
|
|
|
|
function sendError(res, msg, status = 500) {
|
|
sendJson(res, { error: msg }, status);
|
|
}
|
|
|
|
function execCommand(cmd, timeout = 15000) {
|
|
return new Promise((resolve, reject) => {
|
|
exec(cmd, { timeout }, (err, stdout, stderr) => {
|
|
if (err) reject(new Error(stderr || err.message));
|
|
else resolve(stdout.trim());
|
|
});
|
|
});
|
|
}
|
|
|
|
function generateId() {
|
|
return crypto.randomBytes(8).toString('hex');
|
|
}
|
|
|
|
// ========== Utility Functions ==========
|
|
function fetchUrl(url, options = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const mod = url.startsWith('https') ? https : http;
|
|
const req = mod.get(url, { timeout: 15000, ...options }, (res) => {
|
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
return fetchUrl(res.headers.location, options).then(resolve).catch(reject);
|
|
}
|
|
const chunks = [];
|
|
res.on('data', (c) => chunks.push(c));
|
|
res.on('end', () => resolve({
|
|
status: res.statusCode,
|
|
headers: res.headers,
|
|
body: Buffer.concat(chunks),
|
|
}));
|
|
});
|
|
req.on('error', reject);
|
|
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
|
|
});
|
|
}
|
|
|
|
function mihomoApi(method, apiPath, body) {
|
|
return new Promise((resolve, reject) => {
|
|
const url = new URL(apiPath, CONFIG.mihomoApi);
|
|
const postData = body ? JSON.stringify(body) : null;
|
|
const opts = {
|
|
method,
|
|
hostname: url.hostname,
|
|
port: url.port,
|
|
path: url.pathname + url.search,
|
|
headers: {
|
|
'Authorization': `Bearer ${CONFIG.mihomoSecret}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
timeout: 10000,
|
|
};
|
|
const mod = url.protocol === 'https:' ? https : http;
|
|
const req = mod.request(opts, (res) => {
|
|
const chunks = [];
|
|
res.on('data', (c) => chunks.push(c));
|
|
res.on('end', () => {
|
|
const raw = Buffer.concat(chunks).toString();
|
|
try { resolve({ status: res.statusCode, data: JSON.parse(raw) }); }
|
|
catch { resolve({ status: res.statusCode, data: raw }); }
|
|
});
|
|
});
|
|
req.on('error', reject);
|
|
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
|
|
if (postData) req.write(postData);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
function readBody(req) {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks = [];
|
|
req.on('data', (c) => chunks.push(c));
|
|
req.on('end', () => resolve(Buffer.concat(chunks).toString()));
|
|
req.on('error', reject);
|
|
});
|
|
}
|
|
|
|
function sendJson(res, data, status = 200) {
|
|
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
res.end(JSON.stringify(data));
|
|
}
|
|
|
|
function sendError(res, msg, status = 500) {
|
|
sendJson(res, { error: msg }, status);
|
|
}
|
|
|
|
function execCommand(cmd) {
|
|
return new Promise((resolve, reject) => {
|
|
exec(cmd, { timeout: 15000 }, (err, stdout, stderr) => {
|
|
if (err) reject(new Error(stderr || err.message));
|
|
else resolve(stdout.trim());
|
|
});
|
|
});
|
|
}
|
|
|
|
// ============================================================
|
|
// Static File Server
|
|
// ============================================================
|
|
const MIME_TYPES = {
|
|
'.html': 'text/html; charset=utf-8',
|
|
'.css': 'text/css; charset=utf-8',
|
|
'.js': 'application/javascript; charset=utf-8',
|
|
'.json': 'application/json',
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
'.gif': 'image/gif',
|
|
'.ico': 'image/x-icon',
|
|
'.svg': 'image/svg+xml',
|
|
'.webp': 'image/webp',
|
|
'.woff': 'font/woff',
|
|
'.woff2': 'font/woff2',
|
|
'.ttf': 'font/ttf',
|
|
'.eot': 'application/vnd.ms-fontobject',
|
|
'.map': 'application/json',
|
|
'.webmanifest': 'application/manifest+json',
|
|
'.txt': 'text/plain; charset=utf-8',
|
|
'.xml': 'application/xml',
|
|
};
|
|
|
|
function serveStatic(res, filePath) {
|
|
// Prevent directory traversal
|
|
const resolved = path.resolve(filePath);
|
|
if (!resolved.startsWith(path.resolve(CONFIG.publicPath)) &&
|
|
!resolved.startsWith(path.resolve(CONFIG.yacdPath))) {
|
|
res.writeHead(403);
|
|
return res.end('Forbidden');
|
|
}
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
const mime = MIME_TYPES[ext] || 'application/octet-stream';
|
|
try {
|
|
const stat = fs.statSync(filePath);
|
|
if (stat.isDirectory()) {
|
|
filePath = path.join(filePath, 'index.html');
|
|
if (!fs.existsSync(filePath)) {
|
|
res.writeHead(404);
|
|
return res.end('Not Found');
|
|
}
|
|
}
|
|
const data = fs.readFileSync(filePath);
|
|
res.writeHead(200, {
|
|
'Content-Type': mime,
|
|
'Content-Length': data.length,
|
|
'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=3600',
|
|
});
|
|
res.end(data);
|
|
} catch {
|
|
res.writeHead(404);
|
|
res.end('Not Found');
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Transparent Proxy — iptables management
|
|
// ============================================================
|
|
const TransparentProxy = {
|
|
// Private IP ranges to bypass
|
|
PRIVATE_RANGES: [
|
|
'-d 192.168.10.1 -j RETURN',
|
|
'-d 192.168.0.0/16 -j RETURN',
|
|
'-d 10.0.0.0/8 -j RETURN',
|
|
'-d 172.16.0.0/12 -j RETURN',
|
|
'-d 127.0.0.0/8 -j RETURN',
|
|
],
|
|
|
|
// Mark for mihomo's own outbound traffic
|
|
ROUTING_MARK: 255,
|
|
REDIR_PORT: 7893,
|
|
DNS_PORT: 1053,
|
|
|
|
_exec(cmd) {
|
|
return new Promise((resolve, reject) => {
|
|
exec(cmd, { timeout: 10000 }, (err, stdout, stderr) => {
|
|
if (err) reject(new Error(stderr || err.message));
|
|
else resolve(stdout.trim());
|
|
});
|
|
});
|
|
},
|
|
|
|
async getActiveInterfaces() {
|
|
const settings = DB.settings;
|
|
return settings.transparentProxy.interfaces || [];
|
|
},
|
|
|
|
async enableForInterface(iface) {
|
|
const settings = DB.settings;
|
|
const list = settings.transparentProxy.interfaces || [];
|
|
if (list.includes(iface)) return { ok: true, message: 'Already enabled' };
|
|
|
|
// Add iptables rules for this interface (PRIVATE_RANGES already includes 192.168.10.1)
|
|
const rules = [
|
|
...this.PRIVATE_RANGES.map(r => {
|
|
return `iptables -t nat -C PREROUTING -i ${iface} ${r} 2>/dev/null || iptables -t nat -I PREROUTING 1 -i ${iface} ${r}`;
|
|
}),
|
|
`iptables -t nat -C PREROUTING -i ${iface} -p tcp -j REDIRECT --to-ports ${this.REDIR_PORT} 2>/dev/null || iptables -t nat -A PREROUTING -i ${iface} -p tcp -j REDIRECT --to-ports ${this.REDIR_PORT}`,
|
|
`iptables -t nat -C PREROUTING -i ${iface} -p udp --dport 53 -j REDIRECT --to-ports ${this.DNS_PORT} 2>/dev/null || iptables -t nat -A PREROUTING -i ${iface} -p udp --dport 53 -j REDIRECT --to-ports ${this.DNS_PORT}`,
|
|
];
|
|
|
|
for (const rule of rules) {
|
|
try { await this._exec(rule); } catch (e) {
|
|
console.error('[TransparentProxy] Rule failed:', rule, e.message);
|
|
}
|
|
}
|
|
|
|
// Ensure OUTPUT mark bypass
|
|
try {
|
|
await this._exec(`iptables -t nat -C OUTPUT -m mark --mark ${this.ROUTING_MARK} -j RETURN 2>/dev/null || iptables -t nat -I OUTPUT 1 -m mark --mark ${this.ROUTING_MARK} -j RETURN`);
|
|
} catch {}
|
|
|
|
// Ensure DNS redirect for router itself
|
|
try {
|
|
await this._exec(`iptables -t nat -C OUTPUT -p udp --dport 53 ! -d 127.0.0.1 -j REDIRECT --to-ports ${this.DNS_PORT} 2>/dev/null || iptables -t nat -A OUTPUT -p udp --dport 53 ! -d 127.0.0.1 -j REDIRECT --to-ports ${this.DNS_PORT}`);
|
|
} catch {}
|
|
|
|
if (!list.includes(iface)) {
|
|
list.push(iface);
|
|
settings.transparentProxy.interfaces = list;
|
|
DB.save();
|
|
}
|
|
|
|
return { ok: true, message: `Transparent proxy enabled on ${iface}` };
|
|
},
|
|
|
|
async disableForInterface(iface) {
|
|
const settings = DB.settings;
|
|
const list = settings.transparentProxy.interfaces || [];
|
|
|
|
// Remove iptables rules for this interface
|
|
const rules = [
|
|
`iptables -t nat -D PREROUTING -i ${iface} -d 192.168.10.1 -j RETURN 2>/dev/null`,
|
|
...this.PRIVATE_RANGES.map(r => `iptables -t nat -D PREROUTING -i ${iface} ${r.replace('-A', '-D')} 2>/dev/null`),
|
|
`iptables -t nat -D PREROUTING -i ${iface} -p tcp -j REDIRECT --to-ports ${this.REDIR_PORT} 2>/dev/null`,
|
|
`iptables -t nat -D PREROUTING -i ${iface} -p udp --dport 53 -j REDIRECT --to-ports ${this.DNS_PORT} 2>/dev/null`,
|
|
];
|
|
|
|
for (const rule of rules) {
|
|
try { await this._exec(rule); } catch {}
|
|
}
|
|
|
|
const idx = list.indexOf(iface);
|
|
if (idx !== -1) {
|
|
list.splice(idx, 1);
|
|
settings.transparentProxy.interfaces = list;
|
|
DB.save();
|
|
}
|
|
|
|
return { ok: true, message: `Transparent proxy disabled on ${iface}` };
|
|
},
|
|
|
|
async disableAll() {
|
|
const interfaces = await this.getActiveInterfaces();
|
|
for (const iface of interfaces) {
|
|
await this.disableForInterface(iface);
|
|
}
|
|
// Also remove OUTPUT chain rules (DNS redirect and mark bypass)
|
|
try {
|
|
await this._exec(`iptables -t nat -D OUTPUT -m mark --mark ${this.ROUTING_MARK} -j RETURN 2>/dev/null`);
|
|
} catch {}
|
|
try {
|
|
await this._exec(`iptables -t nat -D OUTPUT -p udp --dport 53 ! -d 127.0.0.1 -j REDIRECT --to-ports ${this.DNS_PORT} 2>/dev/null`);
|
|
} catch {}
|
|
return { ok: true };
|
|
},
|
|
|
|
async getStatus() {
|
|
// Check actual iptables/nft rules to find interfaces with TCP REDIRECT to our redir port
|
|
const activeFromIptables = new Set();
|
|
try {
|
|
// Use nft (authoritative since table is iptables-nft managed)
|
|
const nftOutput = await this._exec('nft list chain ip nat PREROUTING 2>/dev/null || echo ""');
|
|
for (const line of nftOutput.split('\n')) {
|
|
// Only match REDIRECT rules (not RETURN) to detect truly enabled interfaces
|
|
const m = line.match(/iifname\s+"([^"]+)".*redirect\s+to\s+:(\d+)/);
|
|
if (m && m[1] !== 'lo' && (parseInt(m[2]) === this.REDIR_PORT || parseInt(m[2]) === this.DNS_PORT)) {
|
|
activeFromIptables.add(m[1]);
|
|
}
|
|
}
|
|
// Fallback: also check iptables format if nft gave nothing
|
|
if (activeFromIptables.size === 0) {
|
|
const output = await this._exec('iptables -t nat -L PREROUTING -n 2>/dev/null || echo ""');
|
|
for (const line of output.split('\n')) {
|
|
const parts = line.trim().split(/\s+/);
|
|
// Format: target prot opt in out source dest [options]
|
|
// parts[0]=target, parts[3]=in, parts[4]=out
|
|
if (parts[0] === 'REDIRECT' && parts[3] && parts[3] !== '*' && parts[3] !== 'lo') {
|
|
const portMatch = line.match(/redir ports (\d+)/);
|
|
if (portMatch && (parseInt(portMatch[1]) === this.REDIR_PORT || parseInt(portMatch[1]) === this.DNS_PORT)) {
|
|
activeFromIptables.add(parts[3]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch {}
|
|
|
|
// Sync database with actual iptables state
|
|
const settings = DB.settings;
|
|
const dbInterfaces = settings.transparentProxy.interfaces || [];
|
|
|
|
// If iptables shows rules but DB doesn't, sync DB
|
|
if (activeFromIptables.size > 0) {
|
|
for (const iface of activeFromIptables) {
|
|
if (!dbInterfaces.includes(iface)) {
|
|
dbInterfaces.push(iface);
|
|
}
|
|
}
|
|
settings.transparentProxy.interfaces = dbInterfaces;
|
|
DB.save();
|
|
}
|
|
|
|
return { interfaces: [...new Set([...dbInterfaces, ...activeFromIptables])] };
|
|
},
|
|
|
|
async restoreFromSettings() {
|
|
// Re-apply iptables rules for interfaces saved in settings (called on startup)
|
|
const interfaces = await this.getActiveInterfaces();
|
|
for (const iface of interfaces) {
|
|
try {
|
|
await this.enableForInterface(iface);
|
|
} catch (e) {
|
|
console.error(`[TransparentProxy] Failed to restore rules for ${iface}:`, e.message);
|
|
}
|
|
}
|
|
},
|
|
};
|
|
|
|
// ============================================================
|
|
// Network Interfaces — /sys/class/net + ip addr
|
|
// ============================================================
|
|
const Network = {
|
|
async listInterfaces() {
|
|
const ifaces = [];
|
|
try {
|
|
const dirs = fs.readdirSync('/sys/class/net');
|
|
for (const name of dirs) {
|
|
const iface = { name, addresses: [], carrier: false, mtu: 0, mac: '' };
|
|
|
|
// Carrier status
|
|
try {
|
|
const carrier = fs.readFileSync(`/sys/class/net/${name}/carrier`, 'utf-8').trim();
|
|
iface.carrier = carrier === '1';
|
|
} catch { iface.carrier = false; }
|
|
|
|
// MTU
|
|
try {
|
|
iface.mtu = parseInt(fs.readFileSync(`/sys/class/net/${name}/mtu`, 'utf-8').trim(), 10);
|
|
} catch {}
|
|
|
|
// MAC
|
|
try {
|
|
iface.mac = fs.readFileSync(`/sys/class/net/${name}/address`, 'utf-8').trim();
|
|
} catch {}
|
|
|
|
// Operational state
|
|
try {
|
|
iface.state = fs.readFileSync(`/sys/class/net/${name}/operstate`, 'utf-8').trim();
|
|
} catch { iface.state = 'unknown'; }
|
|
|
|
// Type
|
|
try {
|
|
const type = fs.readFileSync(`/sys/class/net/${name}/type`, 'utf-8').trim();
|
|
iface.type = type === '1' ? 'ethernet' : type === '772' ? 'loopback' : `type_${type}`;
|
|
} catch {}
|
|
|
|
// IP addresses from ip addr
|
|
try {
|
|
const ipOutput = await execCommand(`ip -4 addr show ${name} 2>/dev/null`);
|
|
const inetMatch = ipOutput.match(/inet\s+(\d+\.\d+\.\d+\.\d+\/\d+)/g);
|
|
if (inetMatch) {
|
|
iface.addresses = inetMatch.map(m => m.replace('inet ', ''));
|
|
}
|
|
} catch {}
|
|
|
|
// IPv6
|
|
try {
|
|
const ip6Output = await execCommand(`ip -6 addr show ${name} scope global 2>/dev/null`);
|
|
const inet6Match = ip6Output.match(/inet6\s+([0-9a-f:]+\/\d+)/gi);
|
|
if (inet6Match) {
|
|
iface.addresses6 = inet6Match.map(m => m.replace(/inet6\s+/i, ''));
|
|
}
|
|
} catch {}
|
|
|
|
ifaces.push({
|
|
name: iface.name,
|
|
up: iface.carrier === true && (iface.addresses.length > 0 || iface.name.startsWith('br') || iface.name === 'lo'),
|
|
state: iface.state,
|
|
ip: (iface.addresses && iface.addresses.length > 0) ? iface.addresses[0].split('/')[0] : '',
|
|
mac: iface.mac,
|
|
mtu: iface.mtu,
|
|
carrier: iface.carrier,
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.error('[Network] Failed to list interfaces:', e.message);
|
|
}
|
|
return ifaces;
|
|
},
|
|
};
|
|
|
|
// ============================================================
|
|
// Default admin creation on first run
|
|
// ============================================================
|
|
function ensureDefaultAdmin() {
|
|
const users = DB.users;
|
|
if (users.length === 0) {
|
|
const { hash, salt } = Auth.hashPassword('admin');
|
|
users.push({
|
|
id: generateId(),
|
|
username: 'admin',
|
|
passwordHash: hash,
|
|
salt,
|
|
createdAt: new Date().toISOString(),
|
|
});
|
|
DB.save(true);
|
|
console.log('[Auth] Default admin user created (admin/admin)');
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// API Route Handlers
|
|
// ============================================================
|
|
async function handleApi(req, res, apiPath, query) {
|
|
const method = req.method;
|
|
let body = null;
|
|
|
|
// ---- Auth routes (public) ----
|
|
|
|
// POST /api/auth/login
|
|
if (apiPath === '/api/auth/login' && method === 'POST') {
|
|
body = JSON.parse(await readBody(req));
|
|
const { username, password } = body;
|
|
if (!username || !password) return sendError(res, 'Missing username or password', 400);
|
|
|
|
const user = DB.users.find(u => u.username === username);
|
|
if (!user || !Auth.verifyPassword(password, user.passwordHash, user.salt)) {
|
|
return sendError(res, 'Invalid credentials', 401);
|
|
}
|
|
|
|
const token = Auth.createToken(user);
|
|
res.setHeader('Set-Cookie', `token=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${CONFIG.tokenExpiry}`);
|
|
return sendJson(res, { ok: true, token, user: { id: user.id, username: user.username } });
|
|
}
|
|
|
|
// POST /api/auth/register
|
|
if (apiPath === '/api/auth/register' && method === 'POST') {
|
|
body = JSON.parse(await readBody(req));
|
|
const { username, password } = body;
|
|
if (!username || !password) return sendError(res, 'Missing username or password', 400);
|
|
if (username.length < 3) return sendError(res, 'Username must be at least 3 characters', 400);
|
|
if (password.length < 4) return sendError(res, 'Password must be at least 4 characters', 400);
|
|
if (DB.users.find(u => u.username === username)) return sendError(res, 'Username already exists', 409);
|
|
|
|
const { hash, salt } = Auth.hashPassword(password);
|
|
const user = {
|
|
id: generateId(),
|
|
username,
|
|
passwordHash: hash,
|
|
salt,
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
DB.users.push(user);
|
|
DB.save();
|
|
|
|
const token = Auth.createToken(user);
|
|
res.setHeader('Set-Cookie', `token=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${CONFIG.tokenExpiry}`);
|
|
return sendJson(res, { ok: true, token, user: { id: user.id, username: user.username } }, 201);
|
|
}
|
|
|
|
// ---- Auth routes (authenticated) ----
|
|
|
|
// GET /api/auth/me
|
|
if (apiPath === '/api/auth/me' && method === 'GET') {
|
|
const authUser = Auth.ensureAuth(req, res);
|
|
if (!authUser) return;
|
|
const user = DB.users.find(u => u.id === authUser.id);
|
|
if (!user) return sendError(res, 'User not found', 404);
|
|
return sendJson(res, { id: user.id, username: user.username, createdAt: user.createdAt });
|
|
}
|
|
|
|
// POST /api/auth/change-password
|
|
if (apiPath === '/api/auth/change-password' && method === 'POST') {
|
|
const authUser = Auth.ensureAuth(req, res);
|
|
if (!authUser) return;
|
|
body = JSON.parse(await readBody(req));
|
|
const { oldPassword, newPassword } = body;
|
|
if (!oldPassword || !newPassword) return sendError(res, 'Missing old/new password', 400);
|
|
if (newPassword.length < 4) return sendError(res, 'New password must be at least 4 characters', 400);
|
|
|
|
const user = DB.users.find(u => u.id === authUser.id);
|
|
if (!user || !Auth.verifyPassword(oldPassword, user.passwordHash, user.salt)) {
|
|
return sendError(res, 'Invalid current password', 401);
|
|
}
|
|
|
|
const { hash, salt } = Auth.hashPassword(newPassword);
|
|
user.passwordHash = hash;
|
|
user.salt = salt;
|
|
DB.save();
|
|
|
|
return sendJson(res, { ok: true, message: 'Password changed' });
|
|
}
|
|
|
|
// ---- All remaining routes require authentication ----
|
|
const authUser = Auth.ensureAuth(req, res);
|
|
if (!authUser) return;
|
|
|
|
// ---- System status ----
|
|
|
|
// GET /api/status
|
|
if (apiPath === '/api/status' && method === 'GET') {
|
|
try {
|
|
const [version, configs, proxies, connections] = await Promise.all([
|
|
mihomoApi('GET', '/version').catch(() => ({ data: null })),
|
|
mihomoApi('GET', '/configs').catch(() => ({ data: null })),
|
|
mihomoApi('GET', '/proxies').catch(() => ({ data: { proxies: {} } })),
|
|
mihomoApi('GET', '/connections').catch(() => ({ data: { connections: [] } })),
|
|
]);
|
|
|
|
let serviceStatus = 'unknown';
|
|
try { serviceStatus = await execCommand('systemctl is-active mihomo'); } catch {}
|
|
|
|
const uptime = os.uptime();
|
|
const totalMem = os.totalmem();
|
|
const freeMem = os.freemem();
|
|
const loadAvg = os.loadavg();
|
|
|
|
return sendJson(res, {
|
|
version: version.data,
|
|
config: configs.data,
|
|
proxyCount: proxies.data ? Object.keys(proxies.data.proxies || {}).length : 0,
|
|
connectionCount: connections.data ? (connections.data.connections || []).length : 0,
|
|
serviceStatus,
|
|
system: {
|
|
hostname: os.hostname(),
|
|
platform: os.platform(),
|
|
arch: os.arch(),
|
|
uptime,
|
|
memory: { total: totalMem, free: freeMem, used: totalMem - freeMem },
|
|
loadAvg,
|
|
cpus: os.cpus().length,
|
|
},
|
|
});
|
|
} catch (e) {
|
|
return sendError(res, 'Cannot connect to mihomo: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// ---- Mihomo service control ----
|
|
|
|
// GET /api/mihomo/status
|
|
if (apiPath === '/api/mihomo/status' && method === 'GET') {
|
|
try {
|
|
const active = await execCommand('systemctl is-active mihomo');
|
|
const enabled = await execCommand('systemctl is-enabled mihomo');
|
|
let pid = '';
|
|
try { pid = await execCommand('systemctl show mihomo --property=MainPID --value'); } catch {}
|
|
return sendJson(res, { active: active === 'active', enabled: enabled === 'enabled', pid, status: active });
|
|
} catch {
|
|
return sendJson(res, { active: false, enabled: false, pid: '', status: 'unknown' });
|
|
}
|
|
}
|
|
|
|
// POST /api/mihomo/start
|
|
if (apiPath === '/api/mihomo/start' && method === 'POST') {
|
|
try {
|
|
await execCommand('systemctl start mihomo');
|
|
// Restore transparent proxy rules after mihomo starts
|
|
try { await TransparentProxy.restoreFromSettings(); } catch (e) {
|
|
console.error('[Mihomo] Failed to restore transparent proxy:', e.message);
|
|
}
|
|
return sendJson(res, { ok: true, message: 'Mihomo started' });
|
|
} catch (e) {
|
|
return sendError(res, 'Failed to start: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// POST /api/mihomo/stop
|
|
if (apiPath === '/api/mihomo/stop' && method === 'POST') {
|
|
try {
|
|
// Remove transparent proxy rules BEFORE stopping mihomo
|
|
// so LAN traffic can still flow directly without being dropped
|
|
try { await TransparentProxy.disableAll(); } catch (e) {
|
|
console.error('[Mihomo] Failed to remove transparent proxy:', e.message);
|
|
}
|
|
await execCommand('systemctl stop mihomo');
|
|
return sendJson(res, { ok: true, message: 'Mihomo stopped' });
|
|
} catch (e) {
|
|
return sendError(res, 'Failed to stop: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// POST /api/mihomo/restart
|
|
if (apiPath === '/api/mihomo/restart' && method === 'POST') {
|
|
try {
|
|
// Remove transparent proxy rules before restart
|
|
try { await TransparentProxy.disableAll(); } catch (e) {
|
|
console.error('[Mihomo] Failed to remove transparent proxy:', e.message);
|
|
}
|
|
await execCommand('systemctl restart mihomo');
|
|
// Restore transparent proxy rules after restart
|
|
try { await TransparentProxy.restoreFromSettings(); } catch (e) {
|
|
console.error('[Mihomo] Failed to restore transparent proxy:', e.message);
|
|
}
|
|
return sendJson(res, { ok: true, message: 'Mihomo restarted' });
|
|
} catch (e) {
|
|
return sendError(res, 'Failed to restart: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// POST /api/mihomo/reload
|
|
if (apiPath === '/api/mihomo/reload' && method === 'POST') {
|
|
try {
|
|
await execCommand('systemctl reload mihomo || systemctl restart mihomo');
|
|
return sendJson(res, { ok: true, message: 'Mihomo reloaded' });
|
|
} catch (e) {
|
|
return sendError(res, 'Failed to reload: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// ---- Proxy management ----
|
|
|
|
// GET /api/proxies
|
|
if (apiPath === '/api/proxies' && method === 'GET') {
|
|
try {
|
|
const result = await mihomoApi('GET', '/proxies');
|
|
return sendJson(res, result.data);
|
|
} catch (e) {
|
|
return sendError(res, e.message);
|
|
}
|
|
}
|
|
|
|
// GET /api/proxy-groups
|
|
if (apiPath === '/api/proxy-groups' && method === 'GET') {
|
|
try {
|
|
const result = await mihomoApi('GET', '/proxies');
|
|
const proxies = (result.data && result.data.proxies) || {};
|
|
const groups = {};
|
|
for (const [name, proxy] of Object.entries(proxies)) {
|
|
if (proxy.type !== 'Direct' && proxy.type !== 'Reject' && proxy.all && proxy.all.length > 0) {
|
|
groups[name] = proxy;
|
|
}
|
|
}
|
|
return sendJson(res, groups);
|
|
} catch (e) {
|
|
return sendError(res, e.message);
|
|
}
|
|
}
|
|
|
|
// PUT /api/proxy-group/:name
|
|
if (apiPath.startsWith('/api/proxy-group/') && method === 'PUT') {
|
|
const groupName = decodeURIComponent(apiPath.split('/api/proxy-group/')[1]);
|
|
body = JSON.parse(await readBody(req));
|
|
try {
|
|
await mihomoApi('PUT', `/proxies/${encodeURIComponent(groupName)}`, { name: body.name });
|
|
return sendJson(res, { ok: true });
|
|
} catch (e) {
|
|
return sendError(res, e.message);
|
|
}
|
|
}
|
|
|
|
// GET /api/delay/:name
|
|
if (apiPath.startsWith('/api/delay/') && method === 'GET') {
|
|
const proxyName = decodeURIComponent(apiPath.split('/api/delay/')[1]);
|
|
try {
|
|
const timeout = query.get('timeout') || '5000';
|
|
const testUrl = query.get('url') || 'http://www.gstatic.com/generate_204';
|
|
const result = await mihomoApi('GET', `/proxies/${encodeURIComponent(proxyName)}/delay?timeout=${timeout}&url=${encodeURIComponent(testUrl)}`);
|
|
return sendJson(res, result.data);
|
|
} catch (e) {
|
|
return sendError(res, e.message);
|
|
}
|
|
}
|
|
|
|
// ---- Subscriptions CRUD ----
|
|
|
|
// GET /api/subscriptions
|
|
if (apiPath === '/api/subscriptions' && method === 'GET') {
|
|
return sendJson(res, DB.subscriptions);
|
|
}
|
|
|
|
// POST /api/subscriptions — create a new subscription entry
|
|
if (apiPath === '/api/subscriptions' && method === 'POST') {
|
|
body = JSON.parse(await readBody(req));
|
|
const { name, url, traffic, expiry } = body;
|
|
if (!name || !url) return sendError(res, 'Missing name or url', 400);
|
|
|
|
const sub = {
|
|
id: generateId(),
|
|
name,
|
|
url,
|
|
traffic: traffic || null,
|
|
expiry: expiry || null,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
DB.subscriptions.push(sub);
|
|
DB.save();
|
|
return sendJson(res, sub, 201);
|
|
}
|
|
|
|
// DELETE /api/subscriptions/:id
|
|
if (apiPath.startsWith('/api/subscriptions/') && method === 'DELETE') {
|
|
const subId = apiPath.split('/api/subscriptions/')[1];
|
|
const idx = DB.subscriptions.findIndex(s => s.id === subId);
|
|
if (idx === -1) return sendError(res, 'Subscription not found', 404);
|
|
DB.subscriptions.splice(idx, 1);
|
|
DB.save();
|
|
return sendJson(res, { ok: true });
|
|
}
|
|
|
|
// PUT /api/subscriptions/:id — update
|
|
if (apiPath.startsWith('/api/subscriptions/') && method === 'PUT') {
|
|
const subId = apiPath.split('/api/subscriptions/')[1];
|
|
const sub = DB.subscriptions.find(s => s.id === subId);
|
|
if (!sub) return sendError(res, 'Subscription not found', 404);
|
|
body = JSON.parse(await readBody(req));
|
|
if (body.name !== undefined) sub.name = body.name;
|
|
if (body.url !== undefined) sub.url = body.url;
|
|
if (body.traffic !== undefined) sub.traffic = body.traffic;
|
|
if (body.expiry !== undefined) sub.expiry = body.expiry;
|
|
sub.updatedAt = new Date().toISOString();
|
|
DB.save();
|
|
return sendJson(res, sub);
|
|
}
|
|
|
|
// POST /api/subscription/import — fetch sub URL, decode, apply to mihomo
|
|
if (apiPath === '/api/subscription/import' && method === 'POST') {
|
|
body = JSON.parse(await readBody(req));
|
|
const { url, name } = body;
|
|
if (!url) return sendError(res, 'Missing url', 400);
|
|
|
|
try {
|
|
const subRes = await fetchUrl(url, { headers: { 'User-Agent': 'ClashForAndroid/2.5.12' } });
|
|
if (subRes.status !== 200) return sendError(res, `Upstream HTTP ${subRes.status}`);
|
|
|
|
let content = subRes.body.toString('utf-8');
|
|
// Try base64 decode
|
|
try {
|
|
const decoded = Buffer.from(content.trim(), 'base64').toString('utf-8');
|
|
if (decoded.includes('://') || decoded.includes('proxies')) content = decoded;
|
|
} catch {}
|
|
|
|
// Save to subscriptions DB
|
|
const existing = DB.subscriptions.find(s => s.url === url);
|
|
if (existing) {
|
|
existing.updatedAt = new Date().toISOString();
|
|
} else {
|
|
DB.subscriptions.push({
|
|
id: generateId(),
|
|
name: name || url.substring(0, 60),
|
|
url,
|
|
traffic: null,
|
|
expiry: null,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
}
|
|
DB.save();
|
|
|
|
return sendJson(res, {
|
|
ok: true,
|
|
message: 'Subscription fetched successfully',
|
|
preview: content.substring(0, 1000),
|
|
size: content.length,
|
|
});
|
|
} catch (e) {
|
|
return sendError(res, 'Failed to fetch subscription: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// POST /api/subscription/push — push content directly to mihomo
|
|
if (apiPath === '/api/subscription/push' && method === 'POST') {
|
|
body = JSON.parse(await readBody(req));
|
|
const { content, url } = body;
|
|
if (!content) return sendError(res, 'Missing content', 400);
|
|
|
|
try {
|
|
const configDir = path.dirname(CONFIG.mihomoConfig);
|
|
const subConfigPath = path.join(configDir, 'sub-config.yaml');
|
|
|
|
// Inject mihomo controller settings
|
|
let configContent = content;
|
|
configContent = configContent.replace(
|
|
/external-controller:\s*'[^']*'/,
|
|
"external-controller: '0.0.0.0:9097'"
|
|
);
|
|
configContent = configContent.replace(
|
|
/secret:\s*"[^"]*"/,
|
|
'secret: "orangepi"'
|
|
);
|
|
|
|
// Inject routing-mark if not present
|
|
if (!configContent.includes('routing-mark:')) {
|
|
configContent = configContent.replace(
|
|
/^(mixed-port:.*|allow-lan:.*)/m,
|
|
`$1\nrouting-mark: 255`
|
|
);
|
|
}
|
|
|
|
// Inject Xiaomi DIRECT rules before existing rules
|
|
const xiaomiRules = [
|
|
'# Xiaomi domains - DIRECT',
|
|
' - DOMAIN-SUFFIX,mi.com,DIRECT',
|
|
' - DOMAIN-SUFFIX,miui.com,DIRECT',
|
|
' - DOMAIN-SUFFIX,xiaomi.com,DIRECT',
|
|
' - DOMAIN-SUFFIX,xiaomi.net,DIRECT',
|
|
' - DOMAIN-SUFFIX,mijia.tech,DIRECT',
|
|
' - DOMAIN-SUFFIX,duokan.com,DIRECT',
|
|
' - DOMAIN-SUFFIX,mi-img.com,DIRECT',
|
|
' - DOMAIN-SUFFIX,miwifi.com,DIRECT',
|
|
' - DOMAIN-SUFFIX,xiaomiev.com,DIRECT',
|
|
' - DOMAIN-SUFFIX,xiaomiyoupin.com,DIRECT',
|
|
' - DOMAIN-KEYWORD,xiaomi,DIRECT',
|
|
' - DOMAIN-KEYWORD,miui,DIRECT',
|
|
' - DOMAIN-KEYWORD,mijia,DIRECT',
|
|
].join('\n');
|
|
configContent = configContent.replace(
|
|
/^rules:\s*\n/m,
|
|
'rules:\n' + xiaomiRules + '\n'
|
|
);
|
|
|
|
fs.writeFileSync(subConfigPath, configContent, 'utf-8');
|
|
|
|
// Reload mihomo with new config
|
|
await mihomoApi('PUT', '/configs', { path: subConfigPath });
|
|
|
|
// Save subscription record if URL provided
|
|
if (url) {
|
|
const existing = DB.subscriptions.find(s => s.url === url);
|
|
if (existing) {
|
|
existing.updatedAt = new Date().toISOString();
|
|
} else {
|
|
DB.subscriptions.push({
|
|
id: generateId(),
|
|
name: url.substring(0, 60),
|
|
url,
|
|
traffic: null,
|
|
expiry: null,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
}
|
|
DB.save();
|
|
}
|
|
|
|
return sendJson(res, { ok: true, message: 'Subscription pushed and applied' });
|
|
} catch (e) {
|
|
return sendError(res, 'Failed to push subscription: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// POST /api/proxy-fetch — CORS proxy for browser
|
|
if (apiPath === '/api/proxy-fetch' && method === 'POST') {
|
|
body = JSON.parse(await readBody(req));
|
|
const { url } = body;
|
|
if (!url) return sendError(res, 'Missing url', 400);
|
|
try {
|
|
const result = await fetchUrl(url, { headers: { 'User-Agent': 'ClashForAndroid/2.5.12' } });
|
|
return sendJson(res, { ok: true, content: result.body.toString('utf-8'), status: result.status });
|
|
} catch (e) {
|
|
return sendError(res, 'Fetch failed: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// ---- Connections ----
|
|
|
|
// GET /api/connections
|
|
if (apiPath === '/api/connections' && method === 'GET') {
|
|
try {
|
|
const result = await mihomoApi('GET', '/connections');
|
|
return sendJson(res, result.data);
|
|
} catch (e) {
|
|
return sendError(res, e.message);
|
|
}
|
|
}
|
|
|
|
// DELETE /api/connections/:id
|
|
if (apiPath.startsWith('/api/connections/') && method === 'DELETE') {
|
|
const connId = apiPath.split('/api/connections/')[1];
|
|
if (connId && connId !== '') {
|
|
try {
|
|
await mihomoApi('DELETE', `/connections/${encodeURIComponent(connId)}`);
|
|
return sendJson(res, { ok: true });
|
|
} catch (e) {
|
|
return sendError(res, e.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
// DELETE /api/connections — close all
|
|
if (apiPath === '/api/connections' && method === 'DELETE') {
|
|
try {
|
|
await mihomoApi('DELETE', '/connections');
|
|
return sendJson(res, { ok: true });
|
|
} catch (e) {
|
|
return sendError(res, e.message);
|
|
}
|
|
}
|
|
|
|
// ---- Rules & Logs ----
|
|
|
|
// GET /api/rules
|
|
if (apiPath === '/api/rules' && method === 'GET') {
|
|
try {
|
|
const result = await mihomoApi('GET', '/rules');
|
|
return sendJson(res, result.data);
|
|
} catch (e) {
|
|
return sendError(res, e.message);
|
|
}
|
|
}
|
|
|
|
// GET /api/logs
|
|
if (apiPath === '/api/logs' && method === 'GET') {
|
|
try {
|
|
const level = query.get('level') || 'info';
|
|
const result = await mihomoApi('GET', `/logs?level=${level}`);
|
|
return sendJson(res, result.data);
|
|
} catch (e) {
|
|
return sendError(res, e.message);
|
|
}
|
|
}
|
|
|
|
// ---- Config ----
|
|
|
|
// GET /api/config
|
|
if (apiPath === '/api/config' && method === 'GET') {
|
|
try {
|
|
const result = await mihomoApi('GET', '/configs');
|
|
return sendJson(res, result.data);
|
|
} catch (e) {
|
|
return sendError(res, e.message);
|
|
}
|
|
}
|
|
|
|
// PUT /api/config/reload
|
|
if (apiPath === '/api/config/reload' && method === 'PUT') {
|
|
try {
|
|
await mihomoApi('PUT', '/configs', { path: CONFIG.mihomoConfig });
|
|
return sendJson(res, { ok: true });
|
|
} catch (e) {
|
|
return sendError(res, e.message);
|
|
}
|
|
}
|
|
|
|
// POST /api/config/reload (also accept POST)
|
|
if (apiPath === '/api/config/reload' && method === 'POST') {
|
|
try {
|
|
await mihomoApi('PUT', '/configs', { path: CONFIG.mihomoConfig });
|
|
return sendJson(res, { ok: true });
|
|
} catch (e) {
|
|
return sendError(res, e.message);
|
|
}
|
|
}
|
|
|
|
// ---- Proxy mode ----
|
|
|
|
// POST /api/proxy-mode
|
|
if (apiPath === '/api/proxy-mode' && method === 'POST') {
|
|
body = JSON.parse(await readBody(req));
|
|
const { mode } = body;
|
|
if (!['rule', 'global', 'direct'].includes(mode)) {
|
|
return sendError(res, 'Invalid mode. Use: rule, global, direct', 400);
|
|
}
|
|
try {
|
|
await mihomoApi('PATCH', '/configs', { mode: mode.charAt(0).toUpperCase() + mode.slice(1) });
|
|
DB.settings.proxyMode = mode;
|
|
DB.save();
|
|
return sendJson(res, { ok: true, mode });
|
|
} catch (e) {
|
|
return sendError(res, e.message);
|
|
}
|
|
}
|
|
|
|
// ---- Settings ----
|
|
|
|
// GET /api/settings
|
|
if (apiPath === '/api/settings' && method === 'GET') {
|
|
return sendJson(res, DB.settings);
|
|
}
|
|
|
|
// PUT /api/settings
|
|
if (apiPath === '/api/settings' && method === 'PUT') {
|
|
body = JSON.parse(await readBody(req));
|
|
const s = DB.settings;
|
|
|
|
if (body.transparentProxy !== undefined) {
|
|
s.transparentProxy = { ...s.transparentProxy, ...body.transparentProxy };
|
|
}
|
|
if (body.selfProxy !== undefined) s.selfProxy = !!body.selfProxy;
|
|
if (body.proxyMode !== undefined) s.proxyMode = body.proxyMode;
|
|
if (body.dns !== undefined) s.dns = { ...s.dns, ...body.dns };
|
|
if (body.ports !== undefined) s.ports = { ...s.ports, ...body.ports };
|
|
|
|
DB.save();
|
|
return sendJson(res, s);
|
|
}
|
|
|
|
// ---- Transparent proxy ----
|
|
|
|
// GET /api/transparent-proxy
|
|
if (apiPath === '/api/transparent-proxy' && method === 'GET') {
|
|
const status = await TransparentProxy.getStatus();
|
|
return sendJson(res, status);
|
|
}
|
|
|
|
// POST /api/transparent-proxy/enable
|
|
if (apiPath === '/api/transparent-proxy/enable' && method === 'POST') {
|
|
body = JSON.parse(await readBody(req));
|
|
const { interface: iface } = body;
|
|
if (!iface) return sendError(res, 'Missing interface name', 400);
|
|
const result = await TransparentProxy.enableForInterface(iface);
|
|
return sendJson(res, result);
|
|
}
|
|
|
|
// POST /api/transparent-proxy/disable
|
|
if (apiPath === '/api/transparent-proxy/disable' && method === 'POST') {
|
|
body = JSON.parse(await readBody(req));
|
|
const { interface: iface } = body;
|
|
if (!iface) return sendError(res, 'Missing interface name', 400);
|
|
const result = await TransparentProxy.disableForInterface(iface);
|
|
return sendJson(res, result);
|
|
}
|
|
|
|
// ---- Network interfaces ----
|
|
|
|
// GET /api/network/interfaces
|
|
if (apiPath === '/api/network/interfaces' && method === 'GET') {
|
|
const interfaces = await Network.listInterfaces();
|
|
return sendJson(res, interfaces);
|
|
}
|
|
|
|
// ---- Traffic SSE stream ----
|
|
|
|
// GET /api/traffic
|
|
if (apiPath === '/api/traffic' && method === 'GET') {
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
'Connection': 'keep-alive',
|
|
'X-Accel-Buffering': 'no',
|
|
});
|
|
|
|
const url = new URL('/traffic', CONFIG.mihomoApi);
|
|
const mod = url.protocol === 'https:' ? https : http;
|
|
const proxyReq = mod.get({
|
|
hostname: url.hostname,
|
|
port: url.port,
|
|
path: url.pathname,
|
|
headers: { 'Authorization': `Bearer ${CONFIG.mihomoSecret}` },
|
|
}, (proxyRes) => {
|
|
proxyRes.on('data', (chunk) => {
|
|
res.write(chunk);
|
|
});
|
|
proxyRes.on('end', () => res.end());
|
|
});
|
|
proxyReq.on('error', () => res.end());
|
|
req.on('close', () => proxyReq.destroy());
|
|
return;
|
|
}
|
|
|
|
// ---- Mihomo logs SSE stream ----
|
|
|
|
// GET /api/logs/stream
|
|
if (apiPath === '/api/logs/stream' && method === 'GET') {
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
'Connection': 'keep-alive',
|
|
'X-Accel-Buffering': 'no',
|
|
});
|
|
|
|
const level = query.get('level') || 'info';
|
|
const url = new URL(`/logs?level=${level}`, CONFIG.mihomoApi);
|
|
const mod = url.protocol === 'https:' ? https : http;
|
|
const proxyReq = mod.get({
|
|
hostname: url.hostname,
|
|
port: url.port,
|
|
path: url.pathname + url.search,
|
|
headers: { 'Authorization': `Bearer ${CONFIG.mihomoSecret}` },
|
|
}, (proxyRes) => {
|
|
proxyRes.on('data', (chunk) => res.write(chunk));
|
|
proxyRes.on('end', () => res.end());
|
|
});
|
|
proxyReq.on('error', () => res.end());
|
|
req.on('close', () => proxyReq.destroy());
|
|
return;
|
|
}
|
|
|
|
// ---- Fallback ----
|
|
sendError(res, 'Not found', 404);
|
|
}
|
|
|
|
// ============================================================
|
|
// Main HTTP Server
|
|
// ============================================================
|
|
const server = http.createServer(async (req, res) => {
|
|
let url;
|
|
try {
|
|
url = new URL(req.url, `http://${req.headers.host}`);
|
|
} catch {
|
|
res.writeHead(400);
|
|
return res.end('Bad Request');
|
|
}
|
|
const pathname = url.pathname;
|
|
const query = url.searchParams;
|
|
|
|
// CORS headers
|
|
const origin = req.headers.origin || '*';
|
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
|
|
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
res.setHeader('Access-Control-Expose-Headers', 'Set-Cookie');
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
res.writeHead(204);
|
|
return res.end();
|
|
}
|
|
|
|
// API routes
|
|
if (pathname.startsWith('/api/')) {
|
|
try {
|
|
await handleApi(req, res, pathname, query);
|
|
} catch (e) {
|
|
console.error('[API Error]', pathname, e.message);
|
|
if (!res.headersSent) {
|
|
sendError(res, e.message);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Serve Yacd-meta at /yacd/ or /ui/
|
|
if (pathname.startsWith('/yacd/') || pathname.startsWith('/ui/')) {
|
|
const prefix = pathname.startsWith('/yacd/') ? '/yacd/' : '/ui/';
|
|
const yacdFile = pathname.replace(prefix, '') || 'index.html';
|
|
const filePath = path.join(CONFIG.yacdPath, yacdFile);
|
|
return serveStatic(res, filePath);
|
|
}
|
|
|
|
// Serve static files (our UI)
|
|
let filePath = path.join(CONFIG.publicPath, pathname === '/' ? 'index.html' : pathname);
|
|
if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
|
|
filePath = path.join(filePath, 'index.html');
|
|
}
|
|
serveStatic(res, filePath);
|
|
});
|
|
|
|
// ============================================================
|
|
// Startup
|
|
// ============================================================
|
|
DB.load();
|
|
ensureDefaultAdmin();
|
|
|
|
// Restore transparent proxy rules from saved settings
|
|
TransparentProxy.restoreFromSettings().catch(e => {
|
|
console.error('[Startup] Failed to restore transparent proxy:', e.message);
|
|
});
|
|
|
|
server.listen(CONFIG.port, '0.0.0.0', () => {
|
|
console.log('');
|
|
console.log(' 🍊 OrangePi Mihomo UI v2');
|
|
console.log(' ─────────────────────────────────');
|
|
console.log(` Dashboard: http://0.0.0.0:${CONFIG.port}`);
|
|
console.log(` Yacd-meta: http://0.0.0.0:${CONFIG.port}/yacd/`);
|
|
console.log(` Mihomo API: ${CONFIG.mihomoApi}`);
|
|
console.log(` Config file: ${CONFIG.mihomoConfig}`);
|
|
console.log(` Database: ${CONFIG.dbPath}`);
|
|
console.log(` Auth tokens: HMAC-SHA256, ${CONFIG.tokenExpiry}s expiry`);
|
|
console.log(' ─────────────────────────────────');
|
|
console.log(` Default login: admin / admin`);
|
|
console.log('');
|
|
});
|
|
|
|
// Graceful shutdown
|
|
process.on('SIGTERM', () => {
|
|
console.log('[Server] SIGTERM received, shutting down...');
|
|
DB.save(true);
|
|
server.close(() => process.exit(0));
|
|
});
|
|
|
|
process.on('SIGINT', () => {
|
|
console.log('[Server] SIGINT received, shutting down...');
|
|
DB.save(true);
|
|
server.close(() => process.exit(0));
|
|
});
|
|
|
|
process.on('uncaughtException', (err) => {
|
|
console.error('[FATAL] Uncaught exception:', err);
|
|
});
|
|
|
|
process.on('unhandledRejection', (reason) => {
|
|
console.error('[FATAL] Unhandled rejection:', reason);
|
|
});
|