Initial release: Mihomo management UI with subscription support

Features:
- Subscription import/update/delete
- Proxy group management
- System dashboard
- Yacd-meta integration
- One-click deployment script
This commit is contained in:
2026-06-14 15:24:57 +08:00
commit 4d488ee6bd
33 changed files with 1613 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
*.log
.DS_Store
*.bak.*
/tmp/
+86
View File
@@ -0,0 +1,86 @@
# OrangePi Mihomo UI 🍊
轻量级 MihomoClash Meta)管理面板,专为 Orange Pi RV2 设计。
## ✨ 功能
- 📋 **订阅管理** - 导入/更新/删除订阅链接
- 🌐 **代理切换** - 可视化切换代理组和节点
- 📊 **系统概览** - 实时查看 mihomo 运行状态
- 📜 **规则查看** - 浏览路由规则
- 🔗 **连接监控** - 查看活跃连接
- 📝 **日志查看** - 运行日志
- 🎨 **Yacd 集成** - 内置 Yacd-meta 高级面板
## 🚀 一键部署
```bash
git clone http://gitea.gofor.ltd/GoforStudio/OrangePi-Mihomo-UI.git
cd OrangePi-Mihomo-UI
sudo bash deploy.sh
```
## 📦 手动安装
### 1. 安装 Mihomo
```bash
# 下载 mihomo (根据架构选择)
# riscv64:
cp bin/mihomo-riscv64 /usr/local/bin/mihomo
chmod +x /usr/local/bin/mihomo
# 配置文件
mkdir -p /etc/mihomo
cp config-example.yaml /etc/mihomo/config.yaml
```
### 2. 启动 Mihomo
```bash
mihomo -d /etc/mihomo
```
### 3. 启动 Web UI
```bash
node server.js
```
## 🔧 配置
环境变量:
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `PORT` | 8899 | Web UI 端口 |
| `MIHOMO_API` | http://127.0.0.1:9097 | Mihomo API 地址 |
| `MIHOMO_SECRET` | orangepi | Mihomo API 密钥 |
| `MIHOMO_CONFIG` | /etc/mihomo/config.yaml | 配置文件路径 |
## 📁 目录结构
```
├── server.js # Node.js 后端服务
├── public/ # Web 前端
│ └── index.html
├── yacd-meta/ # Yacd-meta 面板
├── bin/ # Mihomo 二进制
│ └── mihomo-riscv64
├── deploy.sh # 一键部署脚本
└── package.json
```
## 🌐 访问
部署完成后:
- **管理面板**: `http://<IP>:8899`
- **Yacd 面板**: `http://<IP>:8899/yacd/`
- **Mihomo API**: `http://<IP>:9097`
默认密钥: `orangepi`
## 📄 License
MIT
+309
View File
@@ -0,0 +1,309 @@
#!/bin/bash
# ============================================================
# OrangePi Mihomo UI - One-Click Deployment Script
# Compatible with: Debian/Ubuntu (arm64, riscv64, amd64)
# Usage: sudo bash deploy.sh
# ============================================================
set -e
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
ORANGE='\033[38;5;208m'
NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $1"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
echo -e "${ORANGE}"
echo " ___ ____ _ _ _ ___ ___ _ _ "
echo " / _ \ _ __ __ _ __ _ ___ | _ \| | | \ | |_ \ / _ \| \ | |"
echo "| | | | '__/ _\` |/ _\` |/ _ \ | |_) | | | \| | | | | | | \| |"
echo "| |_| | | | (_| | (_| | __/ | __/| |___ | |\ | | | |_| | |\ |"
echo " \___/|_| \__,_|\__, |\___| |_| |_____| |_| \_|___|\___/|_| \_|"
echo " |___/ "
echo -e "${NC}"
# Check root
if [ "$EUID" -ne 0 ]; then
error "Please run as root: sudo bash deploy.sh"
fi
# ========== Configuration ==========
INSTALL_DIR="/opt/orangepi-mihomo-ui"
MIHOMO_DIR="/etc/mihomo"
MIHOMO_BIN="/usr/local/bin/mihomo"
ARCH=$(uname -m)
info "Detected architecture: $ARCH"
# Map architecture
case "$ARCH" in
x86_64|amd64) MIHOMO_ARCH="linux-amd64"; NODE_ARCH="linux-x64" ;;
aarch64|arm64) MIHOMO_ARCH="linux-arm64"; NODE_ARCH="linux-arm64" ;;
riscv64) MIHOMO_ARCH="linux-riscv64"; NODE_ARCH="linux-riscv64" ;;
armv7l) MIHOMO_ARCH="linux-armv7"; NODE_ARCH="linux-armv7l" ;;
*) error "Unsupported architecture: $ARCH" ;;
esac
info "Mihomo target: $MIHOMO_ARCH"
# ========== Check Dependencies ==========
info "Checking dependencies..."
# Check Node.js
if command -v node &>/dev/null; then
NODE_VER=$(node --version)
info "Node.js found: $NODE_VER"
NODE_MAJOR=$(echo "$NODE_VER" | sed 's/v//' | cut -d. -f1)
if [ "$NODE_MAJOR" -lt 18 ]; then
warn "Node.js version too old, please install v18+"
fi
else
warn "Node.js not found, attempting to install..."
if command -v apt &>/dev/null; then
apt update && apt install -y nodejs npm
elif command -v apk &>/dev/null; then
apk add nodejs npm
else
error "Cannot install Node.js automatically. Please install Node.js 18+ manually."
fi
fi
# Check npm
if ! command -v npm &>/dev/null; then
warn "npm not found, installing..."
apt install -y npm 2>/dev/null || apk add npm 2>/dev/null || true
fi
# Check curl/unzip
for cmd in curl unzip; do
if ! command -v $cmd &>/dev/null; then
info "Installing $cmd..."
apt install -y $cmd 2>/dev/null || apk add $cmd 2>/dev/null || true
fi
done
# ========== Install Mihomo ==========
if [ ! -f "$MIHOMO_BIN" ]; then
info "Installing Mihomo..."
# Get latest version
MIHOMO_VER=$(curl -sL "https://api.github.com/repos/MetaCubeX/mihomo/releases/latest" | grep '"tag_name"' | sed 's/.*"tag_name": *"//;s/".*//')
if [ -z "$MIHOMO_VER" ]; then
MIHOMO_VER="v1.19.27"
warn "Cannot fetch latest version, using $MIHOMO_VER"
fi
DOWNLOAD_URL="https://github.com/MetaCubeX/mihomo/releases/download/${MIHOMO_VER}/mihomo-${MIHOMO_ARCH}-${MIHOMO_VER}.gz"
# Try mirror
MIRROR_URL="https://ghfast.top/${DOWNLOAD_URL}"
info "Downloading Mihomo $MIHOMO_VER for $MIHOMO_ARCH..."
curl -L --progress-bar "$MIRROR_URL" -o /tmp/mihomo.gz || \
curl -L --progress-bar "$DOWNLOAD_URL" -o /tmp/mihomo.gz || \
error "Download failed"
gunzip -f /tmp/mihomo.gz
chmod +x /tmp/mihomo
mv /tmp/mihomo "$MIHOMO_BIN"
info "Mihomo installed to $MIHOMO_BIN"
else
info "Mihomo already installed: $(mihomo -v 2>&1 | head -1)"
fi
# ========== Setup Mihomo Config ==========
if [ ! -d "$MIHOMO_DIR" ]; then
info "Creating mihomo config directory..."
mkdir -p "$MIHOMO_DIR"
fi
if [ ! -f "$MIHOMO_DIR/config.yaml" ]; then
info "Creating default mihomo config..."
cat > "$MIHOMO_DIR/config.yaml" << 'MIHOMO_CONFIG'
# OrangePi Mihomo Default Config
mixed-port: 7890
socks-port: 7891
port: 7892
redir-port: 7893
tproxy-port: 7894
allow-lan: true
bind-address: "*"
mode: rule
log-level: info
ipv6: false
external-controller: 0.0.0.0:9097
external-ui: /opt/orangepi-mihomo-ui/yacd-meta
secret: "orangepi"
dns:
enable: true
listen: 0.0.0.0:1053
ipv6: false
enhanced-mode: fake-ip
fake-ip-range: 198.18.0.1/16
fake-ip-filter:
- "*.lan"
- "*.local"
default-nameserver:
- 223.5.5.5
- 114.114.114.114
nameserver:
- https://dns.alidns.com/dns-query
- https://doh.pub/dns-query
fallback:
- https://dns.google/dns-query
- https://cloudflare-dns.com/dns-query
fallback-filter:
geoip: true
geoip-code: CN
ipcidr:
- 240.0.0.0/4
proxies: []
proxy-groups:
- name: "Proxy"
type: select
proxies:
- DIRECT
rules:
- GEOIP,CN,DIRECT
- MATCH,Proxy
MIHOMO_CONFIG
info "Default config created at $MIHOMO_DIR/config.yaml"
fi
# ========== Setup Mihomo Service ==========
if [ ! -f /etc/systemd/system/mihomo.service ]; then
info "Creating mihomo systemd service..."
cat > /etc/systemd/system/mihomo.service << 'EOF'
[Unit]
Description=mihomo Daemon
After=network.target NetworkManager.service
[Service]
Type=simple
LimitNPROC=500
LimitNOFILE=1000000
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SYS_TIME CAP_SYS_PTRACE CAP_DAC_READ_SEARCH CAP_DAC_OVERRIDE
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SYS_TIME CAP_SYS_PTRACE CAP_DAC_READ_SEARCH CAP_DAC_OVERRIDE
Restart=always
ExecStartPre=/usr/local/bin/mihomo -t -d /etc/mihomo
ExecStart=/usr/local/bin/mihomo -d /etc/mihomo
ExecReload=/bin/kill -HUP $MAINPID
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable mihomo
info "Mihomo service created and enabled"
fi
# ========== Deploy Web UI ==========
info "Deploying Web UI..."
# Copy project files
if [ -d "$INSTALL_DIR" ]; then
warn "Backing up existing installation..."
mv "$INSTALL_DIR" "${INSTALL_DIR}.bak.$(date +%s)"
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cp -r "$SCRIPT_DIR" "$INSTALL_DIR"
cd "$INSTALL_DIR"
# Install Node.js dependencies (if any)
if [ -f package.json ]; then
info "Installing Node.js dependencies..."
npm install --production 2>/dev/null || true
fi
# ========== Setup Yacd-meta ==========
YACD_DIR="$INSTALL_DIR/yacd-meta"
if [ ! -f "$YACD_DIR/index.html" ]; then
info "Downloading Yacd-meta..."
curl -L "https://ghfast.top/https://github.com/MetaCubeX/Yacd-meta/archive/refs/heads/gh-pages.zip" \
-o /tmp/yacd-meta.zip --progress-bar || \
curl -L "https://github.com/MetaCubeX/Yacd-meta/archive/refs/heads/gh-pages.zip" \
-o /tmp/yacd-meta.zip --progress-bar
cd /tmp && unzip -o yacd-meta.zip
rm -rf "$YACD_DIR"
mv /tmp/Yacd-meta-gh-pages "$YACD_DIR"
rm -f /tmp/yacd-meta.zip
cd "$INSTALL_DIR"
info "Yacd-meta deployed"
fi
# ========== Create Web UI Service ==========
info "Creating Web UI systemd service..."
cat > /etc/systemd/system/orangepi-mihomo-ui.service << EOF
[Unit]
Description=OrangePi Mihomo Web UI
After=network.target mihomo.service
[Service]
Type=simple
WorkingDirectory=$INSTALL_DIR
ExecStart=$(which node) server.js
Restart=always
RestartSec=5
Environment=PORT=8899
Environment=MIHOMO_API=http://127.0.0.1:9097
Environment=MIHOMO_SECRET=orangepi
Environment=MIHOMO_CONFIG=$MIHOMO_DIR/config.yaml
Environment=YACD_PATH=$YACD_DIR
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable orangepi-mihomo-ui
# ========== Start Services ==========
info "Starting services..."
systemctl restart mihomo
sleep 1
if systemctl is-active --quiet mihomo; then
info "✅ Mihomo started"
else
warn "Mihomo failed to start, check: journalctl -u mihomo"
fi
systemctl restart orangepi-mihomo-ui
sleep 1
if systemctl is-active --quiet orangepi-mihomo-ui; then
info "✅ Web UI started"
else
warn "Web UI failed to start, check: journalctl -u orangepi-mihomo-ui"
fi
# ========== Done ==========
IP_ADDR=$(hostname -I | awk '{print $1}')
echo ""
echo -e "${ORANGE}============================================${NC}"
echo -e "${GREEN} 🎉 Deployment Complete!${NC}"
echo -e "${ORANGE}============================================${NC}"
echo ""
echo -e " 📊 Web UI: ${GREEN}http://${IP_ADDR}:8899${NC}"
echo -e " 🎨 Yacd: ${GREEN}http://${IP_ADDR}:8899/yacd/${NC}"
echo -e " 🔧 Mihomo API: ${GREEN}http://${IP_ADDR}:9097${NC}"
echo -e " 🔑 Secret: ${YELLOW}orangepi${NC}"
echo ""
echo -e " 📝 Config: ${MIHOMO_DIR}/config.yaml"
echo -e " 📁 Install: ${INSTALL_DIR}"
echo ""
echo -e " Service commands:"
echo -e " systemctl status mihomo"
echo -e " systemctl status orangepi-mihomo-ui"
echo -e " systemctl restart orangepi-mihomo-ui"
echo ""
echo -e "${ORANGE}============================================${NC}"
+12
View File
@@ -0,0 +1,12 @@
{
"name": "orangepi-mihomo-ui",
"version": "1.0.0",
"description": "Lightweight Mihomo management UI for Orange Pi RV2",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node server.js --dev"
},
"keywords": ["mihomo", "clash", "proxy", "orangepi"],
"license": "MIT"
}
+568
View File
@@ -0,0 +1,568 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OrangePi Mihomo UI</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🍊</text></svg>">
<style>
:root {
--bg: #0f172a; --surface: #1e293b; --surface2: #334155;
--border: #475569; --text: #e2e8f0; --text2: #94a3b8;
--primary: #f97316; --primary-hover: #ea580c;
--success: #22c55e; --danger: #ef4444; --warning: #eab308;
--radius: 10px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
/* Layout */
.app { display: flex; min-height: 100vh; }
.sidebar {
width: 220px; background: var(--surface); border-right: 1px solid var(--border);
display: flex; flex-direction: column; position: fixed; height: 100vh; z-index: 10;
}
.main { flex: 1; margin-left: 220px; padding: 24px; }
/* Sidebar */
.logo { padding: 20px; text-align: center; border-bottom: 1px solid var(--border); }
.logo h1 { font-size: 18px; color: var(--primary); }
.logo small { color: var(--text2); font-size: 11px; }
.nav { flex: 1; padding: 12px 0; }
.nav-item {
display: flex; align-items: center; gap: 10px; padding: 12px 20px;
color: var(--text2); cursor: pointer; transition: all 0.2s; text-decoration: none;
}
.nav-item:hover, .nav-item.active { background: var(--surface2); color: var(--primary); }
.nav-item .icon { font-size: 18px; width: 24px; text-align: center; }
.sidebar-footer { padding: 16px; border-top: 1px solid var(--border); }
.sidebar-footer small { color: var(--text2); }
/* Cards */
.card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 20px; margin-bottom: 16px;
}
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.card-title { font-size: 16px; font-weight: 600; }
/* Stats */
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 20px; }
.stat-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 16px;
}
.stat-label { color: var(--text2); font-size: 12px; margin-bottom: 4px; }
.stat-value { font-size: 24px; font-weight: 700; color: var(--primary); }
/* Buttons */
.btn {
display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px;
border: none; border-radius: 6px; cursor: pointer; font-size: 13px;
font-weight: 500; transition: all 0.2s;
}
.btn-primary { background: var(--primary); color: white; }
.btn-primary:hover { background: var(--primary-hover); }
.btn-success { background: var(--success); color: white; }
.btn-danger { background: var(--danger); color: white; }
.btn-ghost { background: transparent; color: var(--text2); border: 1px solid var(--border); }
.btn-ghost:hover { background: var(--surface2); color: var(--text); }
.btn-sm { padding: 4px 10px; font-size: 12px; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* Input */
.input-group { margin-bottom: 12px; }
.input-group label { display: block; font-size: 13px; color: var(--text2); margin-bottom: 4px; }
input, select, textarea {
width: 100%; padding: 10px 12px; background: var(--surface2); border: 1px solid var(--border);
border-radius: 6px; color: var(--text); font-size: 14px; outline: none;
}
input:focus, select:focus { border-color: var(--primary); }
textarea { resize: vertical; min-height: 80px; font-family: monospace; }
/* Table */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border); font-size: 13px; }
th { color: var(--text2); font-weight: 500; font-size: 12px; text-transform: uppercase; }
tr:hover { background: var(--surface2); }
/* Badge */
.badge {
display: inline-block; padding: 2px 8px; border-radius: 10px;
font-size: 11px; font-weight: 600;
}
.badge-success { background: rgba(34,197,94,0.15); color: var(--success); }
.badge-danger { background: rgba(239,68,68,0.15); color: var(--danger); }
.badge-warning { background: rgba(234,179,8,0.15); color: var(--warning); }
/* Proxy group */
.proxy-group { margin-bottom: 12px; }
.proxy-group-header {
display: flex; justify-content: space-between; align-items: center;
padding: 10px; background: var(--surface2); border-radius: 6px; cursor: pointer;
}
.proxy-group-header:hover { background: rgba(249,115,22,0.1); }
.proxy-list { display: none; padding: 8px 0; }
.proxy-list.show { display: block; }
.proxy-item {
display: flex; justify-content: space-between; align-items: center;
padding: 8px 12px; border-radius: 4px; cursor: pointer;
}
.proxy-item:hover { background: var(--surface2); }
.proxy-item.selected { color: var(--primary); }
.latency { font-size: 12px; font-weight: 600; }
.latency-fast { color: var(--success); }
.latency-medium { color: var(--warning); }
.latency-slow { color: var(--danger); }
.latency-timeout { color: var(--text2); }
/* Toast */
.toast-container { position: fixed; top: 20px; right: 20px; z-index: 100; }
.toast {
padding: 12px 20px; border-radius: 8px; margin-bottom: 8px;
font-size: 13px; animation: slideIn 0.3s ease; min-width: 200px;
}
.toast-success { background: rgba(34,197,94,0.9); color: white; }
.toast-error { background: rgba(239,68,68,0.9); color: white; }
.toast-info { background: rgba(59,130,246,0.9); color: white; }
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
/* Loading */
.spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid var(--border);
border-top-color: var(--primary); border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Page */
.page { display: none; }
.page.active { display: block; }
/* Modal */
.modal-overlay {
display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6);
z-index: 50; justify-content: center; align-items: center;
}
.modal-overlay.show { display: flex; }
.modal {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 24px; width: 90%; max-width: 500px;
}
.modal-title { font-size: 18px; font-weight: 600; margin-bottom: 16px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
/* Responsive */
@media (max-width: 768px) {
.sidebar { display: none; }
.main { margin-left: 0; }
}
</style>
</head>
<body>
<div class="app">
<!-- Sidebar -->
<div class="sidebar">
<div class="logo">
<h1>🍊 OrangePi</h1>
<small>Mihomo UI</small>
</div>
<nav class="nav">
<a class="nav-item active" onclick="showPage('dashboard')" href="#dashboard">
<span class="icon">📊</span> 概览
</a>
<a class="nav-item" onclick="showPage('subscriptions')" href="#subscriptions">
<span class="icon">📋</span> 订阅管理
</a>
<a class="nav-item" onclick="showPage('proxies')" href="#proxies">
<span class="icon">🌐</span> 代理节点
</a>
<a class="nav-item" onclick="showPage('rules')" href="#rules">
<span class="icon">📜</span> 规则
</a>
<a class="nav-item" onclick="showPage('connections')" href="#connections">
<span class="icon">🔗</span> 连接
</a>
<a class="nav-item" onclick="showPage('logs')" href="#logs">
<span class="icon">📝</span> 日志
</a>
<a class="nav-item" href="/yacd/" target="_blank">
<span class="icon">🎨</span> Yacd 面板
</a>
</nav>
<div class="sidebar-footer">
<small id="version-info">v1.0.0</small>
</div>
</div>
<!-- Main Content -->
<div class="main">
<!-- Dashboard Page -->
<div id="page-dashboard" class="page active">
<h2 style="margin-bottom:20px">📊 系统概览</h2>
<div class="stats">
<div class="stat-card">
<div class="stat-label">Mihomo 版本</div>
<div class="stat-value" id="stat-version">-</div>
</div>
<div class="stat-card">
<div class="stat-label">代理节点数</div>
<div class="stat-value" id="stat-proxies">-</div>
</div>
<div class="stat-card">
<div class="stat-label">运行模式</div>
<div class="stat-value" id="stat-mode">-</div>
</div>
<div class="stat-card">
<div class="stat-label">订阅数量</div>
<div class="stat-value" id="stat-subs">-</div>
</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">快捷操作</span>
</div>
<button class="btn btn-primary" onclick="reloadConfig()">🔄 重载配置</button>
<a class="btn btn-ghost" href="/yacd/" target="_blank" style="margin-left:8px">🎨 打开 Yacd 面板</a>
</div>
</div>
<!-- Subscriptions Page -->
<div id="page-subscriptions" class="page">
<h2 style="margin-bottom:20px">📋 订阅管理</h2>
<div class="card">
<div class="card-header">
<span class="card-title">导入订阅</span>
</div>
<div class="input-group">
<label>订阅链接</label>
<input type="text" id="sub-url" placeholder="https://example.com/api/v1/client/subscribe?token=...">
</div>
<div class="input-group">
<label>备注名称(可选)</label>
<input type="text" id="sub-name" placeholder="我的订阅">
</div>
<button class="btn btn-primary" onclick="importSubscription()">📥 导入并应用</button>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">已保存的订阅</span>
<button class="btn btn-ghost btn-sm" onclick="loadSubscriptions()">🔄 刷新</button>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>链接</th><th>添加时间</th><th>更新时间</th><th>操作</th></tr></thead>
<tbody id="sub-list"></tbody>
</table>
</div>
<div id="sub-empty" style="text-align:center;color:var(--text2);padding:20px;display:none">
暂无订阅,请导入订阅链接
</div>
</div>
</div>
<!-- Proxies Page -->
<div id="page-proxies" class="page">
<h2 style="margin-bottom:20px">🌐 代理节点</h2>
<div class="card">
<div class="card-header">
<span class="card-title">代理组</span>
<button class="btn btn-ghost btn-sm" onclick="loadProxies()">🔄 刷新</button>
</div>
<div id="proxy-groups"></div>
</div>
</div>
<!-- Rules Page -->
<div id="page-rules" class="page">
<h2 style="margin-bottom:20px">📜 规则</h2>
<div class="card">
<div class="table-wrap">
<table>
<thead><tr><th>类型</th><th>匹配内容</th><th>目标策略</th></tr></thead>
<tbody id="rule-list"></tbody>
</table>
</div>
</div>
</div>
<!-- Connections Page -->
<div id="page-connections" class="page">
<h2 style="margin-bottom:20px">🔗 活跃连接</h2>
<div class="card">
<div class="card-header">
<span class="card-title">连接列表</span>
<button class="btn btn-ghost btn-sm" onclick="loadConnections()">🔄 刷新</button>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>来源</th><th>目标</th><th>规则</th><th>代理</th><th>上传</th><th>下载</th></tr></thead>
<tbody id="conn-list"></tbody>
</table>
</div>
</div>
</div>
<!-- Logs Page -->
<div id="page-logs" class="page">
<h2 style="margin-bottom:20px">📝 日志</h2>
<div class="card">
<div class="card-header">
<span class="card-title">运行日志</span>
<button class="btn btn-ghost btn-sm" onclick="loadLogs()">🔄 刷新</button>
</div>
<div id="log-list" style="font-family:monospace;font-size:12px;max-height:500px;overflow-y:auto;background:var(--bg);padding:12px;border-radius:6px;"></div>
</div>
</div>
</div>
</div>
<!-- Toast Container -->
<div class="toast-container" id="toasts"></div>
<script>
// ========== Utilities ==========
function toast(msg, type = 'info') {
const el = document.createElement('div');
el.className = `toast toast-${type}`;
el.textContent = msg;
document.getElementById('toasts').appendChild(el);
setTimeout(() => el.remove(), 3000);
}
async function api(method, path, body) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body) opts.body = JSON.stringify(body);
const res = await fetch(path, opts);
return res.json();
}
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024, sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
}
function formatTime(iso) {
if (!iso) return '-';
return new Date(iso).toLocaleString('zh-CN');
}
function latencyClass(ms) {
if (!ms || ms < 0) return 'latency-timeout';
if (ms < 200) return 'latency-fast';
if (ms < 500) return 'latency-medium';
return 'latency-slow';
}
// ========== Navigation ==========
function showPage(name) {
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.getElementById('page-' + name).classList.add('active');
event && event.target && event.target.closest('.nav-item')?.classList.add('active');
// Load data for page
switch(name) {
case 'dashboard': loadDashboard(); break;
case 'subscriptions': loadSubscriptions(); break;
case 'proxies': loadProxies(); break;
case 'rules': loadRules(); break;
case 'connections': loadConnections(); break;
case 'logs': loadLogs(); break;
}
}
// ========== Dashboard ==========
async function loadDashboard() {
try {
const [status, subs] = await Promise.all([
api('GET', '/api/status'),
api('GET', '/api/subscriptions'),
]);
if (status.error) throw new Error(status.error);
document.getElementById('stat-version').textContent = status.version?.version || '-';
document.getElementById('stat-proxies').textContent = status.proxyCount || 0;
document.getElementById('stat-mode').textContent = status.config?.mode || '-';
document.getElementById('stat-subs').textContent = subs.length || 0;
document.getElementById('version-info').textContent = 'v' + (status.version?.version || '?');
} catch(e) {
toast('无法连接 mihomo: ' + e.message, 'error');
}
}
async function reloadConfig() {
try {
const res = await api('POST', '/api/config/reload');
if (res.error) throw new Error(res.error);
toast('配置已重载', 'success');
} catch(e) { toast('重载失败: ' + e.message, 'error'); }
}
// ========== Subscriptions ==========
async function importSubscription() {
const url = document.getElementById('sub-url').value.trim();
if (!url) return toast('请输入订阅链接', 'error');
try {
toast('正在获取订阅...', 'info');
// First fetch and validate
const res = await api('POST', '/api/subscription/import', { url });
if (res.error) throw new Error(res.error);
toast('订阅获取成功,正在应用...', 'info');
// Then apply to mihomo
const applyRes = await api('POST', '/api/subscription/apply', { url });
if (applyRes.error) throw new Error(applyRes.error);
toast('🎉 订阅导入并应用成功!', 'success');
document.getElementById('sub-url').value = '';
document.getElementById('sub-name').value = '';
loadSubscriptions();
} catch(e) {
toast('导入失败: ' + e.message, 'error');
}
}
async function loadSubscriptions() {
try {
const subs = await api('GET', '/api/subscriptions');
const tbody = document.getElementById('sub-list');
const empty = document.getElementById('sub-empty');
if (subs.length === 0) {
tbody.innerHTML = '';
empty.style.display = 'block';
return;
}
empty.style.display = 'none';
tbody.innerHTML = subs.map(s => `
<tr>
<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${s.url}">${s.url}</td>
<td>${formatTime(s.addedAt)}</td>
<td>${formatTime(s.updatedAt)}</td>
<td>
<button class="btn btn-primary btn-sm" onclick="refreshSub('${s.url}')">🔄 更新</button>
<button class="btn btn-danger btn-sm" onclick="deleteSub('${s.url}')">🗑️ 删除</button>
</td>
</tr>
`).join('');
} catch(e) { toast('加载失败: ' + e.message, 'error'); }
}
async function refreshSub(url) {
try {
toast('正在更新订阅...', 'info');
const res = await api('POST', '/api/subscription/apply', { url });
if (res.error) throw new Error(res.error);
toast('订阅更新成功!', 'success');
loadSubscriptions();
} catch(e) { toast('更新失败: ' + e.message, 'error'); }
}
async function deleteSub(url) {
if (!confirm('确定删除此订阅?')) return;
try {
await api('DELETE', '/api/subscription', { url });
toast('已删除', 'success');
loadSubscriptions();
} catch(e) { toast('删除失败', 'error'); }
}
// ========== Proxies ==========
async function loadProxies() {
try {
const groups = await api('GET', '/api/proxy-groups');
const container = document.getElementById('proxy-groups');
container.innerHTML = '';
for (const [name, group] of Object.entries(groups)) {
const div = document.createElement('div');
div.className = 'proxy-group';
div.innerHTML = `
<div class="proxy-group-header" onclick="this.nextElementSibling.classList.toggle('show')">
<span><strong>${name}</strong> (${group.type}) → <em style="color:var(--primary)">${group.now || 'DIRECT'}</em></span>
<span>▾</span>
</div>
<div class="proxy-list">
${(group.all || []).map(p => `
<div class="proxy-item ${p === group.now ? 'selected' : ''}"
onclick="switchProxy('${name}', '${p}')">
<span>${p === group.now ? '⊙ ' : ''}${p}</span>
<span class="latency" id="latency-${name}-${p}"></span>
</div>
`).join('')}
</div>
`;
container.appendChild(div);
}
} catch(e) { toast('加载代理失败: ' + e.message, 'error'); }
}
async function switchProxy(group, proxy) {
try {
await api('PUT', `/api/proxy-group/${encodeURIComponent(group)}`, { name: proxy });
toast(`${group}${proxy}`, 'success');
loadProxies();
} catch(e) { toast('切换失败: ' + e.message, 'error'); }
}
// ========== Rules ==========
async function loadRules() {
try {
const data = await api('GET', '/api/rules');
const tbody = document.getElementById('rule-list');
tbody.innerHTML = (data.rules || []).map(r => `
<tr>
<td><span class="badge badge-warning">${r.type}</span></td>
<td>${r.payload || '-'}</td>
<td>${r.proxy || '-'}</td>
</tr>
`).join('');
} catch(e) { toast('加载规则失败', 'error'); }
}
// ========== Connections ==========
async function loadConnections() {
try {
const data = await api('GET', '/api/connections');
const tbody = document.getElementById('conn-list');
tbody.innerHTML = (data.connections || []).map(c => `
<tr>
<td>${c.metadata?.sourceIP || '-'}:${c.metadata?.sourcePort || '-'}</td>
<td>${c.metadata?.destinationIP || c.metadata?.host || '-'}:${c.metadata?.destinationPort || '-'}</td>
<td>${c.rule || '-'}</td>
<td>${c.chains?.[0] || '-'}</td>
<td>${formatBytes(c.upload || 0)}</td>
<td>${formatBytes(c.download || 0)}</td>
</tr>
`).join('');
} catch(e) { toast('加载连接失败', 'error'); }
}
// ========== Logs ==========
async function loadLogs() {
try {
const res = await fetch('/api/logs');
const text = await res.text();
const container = document.getElementById('log-list');
try {
const data = JSON.parse(text);
container.innerHTML = (data.logs || []).map(l =>
`<div style="color:${l.type==='error'?'var(--danger)':l.type==='warning'?'var(--warning)':'var(--text2)'}">[${l.time}] [${l.type}] ${l.payload}</div>`
).join('');
} catch {
container.textContent = text;
}
} catch(e) { toast('加载日志失败', 'error'); }
}
// ========== Init ==========
window.addEventListener('load', () => {
loadDashboard();
// Handle hash navigation
const hash = location.hash.replace('#', '');
if (hash) showPage(hash);
});
</script>
</body>
</html>
+498
View File
@@ -0,0 +1,498 @@
#!/usr/bin/env node
/**
* OrangePi Mihomo UI - Lightweight Mihomo Management Server
* Supports: subscription import, proxy management, config reload
*/
const http = require('http');
const https = require('https');
const fs = require('fs');
const path = require('path');
const { URL } = require('url');
const { execSync, exec } = require('child_process');
// ========== Configuration ==========
const CONFIG = {
port: parseInt(process.env.PORT || '8899'),
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',
yacdPath: process.env.YACD_PATH || path.join(__dirname, 'yacd-meta'),
};
// ========== 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);
}
// ========== 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',
'.ico': 'image/x-icon',
'.svg': 'image/svg+xml',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
};
function serveStatic(res, filePath) {
const ext = path.extname(filePath).toLowerCase();
const mime = MIME_TYPES[ext] || 'application/octet-stream';
try {
const data = fs.readFileSync(filePath);
res.writeHead(200, { 'Content-Type': mime });
res.end(data);
} catch {
res.writeHead(404);
res.end('Not Found');
}
}
// ========== Subscription Management ==========
function parseSubscription(content) {
// Parse YAML-like subscription content (base64 decoded)
// Supports: clash, v2ray, ss, ssr formats
const lines = content.trim().split('\n');
const proxies = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('-') || trimmed.startsWith('port:') ||
trimmed.startsWith('socks-port:') || trimmed.startsWith('mixed-port:') || trimmed.startsWith('allow-lan:') ||
trimmed.startsWith('mode:') || trimmed.startsWith('log-level:') || trimmed.startsWith('external-controller:') ||
trimmed.startsWith('dns:') || trimmed.startsWith('proxies:') || trimmed.startsWith('proxy-groups:') ||
trimmed.startsWith('rules:') || trimmed.startsWith(' ')) {
continue;
}
}
return proxies;
}
function mergeProxies(existingConfig, newProxies) {
// Merge new proxies into existing config
const lines = existingConfig.split('\n');
let insertIdx = -1;
let inProxies = false;
let proxyEndIdx = -1;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.match(/^proxies:\s*$/)) {
inProxies = true;
insertIdx = i + 1;
continue;
}
if (inProxies && !line.startsWith(' ') && !line.startsWith('- ') && line.trim() !== '' && !line.match(/^\s*$/)) {
proxyEndIdx = i;
inProxies = false;
}
}
if (insertIdx === -1) {
// Add proxies section before rules
const rulesIdx = lines.findIndex(l => l.match(/^rules:/));
if (rulesIdx !== -1) {
lines.splice(rulesIdx, 0, '\nproxies:', ...newProxies.map(p => ` ${p}`));
} else {
lines.push('\nproxies:', ...newProxies.map(p => ` ${p}`));
}
} else {
// Replace existing proxies
if (proxyEndIdx === -1) proxyEndIdx = insertIdx;
lines.splice(insertIdx, proxyEndIdx - insertIdx, ...newProxies.map(p => ` ${p}`));
}
return lines.join('\n');
}
// ========== API Routes ==========
async function handleApi(req, res, apiPath) {
const method = req.method;
// GET /api/status - System status
if (apiPath === '/api/status' && method === 'GET') {
try {
const [version, configs, proxies] = await Promise.all([
mihomoApi('GET', '/version'),
mihomoApi('GET', '/configs'),
mihomoApi('GET', '/proxies'),
]);
return sendJson(res, {
version: version.data,
config: configs.data,
proxyCount: proxies.data ? Object.keys(proxies.data.proxies || {}).length : 0,
});
} catch (e) {
return sendError(res, 'Cannot connect to mihomo: ' + e.message);
}
}
// GET /api/proxies - List all 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 - List proxy groups
if (apiPath === '/api/proxy-groups' && method === 'GET') {
try {
const result = await mihomoApi('GET', '/proxies');
const proxies = 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 - Switch proxy group selection
if (apiPath.startsWith('/api/proxy-group/') && method === 'PUT') {
const groupName = decodeURIComponent(apiPath.replace('/api/proxy-group/', ''));
const 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 - Test proxy latency
if (apiPath.startsWith('/api/delay/') && method === 'GET') {
const proxyName = decodeURIComponent(apiPath.replace('/api/delay/', ''));
try {
const result = await mihomoApi('GET', `/proxies/${encodeURIComponent(proxyName)}/delay?timeout=5000&url=http://www.gstatic.com/generate_204`);
return sendJson(res, result.data);
} catch (e) {
return sendError(res, e.message);
}
}
// POST /api/subscription/import - Import subscription
if (apiPath === '/api/subscription/import' && method === 'POST') {
const body = JSON.parse(await readBody(req));
const { url } = body;
if (!url) return sendError(res, 'Missing url', 400);
try {
// Fetch subscription
const subRes = await fetchUrl(url);
if (subRes.status !== 200) return sendError(res, `HTTP ${subRes.status}`);
// Try base64 decode
let content = subRes.body.toString('utf-8');
try {
const decoded = Buffer.from(content.trim(), 'base64').toString('utf-8');
if (decoded.includes('://') || decoded.includes('proxies')) content = decoded;
} catch {}
// Save to temp file
const tempFile = '/tmp/mihomo-sub.yaml';
fs.writeFileSync(tempFile, content);
// Save subscription URL for future updates
const subFile = '/etc/mihomo/subscriptions.json';
let subscriptions = [];
try { subscriptions = JSON.parse(fs.readFileSync(subFile, 'utf-8')); } catch {}
const existing = subscriptions.find(s => s.url === url);
if (existing) {
existing.updatedAt = new Date().toISOString();
} else {
subscriptions.push({ url, addedAt: new Date().toISOString(), updatedAt: new Date().toISOString() });
}
fs.writeFileSync(subFile, JSON.stringify(subscriptions, null, 2));
return sendJson(res, {
ok: true,
message: 'Subscription fetched successfully',
preview: content.substring(0, 500),
size: content.length,
});
} catch (e) {
return sendError(res, 'Failed to fetch subscription: ' + e.message);
}
}
// POST /api/subscription/apply - Apply subscription to mihomo config
if (apiPath === '/api/subscription/apply' && method === 'POST') {
const body = JSON.parse(await readBody(req));
const { url, proxyGroup } = body;
if (!url) return sendError(res, 'Missing url', 400);
try {
const subRes = await fetchUrl(url);
if (subRes.status !== 200) return sendError(res, `HTTP ${subRes.status}`);
let content = subRes.body.toString('utf-8');
try {
const decoded = Buffer.from(content.trim(), 'base64').toString('utf-8');
if (decoded.includes('://') || decoded.includes('proxies')) content = decoded;
} catch {}
// Write to mihomo config directory
const subConfigPath = path.join(path.dirname(CONFIG.mihomoConfig), 'sub-config.yaml');
fs.writeFileSync(subConfigPath, content);
// Update main config to include subscription
let mainConfig = fs.readFileSync(CONFIG.mihomoConfig, 'utf-8');
// Add include for subscription config if not exists
if (!mainConfig.includes('sub-config.yaml')) {
const proxyGroupsIdx = mainConfig.indexOf('proxy-groups:');
if (proxyGroupsIdx !== -1) {
// Add proxy include before proxy-groups
mainConfig = mainConfig.slice(0, proxyGroupsIdx) +
'# Subscription proxies loaded from sub-config.yaml\n' +
mainConfig.slice(proxyGroupsIdx);
}
}
// Reload mihomo config
await mihomoApi('PUT', '/configs', { path: CONFIG.mihomoConfig });
// Save subscription URL
const subFile = '/etc/mihomo/subscriptions.json';
let subscriptions = [];
try { subscriptions = JSON.parse(fs.readFileSync(subFile, 'utf-8')); } catch {}
const existing = subscriptions.find(s => s.url === url);
if (existing) {
existing.updatedAt = new Date().toISOString();
} else {
subscriptions.push({ url, addedAt: new Date().toISOString(), updatedAt: new Date().toISOString() });
}
fs.writeFileSync(subFile, JSON.stringify(subscriptions, null, 2));
return sendJson(res, { ok: true, message: 'Subscription applied and config reloaded' });
} catch (e) {
return sendError(res, 'Failed to apply subscription: ' + e.message);
}
}
// GET /api/subscriptions - List saved subscriptions
if (apiPath === '/api/subscriptions' && method === 'GET') {
const subFile = '/etc/mihomo/subscriptions.json';
try {
const subs = JSON.parse(fs.readFileSync(subFile, 'utf-8'));
return sendJson(res, subs);
} catch {
return sendJson(res, []);
}
}
// DELETE /api/subscription - Remove subscription
if (apiPath === '/api/subscription' && method === 'DELETE') {
const body = JSON.parse(await readBody(req));
const subFile = '/etc/mihomo/subscriptions.json';
try {
let subs = JSON.parse(fs.readFileSync(subFile, 'utf-8'));
subs = subs.filter(s => s.url !== body.url);
fs.writeFileSync(subFile, JSON.stringify(subs, null, 2));
return sendJson(res, { ok: true });
} catch {
return sendError(res, 'Failed');
}
}
// POST /api/config/reload - Reload mihomo config
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);
}
}
// GET /api/config - Get current 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);
}
}
// GET /api/rules - Get 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/connections - Get 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);
}
}
// GET /api/logs - Get logs
if (apiPath === '/api/logs' && method === 'GET') {
try {
const result = await mihomoApi('GET', '/logs');
return sendJson(res, result.data);
} catch (e) {
return sendError(res, e.message);
}
}
// GET /api/traffic - Get traffic stream (SSE)
if (apiPath === '/api/traffic' && method === 'GET') {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
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;
}
sendError(res, 'Not found', 404);
}
// ========== Main Server ==========
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const pathname = url.pathname;
// CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') {
res.writeHead(204);
return res.end();
}
// API routes
if (pathname.startsWith('/api/')) {
try {
await handleApi(req, res, pathname);
} catch (e) {
sendError(res, e.message);
}
return;
}
// Serve Yacd-meta
if (pathname.startsWith('/yacd/')) {
const yacdFile = pathname.replace('/yacd/', '') || 'index.html';
const filePath = path.join(CONFIG.yacdPath, yacdFile);
return serveStatic(res, filePath);
}
// Serve static files (our UI)
let filePath = path.join(__dirname, 'public', pathname === '/' ? 'index.html' : pathname);
if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
filePath = path.join(filePath, 'index.html');
}
serveStatic(res, filePath);
});
server.listen(CONFIG.port, '0.0.0.0', () => {
console.log(`\n🍊 OrangePi Mihomo UI`);
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: ${CONFIG.mihomoConfig}\n`);
});
+1
View File
@@ -0,0 +1 @@
yacd.metacubex.one
Binary file not shown.
+12
View File
@@ -0,0 +1,12 @@
# for netlify hosting
# https://docs.netlify.com/routing/headers/#syntax-for-the-headers-file
/*
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
/*.css
Cache-Control: public, max-age=31536000, immutable
/*.js
Cache-Control: public, max-age=31536000, immutable
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
const e={All:"All",Overview:"Overview",Proxies:"Proxies",Rules:"Rules",Conns:"Conns",Config:"Config",Logs:"Logs",Upload:"Upload",Download:"Download","Upload Total":"Upload Total","Download Total":"Download Total","Active Connections":"Active Connections","Memory Usage":"Memory Usage","Pause Refresh":"Pause Refresh","Resume Refresh":"Resume Refresh",close_all_connections:"Close All Connections",close_filter_connections:"Close all connections after filtering",Search:"Search",Sort:"Sort",Up:"Up",Down:"Down","Test Latency":"Test Latency",settings:"settings",general:"General",management:"Management",dashboard:"Dashboard",sort_in_grp:"Sorting in group",hide_unavail_proxies:"Hide unavailable proxies",auto_close_conns:"Automatically close old connections",double_column_layout:"Double column layout",group_by_provider:"Group proxies by provider",order_natural:"Original order in config file",order_latency_asc:"By latency from small to big",order_latency_desc:"By latency from big to small",order_name_asc:"By name alphabetically (A-Z)",order_name_desc:"By name alphabetically (Z-A)",Connections:"Connections",current_backend:"Current Backend",Active:"Active",switch_backend:"Switch backend",Closed:"Closed",switch_theme:"Switch theme",theme:"theme",about:"about",no_logs:"No logs yet, hang tight...",chart_style:"Chart Style",latency_test_url:"Latency Test URL",latency_test_timeout:"Latency Test Timeout",lang:"Language",proxy_provider:"Proxy Provider",rule_provider:"Rule Provider",update_all_rule_provider:"Update all rule providers",update_all_proxy_provider:"Update all proxy providers",reload_config_file:"Reload config file",restart_core:"Restart core",upgrade_core:"Upgrade core",upgrade_geo:"Upgrade GEO Databases",upgrade_ui:"Upgrade Dashboard UI",update_geo_databases_file:"Update GEO Databases ",flush_fake_ip_pool:"Flush fake-ip data",enable_tun_device:"Enable TUN Device",allow_lan:"Allow LAN",tls_sniffing:"Sniffer",c_host:"Host",c_sni:"Sniff Host",c_process:"Process",c_dl:"DL",c_ul:"UL",c_dl_speed:"DL Speed",c_ul_speed:"UL Speed",c_chains:"Chains",c_rule:"Rule",c_time:"Time",c_source:"Source",c_destination_ip:"Destination IP",c_type:"Type",c_ctrl:"Close",close_all_confirm:"Are you sure you want to close all connections?",close_all_confirm_yes:"I'm sure",close_all_confirm_no:"No",manage_column:"Custom columns",reset_column:"Reset columns",device_name:"Device Tag",delete:"Delete",add_tag:"Add tag",client_tag:"Client tags",sourceip_tip:"Prefix with / for regular expressions, otherwise it's a complete match",disconnect:"Close Connection",internel:"Internal Connection",Clear:"Clear"};export{e as data};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
const e={All:"Все",Overview:"Обзор",Proxies:"Прокси",Rules:"Правила",Conns:"Подключения",Config:"Настройки",Logs:"Логи",Upload:"Отдача",Download:"Загрузка","Upload Total":"Всего отдано","Download Total":"Всего загружено","Active Connections":"Активные подключения","Memory Usage":"Использование памяти","Pause Refresh":"Приостановить обновление","Resume Refresh":"Возобновить обновление",close_all_connections:"Закрыть все подключения",close_filter_connections:"Закрыть все отфильтрованные подключения",Search:"Поиск",Sort:"Сортировка",Up:"Вверх",Down:"Вниз","Test Latency":"Проверить задержку",settings:"настройки",general:"Основные",management:"Управление",dashboard:"Панель управления",sort_in_grp:"Сортировка в группе",hide_unavail_proxies:"Скрыть недоступные прокси",auto_close_conns:"Автоматически закрывать старые подключения",double_column_layout:"Двухколоночный макет",group_by_provider:"Группировать прокси по провайдеру",order_natural:"Исходный порядок из конфигурации",order_latency_asc:"По задержке (от меньшей к большей)",order_latency_desc:"По задержке (от большей к меньшей)",order_name_asc:"По имени (А-Я)",order_name_desc:"По имени (Я-А)",Connections:"Подключения",current_backend:"Текущий бэкенд",Active:"Активные",switch_backend:"Сменить бэкенд",Closed:"Закрытые",switch_theme:"Сменить тему",theme:"тема",about:"о программе",no_logs:"Пока нет логов, подождите...",chart_style:"Стиль графика",latency_test_url:"URL для проверки задержки",latency_test_timeout:"Таймаут проверки задержки",lang:"Язык",proxy_provider:"Провайдер прокси",rule_provider:"Провайдер правил",update_all_rule_provider:"Обновить все провайдеры правил",update_all_proxy_provider:"Обновить все провайдеры прокси",reload_config_file:"Перезагрузить конфигурацию",restart_core:"Перезапустить ядро",upgrade_core:"Обновить ядро",upgrade_geo:"Обновить GEO базы данных",upgrade_ui:"Обновить интерфейс",update_geo_databases_file:"Обновить файлы GEO баз данных",flush_fake_ip_pool:"Очистить пул fake-ip",enable_tun_device:"Включить TUN устройство",allow_lan:"Разрешить LAN",tls_sniffing:"Сниффер",c_host:"Хост",c_sni:"Sniff хост",c_process:"Процесс",c_dl:"Загр.",c_ul:"Отд.",c_dl_speed:"Скорость загр.",c_ul_speed:"Скорость отд.",c_chains:"Цепочки",c_rule:"Правило",c_time:"Время",c_source:"Источник",c_destination_ip:"IP назначения",c_type:"Тип",c_ctrl:"Закрыть",close_all_confirm:"Вы уверены, что хотите закрыть все подключения?",close_all_confirm_yes:"Да, уверен",close_all_confirm_no:"Нет",manage_column:"Настройка столбцов",reset_column:"Сбросить столбцы",device_name:"Метка устройства",delete:"Удалить",add_tag:"Добавить метку",client_tag:"Метки клиентов",sourceip_tip:"Префикс / для регулярных выражений, иначе — точное совпадение",disconnect:"Закрыть подключение",internel:"Внутреннее подключение",Clear:"Очистить"};export{e as data};
+1
View File
@@ -0,0 +1 @@
const n={All:"Tất cả",Overview:"Tổng quan",Proxies:"Proxy",Rules:"Quy tắc",Conns:"Kết nối",Config:"Cấu hình",Logs:"Nhật ký",Upload:"Tải lên",Download:"Tải xuống","Upload Total":"Tổng tải lên","Download Total":"Tổng tải xuống","Active Connections":"Kết nối hoạt động","Memory Usage":"Sử dụng bộ nhớ","Pause Refresh":"Tạm dừng làm mới","Resume Refresh":"Tiếp tục làm mới",close_all_connections:"Đóng tất cả kết nối",close_filter_connections:"Đóng tất cả kết nối sau khi lọc",Search:"Tìm kiếm",Up:"Lên",Down:"Xuống","Test Latency":"Kiểm tra độ trễ",settings:"Cài đặt",sort_in_grp:"Sắp xếp trong nhóm",hide_unavail_proxies:"Ẩn proxy không khả dụng",auto_close_conns:"Tự động đóng kết nối cũ",group_by_provider:"Nhóm proxy theo nhà cung cấp",order_natural:"Thứ tự ban đầu trong tệp cấu hình",order_latency_asc:"Theo độ trễ từ nhỏ đến lớn",order_latency_desc:"Theo độ trễ từ lớn đến nhỏ",order_name_asc:"Theo tên theo thứ tự bảng chữ cái (A-Z)",order_name_desc:"Theo tên theo thứ tự bảng chữ cái (Z-A)",Connections:"Kết nối",current_backend:"Backend hiện tại",Active:"Hoạt động",switch_backend:"Chuyển đổi backend",Closed:"Đã đóng",switch_theme:"Chuyển đổi giao diện",theme:"Giao diện",about:"Về chúng tôi",no_logs:"Chưa có nhật ký, hãy kiên nhẫn...",chart_style:"Kiểu biểu đồ",latency_test_url:"URL kiểm tra độ trễ",latency_test_timeout:"Thời gian chờ kiểm tra độ trễ",lang:"Ngôn ngữ",proxy_provider:"nhà cung cấp proxy",update_all_rule_provider:"Cập nhật tất cả nhà cung cấp quy tắc",update_all_proxy_provider:"Cập nhật tất cả nhà cung cấp proxy",reload_config_file:"Tải lại tệp cấu hình",restart_core:"Khởi động lõi lại Clash",upgrade_core:"Nâng cấp lõi Clash",update_geo_databases_file:"Cập nhật tệp cơ sở dữ liệu GEO",flush_fake_ip_pool:"Xóa bộ nhớ đệm fake-ip",enable_tun_device:"Bật thiết bị TUN",allow_lan:"Cho phép LAN",tls_sniffing:"Bộ giám sát gói tin Sniffer",c_host:"Máy chủ",c_sni:"Phát hiện máy chủ Sniff ",c_process:"Quá trình",c_dl:"Tải Xuống",c_ul:"Tải Lên",c_dl_speed:"Tốc độ Tải Xuống",c_ul_speed:"Tốc độ Tải lên",c_chains:"Chuỗi",c_rule:"Quy tắc",c_time:"Thời gian",c_source:"Nguồn",c_destination_ip:"Địa chỉ IP đích",c_type:"Loại",c_ctrl:"Đóng",close_all_confirm:"Bạn có chắc chắn muốn đóng tất cả kết nối không?",close_all_confirm_yes:"Chắc chắn",close_all_confirm_no:"Không",manage_column:"Quản lý cột",reset_column:"Đặt lại cột",device_name:"Thẻ thiết bị",delete:"Xóa",add_tag:"Thêm thẻ",client_tag:"Thẻ khách hàng",sourceip_tip:"Thêm / vào đầu để sử dụng biểu thức chính quy, nếu không sẽ là kết quả khớp chính xác(By Ohoang7)",disconnect:"Đóng kết nối",internel:"Kết nối nội bộ",Clear:"Dọn dẹp"};export{n as data};
+1
View File
@@ -0,0 +1 @@
const e={All:"全部",Overview:"概览",Proxies:"代理",Rules:"规则",Conns:"连接",Config:"配置",Logs:"日志",Upload:"上传",Download:"下载","Upload Total":"上传总量","Download Total":"下载总量","Active Connections":"活动连接","Memory Usage":"内存使用情况",Memory:"内存","Pause Refresh":"暂停刷新","Resume Refresh":"继续刷新",close_all_connections:"关闭所有连接",close_filter_connections:"关闭所有过滤后的连接",Search:"查找",Sort:"排序",Up:"上传",Down:"下载","Test Latency":"延迟测速",settings:"设置",general:"常规",management:"管理",dashboard:"面板",sort_in_grp:"代理组条目排序",hide_unavail_proxies:"隐藏不可用代理",auto_close_conns:"切换代理时自动断开旧连接",double_column_layout:"双列显示",group_by_provider:"按提供商分组节点",order_natural:"原 config 文件中的排序",order_latency_asc:"按延迟从小到大",order_latency_desc:"按延迟从大到小",order_name_asc:"按名称字母排序 (A-Z)",order_name_desc:"按名称字母排序 (Z-A)",Connections:"连接",current_backend:"当前后端",Active:"活动",switch_backend:"切换后端",Closed:"已断开",switch_theme:"切换主题",theme:"主题",about:"关于",no_logs:"暂无日志...",chart_style:"流量图样式",latency_test_url:"延迟测速 URL",latency_test_timeout:"测速超时",lang:"语言",proxy_provider:"代理提供商",rule_provider:"规则提供商",update_all_rule_provider:"更新所有规则提供商",update_all_proxy_provider:"更新所有代理提供商",reload_config_file:"重载配置文件",update_geo_databases_file:"更新 GEO 数据库文件",flush_fake_ip_pool:"清空 FakeIP 数据库",enable_tun_device:"开启 TUN 转发",allow_lan:"允许局域网连接",tls_sniffing:"SNI 嗅探",c_host:"域名",c_sni:"嗅探域名",c_process:"进程",c_dl:"下载",c_ul:"上传",c_dl_speed:"下载速率",c_ul_speed:"上传速率",c_chains:"节点链",c_rule:"规则",c_time:"连接时间",c_source:"来源",c_destination_ip:"目标IP",c_type:"类型",c_ctrl:"关闭",restart_core:"重启核心",upgrade_core:"更新核心",upgrade_geo:"更新 GEO 数据库",upgrade_ui:"更新面板 UI",close_all_confirm:"确定关闭所有连接?",close_all_confirm_yes:"确定",close_all_confirm_no:"取消",manage_column:"管理列",reset_column:"重置列",device_name:"设备名",delete:"删除",add_tag:"添加标签",client_tag:"客户端标签",sourceip_tip:"/开头为正则,否则为全匹配",disconnect:"断开连接",internel:"内部链接",Clear:"清空"};export{e as data};
+1
View File
@@ -0,0 +1 @@
const e={All:"全部",Overview:"概覽",Proxies:"代理",Rules:"規則",Conns:"連線",Config:"設定",Logs:"紀錄",Upload:"上傳",Download:"下載","Upload Total":"總上傳","Download Total":"總下載","Active Connections":"活動中連線","Memory Usage":"記憶體使用狀況",Memory:"記憶體","Pause Refresh":"暫停重整","Resume Refresh":"繼續重整",close_all_connections:"斷開所有連線",close_filter_connections:"斷開所有過濾後的連線",Search:"搜尋",Up:"上傳",Down:"下載","Test Latency":"測試延遲速度",settings:"設定",sort_in_grp:"依代理群組排序",hide_unavail_proxies:"隱藏不可用的代理伺服器",auto_close_conns:"切換代理伺服器時自動斷開舊連線",double_column_layout:"雙列顯示",group_by_provider:"依提供商分組節點",order_natural:"原 config 文件中的順序",order_latency_asc:"按延遲從小到大",order_latency_desc:"按延遲從大到小",order_name_asc:"按名稱字母順序排序 (A-Z)",order_name_desc:"按名稱字母順序排序 (Z-A)",Connections:"連線",current_backend:"當前後端",Active:"活動中",switch_backend:"切換後端",Closed:"已斷線",switch_theme:"切換主題",theme:"主題",about:"關於",no_logs:"暫時沒有紀錄…",chart_style:"流量圖樣式",latency_test_url:"延遲測速 URL",latency_test_timeout:"測速超時",lang:"語言",proxy_provider:"代理伺服器提供者",update_all_rule_provider:"更新所有規則提供者",update_all_proxy_provider:"更新所有代理伺服器提供者",reload_config_file:"重新載入設定檔",update_geo_databases_file:"更新 GEO 資料庫文件",flush_fake_ip_pool:"清除 Fake IP 資料庫",enable_tun_device:"開啟 TUN 轉發",allow_lan:"允許區域網路連接",tls_sniffing:"SNI 嗅探",c_host:"網域名稱",c_sni:"嗅探網域名稱",c_process:"處理程序",c_dl:"下載",c_ul:"上傳",c_dl_speed:"下載速度",c_ul_speed:"上傳速度",c_chains:"節點鍊",c_rule:"規則",c_time:"連線時間",c_source:"來源",c_destination_ip:"目標 IP",c_type:"類型",c_ctrl:"關閉",restart_core:"重啟核心",upgrade_core:"更新核心",close_all_confirm:"確定關閉所有連接?",close_all_confirm_yes:"確定",close_all_confirm_no:"取消",manage_column:"管理列",reset_column:"重置列",device_name:"設備名稱",delete:"刪除",add_tag:"新增標籤",client_tag:"客戶端標籤",sourceip_tip:"/開頭為正規表達式,否則為全面配對",disconnect:"斷開連線",internel:"內部連線",Clear:"清空"};export{e as data};
+23
View File
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="shortcut icon" href="yacd.ico" />
<link rel="icon" type="image/png" sizes="64x64" href="yacd.png" />
<link rel="icon" type="image/png" sizes="128x128" href="yacd.png" />
<link rel="apple-touch-icon-precomposed" href="apple-touch-icon-precomposed.png" />
<meta name="apple-mobile-web-app-title" content="yacd" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="application-name" content="yacd" />
<meta name="description" content="Yet Another Clash Dashboard" />
<meta name="theme-color" content="#202020" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#f7f7f7" media="(prefers-color-scheme: light)" />
<title>yacd</title>
<script type="module" crossorigin src="./assets/index-C9L_vfV_.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DfZ88PCr.css">
<link rel="manifest" href="./manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="./registerSW.js"></script></head>
<body>
<div id="app" data-base-url="http://127.0.0.1:9090"></div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

+1
View File
@@ -0,0 +1 @@
{"name":"yacd","short_name":"yacd","description":"Yet another Clash dashboard","start_url":"./","display":"standalone","background_color":"#ffffff","theme_color":"#42b883","lang":"en","scope":"./","icons":[{"src":"apple-touch-icon-precomposed.png","sizes":"512x512","type":"image/png"}]}
+1
View File
@@ -0,0 +1 @@
if('serviceWorker' in navigator) {window.addEventListener('load', () => {navigator.serviceWorker.register('./sw.js', { scope: './' })})}
+2
View File
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB