feat(panel, PANEL_ENHANCE_PLAN 批1-A): 访问级 4 位 PIN 鉴权 + 后端只读端点 power/overview/ams/svcd + audio 电平键

鉴权(用户定案:访问面板即需登录):
- PANEL.pin 存 sha256+salt(salt:hex,绝不含明文);与未来锁屏 PIN 同源预留 system.pin(g_sets 注记)。
- 未设 PIN → /api/auth/setup 首次设置流程;未设 PIN 前读/写端点全拦(require_auth 无条件,登录/设置流程除外)。
- token 128bit(16B→32hex) 只存进程内存(重启自然失效),3 天固定不滑动续期,多会话并存。
- 连错 5 次 PIN 锁 5 分钟(429 LOCKED,status 透出 lock_remaining_ms)。
- 旧密码机制 panel.auth_enabled/panel.auth_pass 兼容迁移:未设 PIN 时可暂用旧密码登录(status=legacy),设 PIN 后以 PIN 为准。

新只读端点(均需 PIN 会话):
- /api/power: meow_powermgr MSG_PM_QUERY(0xD0)→RESULT(0xD1) battery/estimate/breakdown,confidence 原样透传。
- /api/audio: 补 rms/peak/lvlseq(追加键,ABI 兼容),抽 audio_fill() 供 overview 复用。
- /api/ams: am list 解析 App 表(pkg/name/state/pid/launches/crashes/last_kind/last_code)。
- /api/svcd: meow_svcd status all 解析服务表(name/state/pid/restart/backoff_ms/trigger/src/exit/exec)。
- /api/overview: 单线程后端只暴露聚合——perf/power/audio/net/ams/svcd 六源顺序 fan-out,任一源失败该子块 available:false,绝不假数据。

前端最小支撑:登录页(PIN 输入+首次设置双态)+ 401 自动跳登录 + token 存 localStorage 随请求带 X-MeowToken(后端亦认 Authorization: Bearer)。
红线:仅新增读级端点,未加任何写端点(音量/路由/策略等);本任务不含批1-B 页面。

md5: meowpanel 3696e85b5eaf111f530c5c39fa68886e(mipsel 静态)/ meowpanel_host 3180e4f87e77db8717597619097805eb。
验证:host+mips -Werror 0 warning;假 IPC/am/svcd 桩六源聚合(含 power 宕机降级)PASS;PIN 全流程(设置→登录→连错5次锁→token 过期)PASS;qemu-mipsel 冒烟 PASS。
This commit is contained in:
MeowOS committed 2026-09-01 21:30:41 +08:00
1 parent 30e2ec901f
commit d12164305d
6 files changed
+627 -155

No files matched your search

