Files
cellular-proxy/ui/index.html
T
Hermes 91939c7cc2 feat: UI/CLI 配置代理账密;armv7 仅保留整包
- 面板可设置 7890 账号密码(管理 API :9091)
- cpxy auth --user/--pass/--clear/--show
- Release armv7 整包已验证,已删除分片资产
2026-07-21 12:29:57 +00:00

328 lines
14 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>数据出口代理 · 控制台</title>
<style>
:root {
--bg: #0f1419; --card: #1a2332; --text: #e7ecf3; --muted: #8b9bb4;
--accent: #3d8bfd; --ok: #3dd68c; --warn: #f5a524; --bad: #f31260; --border: #2a3548;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg); color: var(--text); min-height: 100vh; }
header { padding: 16px 20px; border-bottom: 1px solid var(--border);
display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; }
h1 { font-size: 1.15rem; margin: 0; font-weight: 600; }
.muted { color: var(--muted); font-size: 0.85rem; }
main { padding: 16px; max-width: 960px; margin: 0 auto; display: grid; gap: 14px; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; }
.row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
label { font-size: 0.85rem; color: var(--muted); }
input, button { background: #0d121a; color: var(--text); border: 1px solid var(--border);
border-radius: 8px; padding: 8px 10px; font-size: 0.95rem; }
input { min-width: 160px; }
button { cursor: pointer; background: var(--accent); border: none; font-weight: 600; }
button.secondary { background: #2a3548; }
button.danger { background: var(--bad); }
.pill { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 0.8rem; background: #243044; }
.pill.ok { background: rgba(61,214,140,.15); color: var(--ok); }
.pill.bad { background: rgba(243,18,96,.15); color: var(--bad); }
.pill.warn { background: rgba(245,165,36,.15); color: var(--warn); }
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
th, td { text-align: left; padding: 8px 6px; border-bottom: 1px solid var(--border); }
th { color: var(--muted); font-weight: 500; }
pre { margin: 0; white-space: pre-wrap; word-break: break-all; font-size: 0.8rem;
color: var(--muted); max-height: 220px; overflow: auto; }
.banner { background: rgba(61,139,253,.12); border: 1px solid rgba(61,139,253,.35);
border-radius: 10px; padding: 10px 12px; font-size: 0.9rem; line-height: 1.5; }
#authMsg { margin-top: 8px; font-size: 0.85rem; }
</style>
</head>
<body>
<header>
<div>
<h1>数据出口代理 <span class="muted">cellular-proxy</span></h1>
<div class="muted">走代理 = 走运营商流量 · 单进程轻量</div>
</div>
<div class="row">
<span id="connState" class="pill warn">未连接</span>
<button class="secondary" onclick="refreshAll()">刷新</button>
</div>
</header>
<main>
<section class="banner">
<strong>行为说明:</strong>系统默认仍可走 WiFi。
只要客户端使用本机 HTTP/SOCKS 代理,出口一律绑定<strong>数据网卡</strong>(流量)。
无需配置域名分流。
</section>
<section class="card">
<div class="row" style="margin-bottom:10px">
<div>
<label>API 地址(Clash 9090</label><br/>
<input id="apiBase" placeholder="http://127.0.0.1:9090" />
</div>
<div>
<label>管理 API(默认 9091</label><br/>
<input id="adminBase" placeholder="http://127.0.0.1:9091" />
</div>
<div>
<label>密钥 secret</label><br/>
<input id="secret" type="password" placeholder="PANEL_SECRET" />
</div>
<div style="align-self:end">
<button onclick="saveAuth(); refreshAll()">保存并连接</button>
</div>
</div>
<div class="muted">密钥仅存本浏览器 localStorage。管理 API 与面板 secret 相同。</div>
</section>
<section class="card">
<div class="row" style="justify-content:space-between;margin-bottom:8px">
<strong>代理账号密码(7890</strong>
<span id="proxyAuthState" class="pill warn">未知</span>
</div>
<div class="row" style="margin-bottom:10px">
<div>
<label>用户名</label><br/>
<input id="proxyUser" placeholder="proxy" autocomplete="username" />
</div>
<div>
<label>密码</label><br/>
<input id="proxyPass" type="password" placeholder="新密码" autocomplete="new-password" />
</div>
<div style="align-self:end" class="row">
<button onclick="saveProxyAuth()">保存并生效</button>
<button class="secondary" onclick="clearProxyAuth()">清除鉴权</button>
<button class="secondary" onclick="loadProxyAuth()">刷新状态</button>
</div>
</div>
<div class="muted">保存后会重写 settings、重生 sing-box 配置并重启服务(约 1~2 秒)。</div>
<div id="authMsg" class="muted"></div>
</section>
<section class="card">
<div class="muted">版本</div>
<div id="verInfo"></div>
</section>
<section class="card">
<div class="row" style="justify-content:space-between;margin-bottom:8px">
<strong>出站</strong>
<span class="muted">应只有 cellular(数据网)</span>
</div>
<div id="proxies">加载中…</div>
</section>
<section class="card">
<div class="row" style="justify-content:space-between;margin-bottom:8px">
<strong>当前连接</strong>
<button class="secondary" onclick="loadConnections()">刷新</button>
</div>
<div style="overflow:auto">
<table>
<thead><tr><th>目标</th><th>出站链</th><th>上传</th><th>下载</th><th></th></tr></thead>
<tbody id="connBody"></tbody>
</table>
</div>
</section>
<section class="card">
<strong>怎么用</strong>
<pre>
1. 代理端口默认 7890HTTP + SOCKS5 同一端口)
2. 设备设置系统/应用代理 → Debian 的 LAN-IP:7890
3. 需要鉴权时在上方设置用户/密码,或: sudo cpxy auth --user u --pass p
4. 验证:直连 ifconfig.me 与 走代理 ifconfig.me 公网 IP 应不同
5. 改网卡:sudo cpxy rebind
6. 低内存:MEMORY_MAX_MB=64~96LOG_LEVEL=warn
</pre>
</section>
<section class="card">
<strong>API 调试</strong>
<pre id="raw"></pre>
</section>
</main>
<script>
const $ = (id) => document.getElementById(id);
function defaultBase() { return location.origin || "http://127.0.0.1:9090"; }
function defaultAdminBase() {
try {
const u = new URL(defaultBase());
u.port = "9091";
return u.origin;
} catch {
return "http://127.0.0.1:9091";
}
}
function loadAuth() {
$("apiBase").value = localStorage.getItem("cpxy_api") || defaultBase();
$("adminBase").value = localStorage.getItem("cpxy_admin") || defaultAdminBase();
$("secret").value = localStorage.getItem("cpxy_secret") || "";
}
function saveAuth() {
localStorage.setItem("cpxy_api", $("apiBase").value.trim().replace(/\/$/, ""));
localStorage.setItem("cpxy_admin", $("adminBase").value.trim().replace(/\/$/, ""));
localStorage.setItem("cpxy_secret", $("secret").value);
}
function base() { return ($("apiBase").value || defaultBase()).replace(/\/$/, ""); }
function adminBase() { return ($("adminBase").value || defaultAdminBase()).replace(/\/$/, ""); }
function headers() {
const h = { "Content-Type": "application/json" };
const s = $("secret").value;
if (s) h["Authorization"] = "Bearer " + s;
return h;
}
async function api(path, opt = {}) {
const r = await fetch(base() + path, { ...opt, headers: { ...headers(), ...(opt.headers || {}) } });
if (!r.ok) throw new Error(path + " " + r.status + " " + (await r.text()));
const t = await r.text();
try { return t ? JSON.parse(t) : {}; } catch { return t; }
}
async function adminApi(path, opt = {}) {
const r = await fetch(adminBase() + path, { ...opt, headers: { ...headers(), ...(opt.headers || {}) } });
const t = await r.text();
let data = {};
try { data = t ? JSON.parse(t) : {}; } catch { data = { raw: t }; }
if (!r.ok) throw new Error((data.error || data.message || t || r.status) + "");
return data;
}
function fmt(n) {
if (n == null) return "—";
const u = ["B", "KB", "MB", "GB"];
let i = 0, x = Number(n);
while (x >= 1024 && i < u.length - 1) { x /= 1024; i++; }
return x.toFixed(i ? 1 : 0) + " " + u[i];
}
function setAuthMsg(text, ok) {
const el = $("authMsg");
el.textContent = text || "";
el.style.color = ok === true ? "var(--ok)" : (ok === false ? "var(--bad)" : "var(--muted)");
}
async function loadProxyAuth() {
try {
const data = await adminApi("/proxy-auth");
$("proxyUser").value = data.proxy_user || "";
if (data.auth_enabled) {
$("proxyAuthState").textContent = "已启用 · " + (data.proxy_user || "");
$("proxyAuthState").className = "pill ok";
setAuthMsg("当前已设置鉴权" + (data.proxy_pass_masked ? "(密码 " + data.proxy_pass_masked + "" : ""), true);
} else {
$("proxyAuthState").textContent = "无鉴权";
$("proxyAuthState").className = "pill warn";
setAuthMsg("当前 7890 无账号密码", null);
}
$("proxyPass").value = "";
} catch (e) {
$("proxyAuthState").textContent = "管理 API 不可用";
$("proxyAuthState").className = "pill bad";
setAuthMsg("无法连接管理 API9091)。确认 cellular-proxy-admin 已启动,或用: sudo cpxy auth --user u --pass p。错误: " + e.message, false);
}
}
async function saveProxyAuth() {
saveAuth();
const user = $("proxyUser").value.trim();
const pass = $("proxyPass").value;
if (!user) { setAuthMsg("请填写用户名", false); return; }
if (!pass) { setAuthMsg("请填写密码(不会回显已有密码)", false); return; }
setAuthMsg("正在应用…", null);
try {
const data = await adminApi("/proxy-auth", {
method: "POST",
body: JSON.stringify({ user, pass })
});
setAuthMsg("已生效:用户 " + (data.proxy_user || user), true);
await loadProxyAuth();
} catch (e) {
setAuthMsg("保存失败: " + e.message, false);
}
}
async function clearProxyAuth() {
if (!confirm("确定清除代理账号密码?清除后 7890 无需鉴权。")) return;
saveAuth();
setAuthMsg("正在清除…", null);
try {
await adminApi("/proxy-auth", {
method: "POST",
body: JSON.stringify({ clear: true })
});
$("proxyUser").value = "";
$("proxyPass").value = "";
setAuthMsg("已清除鉴权", true);
await loadProxyAuth();
} catch (e) {
setAuthMsg("清除失败: " + e.message, false);
}
}
async function refreshAll() {
try {
const ver = await api("/version");
$("verInfo").textContent = ver.version || JSON.stringify(ver);
$("connState").textContent = "已连接";
$("connState").className = "pill ok";
$("raw").textContent = JSON.stringify(ver, null, 2);
await loadProxies();
await loadConnections();
await loadProxyAuth();
} catch (e) {
$("connState").textContent = "失败: " + e.message;
$("connState").className = "pill bad";
$("proxies").textContent = "无法连接 API。请确认服务已启动,并填写正确 secret。";
await loadProxyAuth();
}
}
async function loadProxies() {
const data = await api("/proxies");
const proxies = data.proxies || {};
const names = Object.keys(proxies).filter(k => ["cellular", "DIRECT", "REJECT"].includes(k) || proxies[k].type === "Direct");
const keys = names.length ? names : Object.keys(proxies).slice(0, 15);
let html = "<table><thead><tr><th>名称</th><th>类型</th><th>测速</th></tr></thead><tbody>";
for (const name of keys) {
const p = proxies[name] || {};
html += `<tr><td>${name}</td><td>${p.type || ""}</td>
<td><button class="secondary" onclick="delay('${name}')">测速</button> <span id="d-${name}"></span></td></tr>`;
}
html += "</tbody></table>";
$("proxies").innerHTML = html;
}
async function delay(name) {
try {
const r = await api("/proxies/" + encodeURIComponent(name) + "/delay?timeout=5000&url=" +
encodeURIComponent("https://www.gstatic.com/generate_204"));
$("d-" + name).textContent = (r.delay != null ? r.delay + "ms" : JSON.stringify(r));
} catch (e) { $("d-" + name).textContent = e.message; }
}
async function loadConnections() {
try {
const data = await api("/connections");
const conns = data.connections || [];
const body = $("connBody");
body.innerHTML = "";
conns.slice(0, 50).forEach(c => {
const tr = document.createElement("tr");
const host = (c.metadata && (c.metadata.host || c.metadata.destinationIP)) || "—";
const port = c.metadata && c.metadata.destinationPort ? ":" + c.metadata.destinationPort : "";
tr.innerHTML = `<td>${host}${port}</td><td>${c.chains ? c.chains.join("→") : "—"}</td>
<td>${fmt(c.upload)}</td><td>${fmt(c.download)}</td>
<td><button class="danger" onclick="closeConn('${c.id}')">断开</button></td>`;
body.appendChild(tr);
});
if (!conns.length) body.innerHTML = "<tr><td colspan=5 class=muted>暂无连接</td></tr>";
} catch (e) {
$("connBody").innerHTML = "<tr><td colspan=5>" + e.message + "</td></tr>";
}
}
async function closeConn(id) {
await api("/connections/" + id, { method: "DELETE" });
loadConnections();
}
loadAuth();
refreshAll();
setInterval(() => { loadConnections().catch(() => {}); }, 5000);
</script>
</body>
</html>