Files

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