+4 -3
View File
@@ -116,12 +116,13 @@
</div>
</div>
<!-- 登录页(面板鉴权启用后显示) -->
<!-- 登录 / 首次设置 PIN(访问级 PIN 鉴权;访问面板即需登录) -->
<div id="login" class="login-scrim" hidden>
<div class="login-box">
<div class="login-brand"><span class="login-brand-mark">M</span><span>MeowPanel</span></div>
<p class="login-sub">此面板已开启密码鉴权,请输入密码后使用。</p>
<input type="password" id="login-password" class="login-input" placeholder="面板密码" autocomplete="current-password">
<p class="login-sub" id="login-sub">此面板已开启访问鉴权,请输入 4 位 PIN。</p>
<input type="password" id="login-pin" class="login-input" placeholder="4 位 PIN" inputmode="numeric" autocomplete="off" maxlength="4">
<input type="password" id="login-pin2" class="login-input" placeholder="再次输入 PIN" inputmode="numeric" autocomplete="off" maxlength="4" hidden>
<p class="login-err" id="login-err" hidden></p>
<button class="btn btn-primary login-btn" id="login-btn">登 录</button>
</div>
+59 -16
View File
@@ -129,39 +129,80 @@
});
}
/* ---------------- 鉴权(可选密码登录) ---------------- */
function authRequired() { // 任一路由收到 401 → 弹登录页
/* ---------------- 访问级 PIN 鉴权(设置 PIN → 登录;401 自动跳登录;token 存 localStorage) ----------------
* 后端:访问面板即需登录;token 存面板进程内存(重启失效,3 天固定不滑动续期);连续 5 次错 PIN 锁 5 分钟。
* 前端:token 存 localStorage 随请求带 X-MeowToken;401 自动切登录页;未设 PIN 走首次设置流程。 */
let authMode = 'login'; // 'login' | 'setup'
function loginErr(msg) { const err = $('#login-err'); if (err) { err.textContent = msg; err.hidden = false; } }
function authRequired() { // 任一路由收到 401 → 弹登录页(会话失效/未认证)
state.unauth = true;
showLogin();
refreshAuthGate();
}
function showLogin() {
async function refreshAuthGate() { // 查状态决定「首次设置」还是「登录」
let st = null;
try { st = await api('GET', '/api/auth/status'); } catch (e) { st = null; }
if (st && st.session) { state.unauth = false; hideLogin(); return; }
if (st && !st.pin_set && !st.legacy) showLogin('setup');
else showLogin('login');
}
function showLogin(mode) {
state.unauth = true;
authMode = mode || 'login';
const lg = $('#login'); if (lg) lg.hidden = false;
const ip = $('#login-password'); if (ip) ip.focus();
const sub = $('#login-sub');
const pin2 = $('#login-pin2');
const btn = $('#login-btn');
if (authMode === 'setup') {
if (sub) sub.textContent = '首次使用:请设置一个 4 位数字 PIN 作为本面板访问口令。';
if (pin2) pin2.hidden = false;
if (btn) btn.textContent = '设 置';
} else {
if (sub) sub.textContent = '此面板已开启访问鉴权,请输入 4 位 PIN。';
if (pin2) pin2.hidden = true;
if (btn) btn.textContent = '登 录';
}
const ip = $('#login-pin'); if (ip) { ip.value = ''; ip.focus(); }
const ip2 = $('#login-pin2'); if (ip2 && authMode !== 'setup') ip2.value = '';
const err = $('#login-err'); if (err) err.hidden = true;
}
function hideLogin() { const lg = $('#login'); if (lg) lg.hidden = true; }
async function doLogin() {
const pw = $('#login-password').value;
const err = $('#login-err');
if (err) err.hidden = true;
if (!pw) { if (err) { err.textContent = '请输入密码'; err.hidden = false; } return; }
const pin = ($('#login-pin').value || '').trim();
const err = $('#login-err'); if (err) err.hidden = true;
if (!pin) { loginErr('请输入 PIN'); return; }
if (authMode === 'setup') {
const pin2 = ($('#login-pin2').value || '').trim();
if (pin.length !== 4 || /[^0-9]/.test(pin)) { loginErr('PIN 必须为 4 位数字'); return; }
if (pin !== pin2) { loginErr('两次输入的 PIN 不一致'); return; }
const btn = $('#login-btn'); if (btn) btn.disabled = true;
try {
await api('POST', '/api/auth/setup', { pin: pin });
toast('PIN 已设置,请登录');
showLogin('login');
} catch (e2) { loginErr((e2 && e2.message) ? e2.message : '设置失败'); }
finally { if (btn) btn.disabled = false; }
return;
}
const btn = $('#login-btn'); if (btn) btn.disabled = true;
try {
const d = await api('POST', '/api/auth/login', { password: pw });
const d = await api('POST', '/api/auth/login', { pin: pin });
storageSet('meowpanel.token', d.token);
state.unauth = false;
hideLogin();
toast('已登录');
if (started) route(); else startApp();
} catch (e) {
if (err) { err.textContent = (e && e.message) ? e.message : '登录失败'; err.hidden = false; }
if (e && (e.code === 'LOCKED' || e.status === 429)) loginErr(e.message || '错误次数过多,已锁定,请稍后重试');
else loginErr((e && e.message) ? e.message : '登录失败');
} finally {
const b = $('#login-btn'); if (b) b.disabled = false;
}
}
function bindLogin() {
const ok = $('#login-btn'); if (ok) ok.addEventListener('click', doLogin);
const ip = $('#login-password'); if (ip) ip.addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
const ip = $('#login-pin'); if (ip) ip.addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
// PIN 输入:只允许数字,最长 4 位
[ip, $('#login-pin2')].forEach(el => { if (el) el.addEventListener('input', () => { el.value = el.value.replace(/[^0-9]/g, '').slice(0, 4); }); });
}
function passwordDlg(title, body) {
return new Promise(res => {
@@ -1488,7 +1529,7 @@
$('#menu-btn').addEventListener('click', () => $('#sidebar').classList.toggle('open'));
}
function route() {
if (state.unauth) { showLogin(); return; } // 未认证 → 登录页
if (state.unauth) { showLogin(authMode); return; } // 未认证 → 登录页(保持当前设置/登录态)
const id = (location.hash || '#/perf').replace('#/', '').split('?')[0] || 'perf';
const r = ROUTES[id] || ROUTES.perf;
state.route = id;
@@ -1517,11 +1558,13 @@
bindFiles(); bindApps(); bindFonts(); bindSettings(); bindRemote(); bindScreen(); bindSystem(); bindLogs(); bindNotif(); bindNet();
bindLogin();
bindPwDlg();
// 首次进入探鉴权状态:启用且无有效会话 → 登录页;否则直接进应用
// 首次进入:访问级 PIN 鉴权——已登录直接进;未设 PIN(且无旧密码)→ 首次设置流程;已设 → 登录
let st = null;
try { st = await api('GET', '/api/auth/status'); } catch (e) { st = null; }
if (st && st.auth_enabled && !st.session) { showLogin(); return; }
startApp();
if (st && st.session) { startApp(); return; }
if (st && !st.pin_set && !st.legacy) { showLogin('setup'); return; }
if (st) { showLogin('login'); return; }
startApp(); // 后端不可达:仍启动(显示离线横幅)
}
document.addEventListener('DOMContentLoaded', init);
})();