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

+60 -22
View File
@@ -28,7 +28,8 @@ cd /home/Aska/Project/R3ProII/patch_v2/meowpanel
- 两分支均以 `-Os -Wall -Wextra` 复核,**0 warning**(`-Werror` 亦通过)。
- 产出(本次构建实测):
- `meowpanel`:mipsel 静态,`ELF 32-bit LSB executable, MIPS, MIPS32 rel2, statically linked, stripped`,
体积约 **218 KB**(固件阈值内)。md5:`015f9cf1c12e36d133b01cf6b6648edd`(本次含 `/api/audio` 音频状态端点 + 前端「音频」「字体」页)。
体积约 **236 KB**(固件阈值内)。md5:`3696e85b5eaf111f530c5c39fa68886e`(批1-A:访问级 **4 位 PIN 鉴权** + `/api/{power,overview,ams,svcd}` + `/api/audio` 补 `rms/peak/lvlseq`。
**重防火墙注意**:运行中二进制先 `.new` 再 `mv`,切勿直接覆盖运行中的 `meowpanel`)。
- `meowpanel_host`:aarch64 动态链接(宿主编译,仅供调试),体积约 **148 KB**。
- 依赖:仅 libc + Linux 内核头(`linux/input.h`),零第三方库(设备无 python3/lua)。鉴权用**内联 SHA-256**(拷贝自
`meow_verify/sha256.c` 公有领域参考写法,前缀 `mp_sha256_*` 避免符号冲突;**未改 `meow_verify/` 目录**)。
@@ -73,9 +74,10 @@ http://<host>.local:8080/
- mDNS 由板载 `meow_mdns` 应答 `<host>.local`。
- 端口默认 **8080**,可用 `-p PORT` / 环境变量 `MEOWPORT` / 与后端同目录 `meowpanel.conf`(`port=N`)覆盖。
(优先级:`-p` > `MEOWPORT` > `meowpanel.conf` > 默认 8080。)
- 无鉴权(设备内网信任模型;**可选**密码鉴权已实现,见 §5.9)。默认关=零回归;开启后除登录/登出/状态外 `/api/*` 需会话。
- **访问级 PIN 鉴权(用户 2026-09-01 定案)**:访问面板即需登录(4 位 PIN,`panel.pin`,见 §5.9);未认证请求一律 401。
除 `/api/auth/{login,setup,logout,status}` 外全部 `/api/` 需有效会话(token 存面板进程内存,重启失效,3 天固定不滑动续期)。
- 上传/下载/管理类请求统一带 `X-MeowToken: <token>`(或 `Authorization: Bearer <token>` / `Cookie: meow_token=<token>`);
缺/错/过期 → 401 `UNAUTHORIZED`。**默认无鉴权时请勿把 8080 暴露公网**。
缺/错/过期 → 401 `UNAUTHORIZED`。**HTTP 明文(无 TLS),内网使用;请勿把 8080 暴露公网**。
---
@@ -88,8 +90,9 @@ http://<host>.local:8080/
| GET | `/`、`/meowpanel.css`、`/meowpanel.js`、`/assets/<f>` | 静态托管 | ✅ |
| GET | `/api/screen.jpeg` | 实时画面:fb0 当前帧 → RGB565→JPEG(`?q=1-100` 质量,默认 70;`?delta=1` 开启增量帧,见 §5.10) | ✅(阶段 C) |
| POST | `/api/input/tap` | 触摸注入:`{x,y}`(480×720 原生坐标;write 进 goodix event1,本机实测可投递);本机触摸后 5s 内忽略注入 | ✅(阶段 C) |
| GET | `/api/auth/status` | 鉴权状态(enabled/会话有效) | ✅(公开) |
| POST | `/api/auth/login` | 密码登录发 token(sha256 带盐校验) | ✅(公开;错→401) |
| GET | `/api/auth/status` | 访问鉴权状态(auth_enabled/session/pin_set/legacy/locked/lock_remaining_ms) | ✅(公开) |
| POST | `/api/auth/setup` | 首次设置 4 位 PIN(仅未设 PIN 时;`{pin}` 4 位数字;已设→409) | ✅(公开;非法→422) |
| POST | `/api/auth/login` | PIN 登录发 token(`{pin}`;未设 PIN 时接受旧密码 `{password}` 迁移;错→401,连错 5 次→429 LOCKED) | ✅(公开) |
| POST | `/api/auth/logout` | 登出失效 token | ✅(公开;幂等) |
| GET | `/api/status` | 性能+系统(`meow_svc_perf` IPC subset=all 透传) | ✅ |
| GET | `/api/perf` | 仅性能(subset=perf 透传) | ✅ |
@@ -112,7 +115,11 @@ http://<host>.local:8080/
| GET | `/api/fonts` | 字体库列表(扫描 `MeowOS/fonts/<family>/<weight>/` 三件套齐) | ✅(无卡→503) |
| POST | `/api/fonts/import` | 上传字体库 `.tar.gz`(multipart `file`,可带 `?force=1` 覆盖) | ✅(缺件/坏json/穿越→400,冲突→409,超限→413) |
| GET | `/api/fonts/export` | 导出 `<family>/<weight>/` 三件套为 `.tar.gz`(`?family=&weight=`) | ✅(缺件→404) |
| GET | `/api/audio` | 音频状态快照:meow_audiod IPC `MSG_AUDIO_QUERY→STATE` 透传(当前流/路由/音量/underrun,见 §5.12) | ✅(audiod 不在→`available:false`,不报错) |
| GET | `/api/audio` | 音频状态快照:meow_audiod IPC `MSG_AUDIO_QUERY→STATE` 透传(当前流/路由/音量/underrun + **`rms/peak/lvlseq` 电平**,见 §5.12) | ✅(audiod 不在→`available:false`,不报错) |
| GET | `/api/power` | 功耗管理:meow_powermgr IPC `MSG_PM_QUERY→RESULT`(battery/estimate/breakdown,confidence 原样透传) | ✅(powermgr 不在→503 `POWER_UNAVAILABLE`) |
| GET | `/api/ams` | App 运行表:`am list`(pkg/name/state/pid/launches/crashes/last_kind/last_code) | ✅(amsd 不在→`available:false`) |
| GET | `/api/svcd` | 服务表:`meow_svcd status all`(name/state/pid/restart/backoff_ms/trigger/src/exit/exec) | ✅(svcd 不在→`available:false`) |
| GET | `/api/overview` | **首页聚合**(单线程后端只暴露聚合):后端顺序 fan-out perf/power/audio/net/ams/svcd 六源合成一份 JSON;任一源失败→该子块 `available:false`,绝不假数据 | ✅(**读级,需 PIN 会话**) |
| GET | `/api/apps` | 扫描 `MeowAPPs/` 读 `app.json` | ✅ |
| GET | `/api/apps/:pkg` | 应用详情 | ✅ |
| POST | `/api/apps/install` | 经 `nyai install`(multipart 或 path) | ✅ |
@@ -237,21 +244,26 @@ http://<host>.local:8080/
TF 路径仅为 launcher 不可达时的诚实降级(会显示自动过期的历史通知,直到 clear)。
- **并发**:IPC 可用时面板不写 TF(撤清由 launcher 自行归档),避免与 launcher `O_APPEND` 竞写;仅 IPC 回退到「写临时 + rename」。
### 5.9 可选鉴权(panel.auth_enabled / panel.auth_pass)
- **设置键**:`panel.auth_enabled`(bool,默认 `0`=关)+ `panel.auth_pass`(str,存 `salt:hex`)。两者写 `settings.conf`,
与实体设置页/CLI 同一存储契约(面板为独立进程,直接镜像文件)。`panel.auth_pass` 的 `GET /api/settings` 恒回
`"value":"" + "set":<bool>`,**绝不回传存量哈希/明文**。
- **门禁**:`require_auth` 在路由分发前执行——未启用直接放行(零回归);启用后除 `/api/auth/{login,logout,status}` 外全部
`/api/` 需有效会话(token 头 / `X-MeowToken` / `Cookie meow_token`),缺/错/过期 → 401 `UNAUTHORIZED`。
危险端点(reboot/shutdown/ota、key 注入)在未开启时**不额外限制**(保持内网模型),开启后亦统一纳入会话校验。
- **密码**:`PUT /api/settings/panel.auth_pass` 传明文,后端生成 16B 盐(/dev/urandom)→ `salt:hex`(sha256(salt||password))
落盘;空串=清除。校验时重算并恒定比较。**哈希/明文绝不进日志/响应**。`panel.auth_enabled` 置 1 时若未设密码 → 422 `请先设置面板密码`。
- **会话**:`POST /api/auth/login` 校验后发 token(32B → 64 hex),存内存(`AUTH_MAX_SESS`=8,满淘汰最早过期)+ 卡上
`<web_root>/meowpanel.session`(`<token> <expires_at>`,重启加载、防吊销失效),有效期 12h。`logout` 失效当前 token。
- **静态屏蔽**:`serve_static` 额外拒绝后端可执行文件与 `meowpanel.session`(403),防 token 文件被 HTTP 读取。
- **前端**:api 客户端统一带 `X-MeowToken`,收到 401 → 弹登录页(`#login` overlay);登录成功 token 存 `localStorage`;
首次进入以 `/api/auth/status` 决定是否弹登录。设置页「面板鉴权」卡片含开关(`panel.auth_enabled`)+「设置/修改密码」
按钮(`panel.auth_pass`,走 `passwordDlg` 输入)。
### 5.9 访问级 PIN 鉴权(panel.pin;用户定案 2026-09-01)
- **模型(覆盖 282 报告「默认无鉴权」前提)**:**访问面板即需登录**。未认证请求一律 401(静态登录页自身 + css/js 放行——
`serve_static` 先于 `require_auth`);未设 PIN 前无法建立会话,故**读端点也全拦**(登录/设置流程除外),写端点自然一律拒绝。
- **设置键**:`panel.pin`(str,存 `salt:hex` = sha256(salt||pin) 带盐哈希,绝不存明文),写板载 `settings.conf`(与实体设置页/CLI
同一存储契约,面板独立进程直接镜像文件);**与未来 MeowOS 锁屏 PIN 同源**:设置键 `system.pin` 预留(本后端只读面板键,见 `g_sets` 注记)。
`panel.pin` 的 `GET /api/settings` 恒回 `"value":"" + "set":<bool>`,**绝不回传哈希/明文**。
- **PIN**:4 位数字(`pin_validate`);校验时重算 sha256(salt||pin) 并恒定比较(`memcmp`)。
- **门禁**:`require_auth` 在路由分发前执行——除 `/api/auth/{login,setup,logout,status}` 外全部 `/api/` 需有效会话;
缺/错/过期 → 401 `UNAUTHORIZED`。危险端点(reboot/shutdown/ota、key 注入、settings 写等)一律纳入会话校验(不再有「未开启」旁路)。
- **会话 token**:`/dev/urandom` 16B → **32 hex(128bit)**,**只存面板进程内存**(`g_sess[]`,`AUTH_MAX_SESS`=8,满淘汰最早过期),
**无卡上持久化**——设备重启自然清(会话即失效)。有效期 **3 天**(`AUTH_TOKEN_TTL`,**固定不滑动续期**,逐 token 校验到期戳);
多会话并存。`logout` 失效当前 token。
- **锁定(防爆破)**:连续 `PIN_MAX_FAILS=5` 次错 PIN → 锁 `PIN_LOCK_SEC=300`s(`429 LOCKED`,响应附 `lock_remaining_ms`;
`status` 透出 `locked/lock_remaining_ms`);内存态,重启清。
- **旧密码机制兼容迁移**(`panel.auth_enabled`/`panel.auth_pass` 保留):旧密码存在且未设 PIN 时,视为「已设访问屏障」的同等物——
登录可用旧密码(`{"password":…}`),`status` 透出 `legacy:true` 并提示改设 4 位 PIN;**一旦设置 `panel.pin`,新代码以 PIN 为准**
(登录只认 PIN,旧密码不再作为凭据)。`PUT /api/settings/panel.auth_pass` 仍可写(引导迁移),`panel.auth_enabled` 已不作门禁(保留兼容)。
- **前端**:api 客户端统一带 `X-MeowToken`(token 存 `localStorage`);收到 401 → 自动切登录页;未设 PIN → 首次设置流程
(`#login` overlay 双态:设置=输入+确认,登录=单输入);429 `LOCKED` → 显示锁定提示。设置页「面板鉴权」卡片沿用旧密码项。
- **静态屏蔽**:`serve_static` 额外拒绝后端可执行文件(403),防可执行被 HTTP 读取。
### 5.10 实时画面(`/api/screen.jpeg`,含增量帧 `?delta=1`)+ 触摸注入(`/api/input/tap`)— 阶段 C
- **实时画面(整帧,默认)**:`GET /api/screen.jpeg` 读 `/dev/fb0` **当前显示帧**(`FBIOGET_VSCREENINFO` 取 `yoffset`/`xres`/`yres`/`bpp`,
@@ -314,8 +326,10 @@ http://<host>.local:8080/
(`active=.. app=.. sid=.. rate=.. ch=.. fmt=.. written=.. played=.. underrun=.. paused=.. route_mode=.. port=.. vol=..`,
空格分隔 k=v、NUL 结尾)。**audiod 无 MSG_HELLO 握手**(见 meow_audio.c CLI),与 perf/power/launcher 路径不同。
- **响应**:解析 k=v 为 JSON——`{available:true, active, app, sid, rate, ch, fmt, written_frames, played_frames,
underrun, paused, route_mode("auto"/"manual"), port, vol}`;`port` 为 amixer numid=9 路由口
underrun, paused, route_mode("auto"/"manual"), port, vol, **rms, peak, lvlseq**}`;`port` 为 amixer numid=9 路由口
(0=空闲 1=lineout 2=3.5 3=4.4 4=Type-C SPDIF;宿主无 amixer 时为 -1)。
- **电平(追加键,ABI/协议兼容)**:`rms`/`peak` = deci-dB(`-990`=‑inf;如 `-139`=‑13.9dBFS),`lvlseq` = 快照代际
(每次结算/开流复位递增,前端可用其判「新刻度」驱动 sparkline)。仅追加键,不破坏既有字段。
- **audiod 不在**(socket 缺失/连接拒/超时/非预期应答)→ HTTP 200 `{"available":false}`,**不报错**(面板音频页显示
「audiod 不可用」)。只读端点;不放测试音(真机乱响风险,tone 留给 CLI `meow_audio tone`)。
- **前端**:「音频」页(`web/#/audio`)5s 轮询展示——当前流(app/rate/ch/fmt/已写帧/已播放帧/underrun/暂停态)+
@@ -328,6 +342,30 @@ http://<host>.local:8080/
`family/weight` 与 `warning`(sha256 不符仅提示)/`hint`。
- 设置页原「字体库」卡保留(同一后端,互不影响)。
### 5.14 批1-A 新增只读端点(供批1-B 前端消费;JSON 形状)
> 本批只做**后端端点 + 最小前端支撑(登录)**,首页/电源/进程页归批1-B。前端调用统一带 PIN 会话 token(见 §5.9)。
- **`GET /api/overview`**(首页生命线,单线程后端只暴露聚合):
```json
{ "ts": 1788268891,
"perf": { "...": "meow_svc_perf 聚合 JSON(cpu/memory/storage/tfcard/temp/system/network,透传)" },
"power": { "mode":"meow","battery":{"state","pct","mv","health","technology","temp_c10","charger_temp_c10","cycle_count","nominal_mah_note"},
"estimate":{"est_mw","confidence":"estimated|derived|measured|none","calibrated","degraded"},
"breakdown":{"baseline_mw","cpu_mw","cpu_util_pct","backlight_mw","panel_mw","brightness_pct","wifi_mw","wifi_state","bt_mw","bt_state","audio_mw","audio_state","degraded"},
"policy": null },
"audio": { "available": true, "active","app","sid","rate","ch","fmt","written_frames","played_frames","underrun","paused",
"route_mode","port","vol","rms","peak","lvlseq" },
"net": { "...": "netmgrd 概览 JSON(mode/ifaces[]/wifi/bt/rfkill/hostname_local,透传)" },
"ams": { "available": true, "count": 3, "apps":[ {"pkg","name","state":"fg|bg|stop","pid","launches","crashes","last_kind":"clean|exit|sig","last_code"} ] },
"svcd": { "available": true, "count": 3, "services":[ {"name","state":"running|stopped|stopping|disabled|failed","pid","restart","restart_str","backoff_ms","trigger","src":"board|tf","exit","exit_time","exec"} ] }
}
```
- **任一源失败 → 该子块 `{"available":false}`**(`perf`/`power`/`net` 失败也是 `{"available":false}`,绝不假数据);
`audio`/`ams`/`svcd` 成功时自带 `available:true`。`perf`/`power`/`net` 成功时为对应源**原始 JSON**(无 `available` 字段)。
- **`GET /api/power`**:即 overview 的 `power` 子块(可 503 `POWER_UNAVAILABLE`,非 `available:false`)。
- **`GET /api/ams`** / **`GET /api/svcd`**:即 overview 的 `ams`/`svcd` 子块;源不在 → `{"available":false}`。
- **`GET /api/audio`**:overview 的 `audio` 子块 + rms/peak/lvlseq 消费说明见 §5.12。
---
## 6. 与协议的出入(「实现为准 + 原因」,供回改 MEOWPANEL_PROTOCOL.md)
Binary file not shown.
+504 -114
View File
@@ -94,8 +94,13 @@ static const char *g_notif_log = NULL; /* 通知转储日志:<card>/notificat
#define LAUNCHER_SOCK "/usr/data/meow_ipc.sock" /* launcher 主 IPC(通知活跃集查询/撤清) */
#define AUDIO_SOCK "/usr/data/meow_audio.sock" /* meow_audiod 状态快照(MSG_AUDIO_QUERY→STATE) */
#define NET_SOCK "/usr/data/meow_net.sock" /* meow_netmgrd 网络管理(MSG_NET_*,期2 观测层/DNS 代理) */
#define PM_SOCK "/usr/data/meow_pm.sock" /* meow_powermgr 功耗管理(MSG_PM_QUERY→RESULT) */
#define NYAI_PATH "/usr/bin/nyai" /* 固件内置(权威) */
#define NYAI_PATH_CARD "/usr/data/mnt/sd_0/MeowOS/opt/bin/nyai" /* 旧卡上路径(回退) */
#define AMS_BIN "/usr/bin/am" /* 固件内置 amsd 用户侧 CLI(权威) */
#define AMS_BIN_CARD "/usr/data/mnt/sd_0/MeowOS/opt/bin/am" /* 旧卡上路径(回退) */
#define SVCD_BIN "/usr/bin/meow_svcd" /* 固件内置 svcd 服务表 CLI(权威) */
#define SVCD_BIN_CARD "/usr/data/mnt/sd_0/MeowOS/opt/bin/meow_svcd" /* 旧卡上路径(回退) */
#define BOOT_DEFAULT_FILE "/data/meow_boot_default"
#define VERSION_FILE "/etc/meowos_version"
#define HOSTNAME_FILE ""
@@ -127,6 +132,7 @@ static const char *g_sock_power = POWER_SOCK;
static const char *g_sock_launcher = LAUNCHER_SOCK;
static const char *g_sock_audio = AUDIO_SOCK;
static const char *g_sock_net = NET_SOCK; /* meow_netmgrd(--sock-net / env MEOW_NET_SOCK 覆盖) */
static const char *g_sock_pm = PM_SOCK; /* meow_powermgr(--sock-pm / env MEOW_PM_SOCK 覆盖) */
/* ---------------- 增量帧缓存(GET /api/screen.jpeg?delta=1,阶段 C 增强) ----------------
* 服务端缓存上一帧紧凑 RGB565,按 8×8 块比对,只编码变化块(外接 bbox 子图)并以响应头回传偏移。
@@ -239,6 +245,7 @@ static void panel_log(const char *fmt, ...);
static int qparam(const char *query, const char *key, char *out, int outcap); /* 定义在文件后部;网络连接表 query 取值 */
static int tf_present(void); /* 定义在文件后部;net 日志卡根判定 */
static const char *web_root(void); /* 定义在文件后部;此处前向声明供鉴权会话路径使用 */
static int run_cmd(const char *cmd, char *out, int outcap); /* 定义在文件后部;/api/ams、/api/svcd、/api/overview 用(有界收割) */
/* ---------------- CSV(enum 名/标签)非改写取值;定义在设置列表区,先声明供 settings_coerce 用 ---------------- */
static const char *csv_at(const char *s, int idx, char *buf, int buflen);
@@ -516,7 +523,12 @@ static const setdef_t g_sets[] = {
{ "network.bluetooth_enabled", ST_BOOL, "0", 0,0, NULL, NULL, "网络" },
/* 系统 */
{ "system.hostname", ST_STR, "meowos",0,0, NULL, NULL, "系统" },
/* 面板(MeowPanel 自身,可选鉴权) */
/* 面板(MeowPanel 自身,访问级 PIN 鉴权)
* panel.pin = 4 位数字 PIN 的 sha256+salt 哈希(`<salt_hex>:<sha256_hex(salt||pin)>`,绝不存明文)。
* 与未来 MeowOS 锁屏 PIN 同源:锁屏键 `system.pin` 预留——本后端只读面板键 panel.pin;将来锁屏启用时
* 以 system.pin 为准并做同源迁移(panel.pin 视为其缓存/别名),此处仅留兼容注记,不实现。
* panel.auth_enabled/panel.auth_pass 为旧密码机制,保留兼容迁移(见鉴权段注释),新代码以 PIN 为准。 */
{ "panel.pin", ST_STR, "", 0,0, NULL, NULL, "面板" },
{ "panel.auth_enabled", ST_BOOL, "0", 0,0, NULL, NULL, "面板" },
{ "panel.auth_pass", ST_STR, "", 0,0, NULL, NULL, "面板" },
};
@@ -945,41 +957,109 @@ static long long audio_kv_ll(const char *txt, const char *key, long long defv)
return strtoll(b, NULL, 10);
}
static int api_audio(int sock)
/* 音频快照填充:把 `{"available":...}` 追加进 sbuf(供 /api/audio 单发与 /api/overview 聚合共用)。
* audiod 不在/超时/应答异常一律 `{"available":false}`(HTTP 200,不报错)——面板/首页据此降级,绝不假数据。 */
static void audio_fill(sbuf_t *s)
{
int fd = ipc_connect_tmo(g_sock_audio, IPC_TIMEOUT_MS);
if (fd < 0) return send_json(sock, 200, "{\"available\":false}");
if (fd < 0) { sA(s, "{\"available\":false}"); return; }
uint8_t buf[MEOW_IPC_MAX];
uint16_t type = 0; uint32_t rid = 0;
int n = -1;
if (send_frame(fd, MSG_AUDIO_QUERY, 5, NULL, 0) == 0)
n = recv_frame(fd, &type, &rid, buf, sizeof buf - 1);
close(fd);
if (n <= 0 || type != MSG_AUDIO_STATE)
return send_json(sock, 200, "{\"available\":false}");
if (n <= 0 || type != MSG_AUDIO_STATE) { sA(s, "{\"available\":false}"); return; }
buf[n] = 0;
const char *txt = (const char *)buf;
char app[MEOW_APP_ID_MAX + 8], fmt[32], rmode[16];
audio_kv(txt, "app", app, sizeof app);
audio_kv(txt, "fmt", fmt, sizeof fmt);
audio_kv(txt, "route_mode", rmode, sizeof rmode);
sbuf_t s = {0};
sA(&s, "{\"available\":true,\"active\":%lld,\"app\":", audio_kv_ll(txt, "active", 0));
sA_str(&s, app);
sA(&s, ",\"sid\":%lld,\"rate\":%lld,\"ch\":%lld,\"fmt\":",
sA(s, "{\"available\":true,\"active\":%lld,\"app\":", audio_kv_ll(txt, "active", 0));
sA_str(s, app);
sA(s, ",\"sid\":%lld,\"rate\":%lld,\"ch\":%lld,\"fmt\":",
audio_kv_ll(txt, "sid", 0), audio_kv_ll(txt, "rate", 0), audio_kv_ll(txt, "ch", 0));
sA_str(&s, fmt);
sA(&s, ",\"written_frames\":%lld,\"played_frames\":%lld,\"underrun\":%lld,\"paused\":%lld,\"route_mode\":",
sA_str(s, fmt);
sA(s, ",\"written_frames\":%lld,\"played_frames\":%lld,\"underrun\":%lld,\"paused\":%lld,\"route_mode\":",
audio_kv_ll(txt, "written", 0), audio_kv_ll(txt, "played", 0),
audio_kv_ll(txt, "underrun", 0), audio_kv_ll(txt, "paused", 0));
sA_str(&s, rmode);
sA(&s, ",\"port\":%lld,\"vol\":%lld}",
sA_str(s, rmode);
sA(s, ",\"port\":%lld,\"vol\":%lld",
audio_kv_ll(txt, "port", -1), audio_kv_ll(txt, "vol", -1));
int rc = send_json(sock, 200, s.b ? s.b : "");
/* 电平监控(261):rms/peak deci-dB(-990=-inf),lvlseq=快照代际;透传时别漏(ABI 追加键)。 */
sA(s, ",\"rms\":%lld,\"peak\":%lld,\"lvlseq\":%lld}",
audio_kv_ll(txt, "rms", -990), audio_kv_ll(txt, "peak", -990), audio_kv_ll(txt, "lvlseq", 0));
}
static int api_audio(int sock)
{
sbuf_t s = {0};
audio_fill(&s);
int rc = send_json(sock, 200, s.b ? s.b : "{\"available\":false}");
sbuf_free(&s);
return rc;
}
/* ---------------- 功耗管理:meow_powermgr IPC(MSG_PM_QUERY→MSG_PM_RESULT) ----------------
* 客户端序列同 perf/power:connect → MSG_HELLO → 读 ACK → MSG_PM_QUERY(subset) → MSG_PM_RESULT(JSON)。
* powermgr 不在/超时/应答异常 → 负错误码(调用方转 503 POWER_UNAVAILABLE;overview 里转 available:false)。 */
static int ipc_pm_query(uint32_t subset, char *out, int cap)
{
int fd = ipc_connect_tmo(g_sock_pm, IPC_TIMEOUT_MS);
if (fd < 0) return MEOW_ERR_NODEV;
int rc = MEOW_ERR_INTERNAL;
if (ipc_hello(fd) < 0) { rc = MEOW_ERR_INTERNAL; goto done; }
m_pm_query_t q;
memset(&q, 0, sizeof q);
q.subset = subset;
if (send_frame(fd, MSG_PM_QUERY, 0x31, &q, sizeof q) < 0) { rc = MEOW_ERR_IO; goto done; }
uint8_t buf[MEOW_IPC_MAX];
uint16_t type; uint32_t rid;
int n = recv_frame(fd, &type, &rid, buf, sizeof buf);
if (n < 12) { rc = MEOW_ERR_IO; goto done; } /* code(4)+subset(4)+json_len(2)+pad(2) */
if (type != MSG_PM_RESULT) { rc = MEOW_ERR_INVAL; goto done; }
uint32_t c; memcpy(&c, buf, 4);
if (c != MEOW_OK) { rc = (int)c; goto done; }
/* powermgr 发**紧凑**负载:json 从偏移 12 起(json_len 在偏移 8);同 m_pm_result_t 布局,兼容全量/紧凑。 */
uint16_t jl; memcpy(&jl, buf + 8, 2);
if (jl > MEOW_IPC_MAX - 12) jl = (uint16_t)(MEOW_IPC_MAX - 12);
if ((size_t)jl >= (size_t)cap) jl = (uint16_t)(cap - 1);
memcpy(out, buf + 12, jl);
out[jl] = 0;
rc = MEOW_OK;
done:
close(fd);
return rc;
}
/* GET /api/power → powermgr 聚合 JSON(battery/estimate/breakdown,confidence 原样透传)。 */
static int api_power(int sock)
{
char js[MEOW_IPC_MAX];
int rc = ipc_pm_query(0, js, sizeof js);
if (rc != MEOW_OK)
return send_err(sock, 503, "POWER_UNAVAILABLE", "电源管理服务不可用");
return send_json(sock, 200, js);
}
/* ---- am/meow_svcd 二进制解析(popen 薄壳,复用有界收割 run_cmd) ----
* env MEOW_AMS_BIN / MEOW_SVCD_BIN 可覆盖(宿主/qemu 测试用;设备默认 /usr/bin,回退卡上路径)。 */
static const char *ams_bin(void)
{
const char *e = getenv("MEOW_AMS_BIN");
if (e && e[0]) return e;
if (!access(AMS_BIN, X_OK)) return AMS_BIN;
return AMS_BIN_CARD;
}
static const char *svcd_bin(void)
{
const char *e = getenv("MEOW_SVCD_BIN");
if (e && e[0]) return e;
if (!access(SVCD_BIN, X_OK)) return SVCD_BIN;
return SVCD_BIN_CARD;
}
/* ---------------- 网络管理:meow_netmgrd IPC(MSG_NET_*,期2 观测层/DNS 代理) ----------------
* 收口于独立 socket /usr/data/meow_net.sock(--sock-net / env MEOW_NET_SOCK 覆盖,宿主测试用)。
* 读级(STATUS/CONNS/DNS status|stats)netmgrd 放行任意调用者;flush 写级——HTTP 层沿用面板
@@ -1216,6 +1296,199 @@ static int api_net_log(int sock)
return rc;
}
/* ---------------- amsd / svcd 服务表 + 首页聚合(批1 只读) ---------------- */
/* 就地去掉 ANSI 转义序列(\033[...final);svcd status all 走管道时 CLI 会去色,此处双保险)。 */
static void panel_strip_ansi(char *s)
{
char *r = s, *w = s;
while (*r) {
if (*r == '\033' && r[1] == '[') {
r += 2;
while (*r && !(*r >= '@' && *r <= '~')) r++;
if (*r) r++;
} else { *w++ = *r++; }
}
*w = 0;
}
/* 切分成空白分隔 token(就地改 buf,返回个数;<maxtoks)。 */
static int txt_tokens(char *buf, char **toks, int maxtoks)
{
int n = 0;
char *p = buf;
while (*p && n < maxtoks) {
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
if (!*p) break;
toks[n++] = p;
while (*p && *p != ' ' && *p != '\t' && *p != '\n' && *p != '\r') p++;
if (*p) *p++ = 0;
}
return n;
}
/* join toks[from..to) 空格连接进 out(exec/name 可能含空格)。 */
static void join_tokens(char **toks, int from, int to, char *out, size_t cap)
{
size_t o = 0;
for (int i = from; i < to; i++) {
size_t need = strlen(toks[i]) + (o ? 1 : 0);
if (o + need >= cap) { out[cap - 1] = 0; return; }
if (o) out[o++] = ' ';
memcpy(out + o, toks[i], strlen(toks[i]));
o += strlen(toks[i]);
}
out[o] = 0;
}
/* GET /api/ams:`am list`(注册表+状态)parse → App 表。amsd 不在 → available:false(绝无假数据)。 */
static void ams_fill(sbuf_t *s)
{
char out[16384];
char cmd[300];
snprintf(cmd, sizeof cmd, "%s list 2>/dev/null", ams_bin());
int code = run_cmd(cmd, out, sizeof out);
if (code != 0 || !strstr(out, "APP")) { sA(s, "{\"available\":false}"); return; }
panel_strip_ansi(out);
char *toks[256];
sbuf_t apps = {0};
int first = 1, count = 0;
char *ln = out;
while (*ln) {
char *nl = strchr(ln, '\n');
if (nl) *nl = 0;
if (strncmp(ln, "APP ", 4) == 0) {
int n = txt_tokens(ln, toks, 256);
if (n >= 9) {
/* 布局:APP <pkg> <name...> <state> <pid> <launches> <crashes> <last_kind> <last_code>
* tok[0]="APP" 前缀;自右向左固定:state=tok[n-6], pid=n-5, launches=n-4, crashes=n-3,
* last_kind=n-2, last_code=n-1;pkg=tok[1],name=join(tok[2..n-6))。 */
char nm[128];
join_tokens(toks, 2, n - 6, nm, sizeof nm);
if (!first) sA(&apps, ",");
first = 0; count++;
sA(&apps, "{\"pkg\":"); sA_str(&apps, toks[1]);
sA(&apps, ",\"name\":"); sA_str(&apps, nm);
sA(&apps, ",\"state\":"); sA_str(&apps, toks[n-6]);
sA(&apps, ",\"pid\":%d", atoi(toks[n-5]));
sA(&apps, ",\"launches\":%d", atoi(toks[n-4]));
sA(&apps, ",\"crashes\":%d", atoi(toks[n-3]));
sA(&apps, ",\"last_kind\":"); sA_str(&apps, toks[n-2]);
sA(&apps, ",\"last_code\":%d}", atoi(toks[n-1]));
}
}
if (nl) { *nl = '\n'; ln = nl + 1; } else break;
}
sA(s, "{\"available\":true,\"count\":%d,\"apps\":[%s]}", count, apps.b ? apps.b : "");
sbuf_free(&apps);
}
static int api_ams(int sock)
{
sbuf_t s = {0};
ams_fill(&s);
int rc = send_json(sock, 200, s.b ? s.b : "{\"available\":false}");
sbuf_free(&s);
return rc;
}
/* GET /api/svcd:`meow_svcd status all` parse → 服务表(state/pid/restart 计数/exec/来源)。
* svcd 未运行(exit!=0)→ available:false(绝无假数据)。 */
static void svcd_fill(sbuf_t *s)
{
char out[16384];
char cmd[300];
snprintf(cmd, sizeof cmd, "%s status all 2>/dev/null", svcd_bin());
int code = run_cmd(cmd, out, sizeof out);
if (code != 0) { sA(s, "{\"available\":false}"); return; }
panel_strip_ansi(out);
char *toks[256];
sbuf_t svcs = {0};
int first = 1, count = 0;
char *ln = out;
while (*ln) {
char *nl = strchr(ln, '\n');
if (nl) *nl = 0;
int n = txt_tokens(ln, toks, 256);
/* 数据行:NAME STATE PID RESTART BACKOFF TRIGGER SRC EXIT EXIT_TIME EXEC...
* state=tok[1] 须为合法状态;exec=join(tok[9..n))。 */
if (n >= 10) {
const char *st = toks[1];
int is_known = (!strcmp(st,"running") || !strcmp(st,"stopped") ||
!strcmp(st,"stopping") || !strcmp(st,"disabled") || !strcmp(st,"failed"));
if (is_known) {
char exec[512];
join_tokens(toks, 9, n, exec, sizeof exec);
int restarts = 0;
{ const char *slash = strchr(toks[3], '/'); (void)slash; restarts = atoi(toks[3]); }
if (!first) sA(&svcs, ",");
first = 0; count++;
sA(&svcs, "{\"name\":"); sA_str(&svcs, toks[0]);
sA(&svcs, ",\"state\":"); sA_str(&svcs, toks[1]);
sA(&svcs, ",\"pid\":%d", atoi(toks[2]));
sA(&svcs, ",\"restart\":%d", restarts);
sA(&svcs, ",\"restart_str\":");
sA_str(&svcs, toks[3]);
sA(&svcs, ",\"backoff_ms\":%d", atoi(toks[4]));
sA(&svcs, ",\"trigger\":");
sA_str(&svcs, toks[5]);
sA(&svcs, ",\"src\":");
sA_str(&svcs, toks[6]);
sA(&svcs, ",\"exit\":");
sA_str(&svcs, toks[7]);
sA(&svcs, ",\"exit_time\":");
sA_str(&svcs, toks[8]);
sA(&svcs, ",\"exec\":");
sA_str(&svcs, exec);
sA(&svcs, "}");
}
}
if (nl) { *nl = '\n'; ln = nl + 1; } else break;
}
sA(s, "{\"available\":true,\"count\":%d,\"services\":[%s]}", count, svcs.b ? svcs.b : "");
sbuf_free(&svcs);
}
static int api_svcd(int sock)
{
sbuf_t s = {0};
svcd_fill(&s);
int rc = send_json(sock, 200, s.b ? s.b : "{\"available\":false}");
sbuf_free(&s);
return rc;
}
/* GET /api/overview:首页生命线。**单线程后端只暴露聚合**(不放六个单测端点给首页轮询)。
* 后端顺序 fan-out perf/power/audio/net/amsd/svcd 六源合成一份 JSON;任一源失败 → 该子块 `available:false`,
* 绝不假数据(对齐 netmgrd 503 降级语义)。 */
static int api_overview(int sock)
{
sbuf_t s = {0};
sA(&s, "{\"ts\":%lld,", (long long)time(NULL));
char pb[4096];
if (ipc_perf_query(0, pb, sizeof pb) == MEOW_OK) sA(&s, "\"perf\":%s,", pb);
else sA(&s, "\"perf\":{\"available\":false},");
char pw[MEOW_IPC_MAX];
if (ipc_pm_query(0, pw, sizeof pw) == MEOW_OK) sA(&s, "\"power\":%s,", pw);
else sA(&s, "\"power\":{\"available\":false},");
sA(&s, "\"audio\":");
audio_fill(&s);
uint16_t nt; uint32_t ncode; char njson[MEOW_IPC_MAX];
int nn = net_ipc(MSG_NET_STATUS_REQ, NULL, 0, &nt, &ncode, NULL, njson, sizeof njson);
if (nn > 0 && nt == MSG_NET_STATUS_RESULT && ncode == MEOW_OK && njson[0])
sA(&s, ",\"net\":%s", njson);
else
sA(&s, ",\"net\":{\"available\":false}");
sA(&s, ",\"ams\":");
ams_fill(&s);
sA(&s, ",\"svcd\":");
svcd_fill(&s);
sA(&s, "}");
int rc = send_json(sock, 200, s.b ? s.b : "{}");
sbuf_free(&s);
return rc;
}
/* ---------------- 路径安全 ---------------- */
static int tf_present(void)
{
@@ -1828,26 +2101,27 @@ static int read_request(int sock, req_t *r)
return 0;
}
/* ================= 可选鉴权(panel.auth_enabled / panel.auth_pass) =================
* 设计(MEOWPANEL_PROTOCOL §1.3 落地):
* - 键 `panel.auth_enabled`(默认 0=关)开关;`panel.auth_pass` 存「sha256 带盐哈希」格式 `salt:hex`,
* 绝不落明文,也绝不进日志/响应。
* - 启用后,除 /api/auth/{login,logout,status} 外,所有 /api/ 端点需有效会话
* (`Authorization: Bearer <token>` / `X-MeowToken: <token>` / `Cookie: meow_token=<token>`)。
* - 会话 token 由 /dev/urandom 生成(32B → 64 hex),存内存 + 卡上文件持久化(防重启失效),带过期。
* - 未启用(默认)时鉴权门禁直接放行,行为与现状完全一致(零回归);危险端点不额外限制。
/* ================= 访问级 PIN 鉴权(PANEL_ENHANCE_PLAN 用户定案 2026-09-01) =================
* 模型(用户二次定案,覆盖 282「默认无鉴权」前提):
* - **访问面板即需登录**:未认证请求一律 401(静态登录页自身 + css/js 放行——serve_static 先于本门禁)。
* - 4 位数字 PIN,设置键 `panel.pin`:sha256+salt 存 settings.conf(格式 `<salt_hex>:<sha256_hex(salt||pin)>`,
* auth_make_hash 生成)。与未来 MeowOS 锁屏 PIN 同源:设置键 `system.pin` 预留(本后端只读面板键,见 g_sets 注记)。
* - token 随机 **128bit**(/dev/urandom 16B → 32 hex),**只存面板进程内存**(`g_sess[]`,重启自然失效,无卡上持久化)。
* - 会话有效期 **3 天**(AUTH_TOKEN_TTL,**固定不滑动续期**)或设备重启(内存清、先到为准);多会话并存。
* - **未设 PIN**:首次访问走 `POST /api/auth/setup` 引导设置;未设 PIN 前无法建立任何会话,故全部
* `/api/` 路径被拦(读端点也按「访问级」全拦——定案即全拦,登录/设置流程除外),写端点自然一律拒绝。
* - **错误锁定**:连续 PIN_MAX_FAILS=5 次错 PIN 锁 PIN_LOCK_SEC=300s(内存态,重启清)。
* - **旧密码机制兼容迁移**(`panel.auth_enabled`/`panel.auth_pass`):旧密码存在且未设 PIN 时,
* 视为「已设访问屏障」的同等物——登录暂可用旧密码(body `password` 字段),status 透出 `legacy:true`
* 提示改设 4 位 PIN;一旦设置 `panel.pin`,**新代码以 PIN 为准**(登录只认 PIN,旧密码不再作为凭据)。
*/
#define AUTH_TOKEN_TTL (12 * 3600) /* 会话有效期:12 小时 */
#define AUTH_MAX_SESS 8 /* 内存最多保存的并发会话数 */
#define AUTH_HASH_LEN 64 /* sha256 hex 长度 */
static char g_auth_session_file[600];
static const char *auth_session_file(void)
{
if (!g_auth_session_file[0])
snprintf(g_auth_session_file, sizeof g_auth_session_file, "%s/meowpanel.session", web_root());
return g_auth_session_file;
}
#define AUTH_TOKEN_BYTES 16 /* 128bit → 32 hex */
#define AUTH_TOKEN_TTL (3 * 86400) /* 3 天(固定,不滑动续期) */
#define AUTH_MAX_SESS 8 /* 内存最多保存的并发会话数 */
#define AUTH_HASH_LEN 64 /* sha256 hex 长度 */
#define PIN_MAX_FAILS 5 /* 连续错 PIN 锁定阈值 */
#define PIN_LOCK_SEC 300 /* 锁定 5 分钟 */
static time_t g_auth_ttl = AUTH_TOKEN_TTL; /* 会话 TTL(env MEOW_AUTH_TTL 可覆盖,宿主测试用) */
/* ---- 最小 SHA-256(FIPS 180-4,公有领域参考写法;内联拷贝自 meow_verify/sha256.c,前缀 mp_ 避免符号冲突) ---- */
static const uint32_t mp_sha256_k[64] = {
@@ -1973,10 +2247,10 @@ static void auth_hex(const unsigned char *raw, size_t n, char *out)
out[n*2] = 0;
}
static int auth_token_gen(char *out, int outcap) /* 32B → 64 hex */
static int auth_token_gen(char *out, int outcap) /* 16B → 32 hex(128bit) */
{
unsigned char raw[32];
if (outcap < 65) return -1;
unsigned char raw[AUTH_TOKEN_BYTES];
if (outcap < AUTH_TOKEN_BYTES * 2 + 1) return -1;
if (auth_read_random(raw, sizeof raw) < 0) return -1;
auth_hex(raw, sizeof raw, out);
return 0;
@@ -2013,12 +2287,32 @@ static int auth_make_hash(const char *password, char *out, int outcap) /* salt:
return 0;
}
static int auth_enabled(void)
/* ---- PIN / 旧密码 凭据校验(都走「salt:hex」哈希校验,恒定比较) ---- */
/* 校验 `salt:hex` 哈希是否匹配明文(恒定比较,不反解)。stored 为 `salt:hex` 完整串。 */
static int hash_matches(const char *stored, const char *password)
{
return settings_get_int("panel.auth_enabled", 0) != 0;
if (!stored || !stored[0] || !password) return 0;
char tmp[160];
snprintf(tmp, sizeof tmp, "%s", stored);
char *colon = strchr(tmp, ':');
if (!colon) return 0;
*colon = 0;
char want[AUTH_HASH_LEN + 1];
if (auth_pass_hash(tmp, password, want, sizeof want) != 0) return 0;
if (strlen(colon + 1) != AUTH_HASH_LEN) return 0;
return memcmp(want, colon + 1, AUTH_HASH_LEN) == 0;
}
static int auth_pass_set(void)
static int pin_set(void) /* panel.pin 是否已设(访问屏障开启) */
{
char *v = settings_get_value("panel.pin");
int s = (v && v[0]) ? 1 : 0;
free(v);
return s;
}
static int auth_pass_set(void) /* 旧密码是否已设(兼容迁移用) */
{
char *v = settings_get_value("panel.auth_pass");
int s = (v && v[0]) ? 1 : 0;
@@ -2026,25 +2320,57 @@ static int auth_pass_set(void)
return s;
}
static int auth_verify(const char *password)
static int auth_verify(const char *password) /* 旧密码校验(迁移期;见 login) */
{
char *stored = settings_get_value("panel.auth_pass");
int ok = 0;
if (stored && stored[0]) {
char *colon = strchr(stored, ':');
if (colon) {
*colon = 0;
char want[AUTH_HASH_LEN + 1];
if (auth_pass_hash(stored, password, want, sizeof want) == 0 && strlen(colon + 1) == AUTH_HASH_LEN
&& strcmp(want, colon + 1) == 0)
ok = 1;
}
}
int ok = hash_matches(stored, password);
free(stored);
return ok;
}
/* ---- 会话(内存 + 卡上持久化) ---- */
static int pin_verify(const char *pin) /* PIN 校验 */
{
char *stored = settings_get_value("panel.pin");
int ok = hash_matches(stored, pin);
free(stored);
return ok;
}
/* PIN 合法:恰好 4 位数字(0-9)。 */
static int pin_validate(const char *pin)
{
if (!pin) return 0;
size_t l = strlen(pin);
if (l != 4) return 0;
for (size_t i = 0; i < l; i++)
if (pin[i] < '0' || pin[i] > '9') return 0;
return 1;
}
/* ---- 错误锁定(内存态,重启清;4 位 PIN 防爆破必备) ---- */
static int g_pin_fails = 0;
static time_t g_pin_locked_until = 0;
static long long pin_lock_remaining(void) /* 剩余锁定秒;0=未锁 */
{
time_t now = time(NULL);
if (g_pin_locked_until <= now) return 0;
return (long long)(g_pin_locked_until - now);
}
static void pin_lock_reset(void)
{
g_pin_fails = 0;
g_pin_locked_until = 0;
}
static void pin_record_fail(void)
{
if (++g_pin_fails >= PIN_MAX_FAILS) {
g_pin_locked_until = time(NULL) + PIN_LOCK_SEC;
g_pin_fails = 0;
}
}
/* ---- 会话(**纯内存**:重启自然失效,无卡上持久化) ---- */
typedef struct { char token[65]; time_t expires; } auth_sess_t;
static auth_sess_t g_sess[AUTH_MAX_SESS];
static int g_sess_n = 0;
@@ -2090,38 +2416,6 @@ static void sess_remove(const char *token)
g_sess_n--;
}
static void sess_save(void)
{
FILE *f = fopen(auth_session_file(), "w");
if (!f) return;
time_t now = time(NULL);
for (int i = 0; i < g_sess_n; i++) {
if (g_sess[i].expires > 0 && g_sess[i].expires <= now) continue;
fprintf(f, "%s %lld\n", g_sess[i].token, (long long)g_sess[i].expires);
}
fclose(f);
}
static void sess_load(void)
{
FILE *f = fopen(auth_session_file(), "r");
if (!f) return;
char line[128];
time_t now = time(NULL);
g_sess_n = 0;
while (g_sess_n < AUTH_MAX_SESS && fgets(line, sizeof line, f)) {
char tok[65];
long long exp = 0;
if (sscanf(line, "%64s %lld", tok, &exp) >= 1) {
if (exp > 0 && exp <= (long long)now) continue; /* 已过期 */
snprintf(g_sess[g_sess_n].token, sizeof g_sess[g_sess_n].token, "%s", tok);
g_sess[g_sess_n].expires = (time_t)exp;
g_sess_n++;
}
}
fclose(f);
}
static void auth_cp(char *dst, int dcap, const char *src) /* 有界拷贝(防 -Wformat-truncation) */
{
size_t l = strlen(src);
@@ -2155,48 +2449,84 @@ static int extract_token(const req_t *r, char *out, int outcap)
return 0;
}
/* ---- 鉴权门禁:启用后除 /api/auth 系列外全部 /api/ 端点需会话 ---- */
/* ---- 鉴权门禁:**访问面板即需登录**——除 /api/auth 系列外,全部 /api/ 端点需有效会话。
* 未设 PIN 时无法建立会话 → 读端点也一律 401(「全拦」定案),写端点自然一律拒绝;登录/设置流程除外。 ---- */
static int require_auth(int sock, const req_t *r)
{
if (!auth_enabled()) return 1; /* 默认关:零回归 */
const char *p = r->path;
if (!strcmp(p, "/api/auth/login") || !strcmp(p, "/api/auth/logout") || !strcmp(p, "/api/auth/status")) return 1;
if (!strcmp(p, "/api/auth/login") || !strcmp(p, "/api/auth/logout") ||
!strcmp(p, "/api/auth/status") || !strcmp(p, "/api/auth/setup")) return 1;
char t[65];
if (extract_token(r, t, sizeof t) && t[0] && sess_valid(t)) return 1;
return send_err(sock, 401, "UNAUTHORIZED", "未认证或会话已失效");
}
/* GET /api/auth/status(公开):访问屏障状态 + 当前会话 + 锁定信息。 */
static int api_auth_status(int sock, const req_t *r)
{
int en = auth_enabled();
int ps = pin_set();
int lg = auth_pass_set();
int sess = 0;
if (en) {
char t[65];
if (extract_token(r, t, sizeof t) && t[0]) sess = sess_valid(t);
}
char t[65];
if (extract_token(r, t, sizeof t) && t[0]) sess = sess_valid(t);
long long rem = pin_lock_remaining();
sbuf_t s = {0};
sA(&s, "{\"auth_enabled\":%s,\"session\":%s}", en ? "true" : "false", sess ? "true" : "false");
sA(&s, "{\"auth_enabled\":true,\"session\":%s,\"pin_set\":%s,\"legacy\":%s,\"locked\":%s",
sess ? "true" : "false", ps ? "true" : "false", lg ? "true" : "false", rem > 0 ? "true" : "false");
if (rem > 0) sA(&s, ",\"lock_remaining_ms\":%lld", rem * 1000LL);
sA(&s, "}");
int rc = send_json(sock, 200, s.b ? s.b : "{}");
sbuf_free(&s);
return rc;
}
/* 登录统一入口:PIN(panel.pin 已设,权威)或旧密码(仅未设 PIN 时;迁移期)。
* 连续 PIN_MAX_FAILS 次错 → 锁 PIN_LOCK_SEC(429 LOCKED)。 */
static int api_auth_login(int sock, const req_t *r)
{
if (!auth_enabled()) return send_err(sock, 400, "BAD_REQUEST", "面板鉴权未启用");
if (!r->body || !r->body[0]) return send_err(sock, 400, "BAD_REQUEST", "缺少密码");
if (!r->body || !r->body[0]) return send_err(sock, 400, "BAD_REQUEST", "缺少 PIN");
jv_t *root = jv_parse(r->body, (int)r->body_len);
if (!root) return send_err(sock, 400, "BAD_REQUEST", "body 非合法 JSON");
jv_t *pw = jv_obj_get(root, "password");
if (!pw || pw->type != J_STR) { jv_free(root); return send_err(sock, 400, "BAD_REQUEST", "缺少 password 字段"); }
int ok = auth_verify(pw->str);
jv_t *jpin = jv_obj_get(root, "pin");
jv_t *jpw = jv_obj_get(root, "password");
char pin[8], pw[256];
if (jpin && jpin->type == J_STR && jpin->str) snprintf(pin, sizeof pin, "%s", jpin->str); else pin[0] = 0;
if (jpw && jpw->type == J_STR && jpw->str) snprintf(pw, sizeof pw, "%s", jpw->str); else pw[0] = 0;
jv_free(root);
if (!ok) return send_err(sock, 401, "UNAUTHORIZED", "密码错误");
if (pin_lock_remaining() > 0) { /* 已在锁定期 */
sbuf_t e = {0};
sA(&e, "{\"ok\":false,\"error\":{\"code\":\"LOCKED\",\"message\":\"已锁定,请稍后重试\"},\"lock_remaining_ms\":%lld}",
pin_lock_remaining() * 1000LL);
int rc = send_json(sock, 429, e.b ? e.b : "");
sbuf_free(&e);
return rc;
}
int use_legacy = 0;
const char *cred = NULL;
if (pin_set()) { cred = pin; if (!cred || !cred[0]) return send_err(sock, 400, "BAD_REQUEST", "缺少 pin 字段"); }
else if (auth_pass_set()) { use_legacy = 1; cred = pw; if (!cred || !cred[0]) return send_err(sock, 400, "BAD_REQUEST", "缺少 password 字段"); }
else return send_err(sock, 400, "BAD_REQUEST", "请先设置面板 PIN(/api/auth/setup)");
int ok = use_legacy ? auth_verify(cred) : pin_verify(cred);
if (!ok) {
pin_record_fail();
if (pin_lock_remaining() > 0) { /* 本错已触发锁定 */
sbuf_t e = {0};
sA(&e, "{\"ok\":false,\"error\":{\"code\":\"LOCKED\",\"message\":\"连续错误次数过多,已锁定 5 分钟\"},\"lock_remaining_ms\":%lld}",
pin_lock_remaining() * 1000LL);
int rc = send_json(sock, 429, e.b ? e.b : "");
sbuf_free(&e);
return rc;
}
return send_err(sock, 401, "UNAUTHORIZED", "PIN 错误");
}
pin_lock_reset();
char token[65];
if (auth_token_gen(token, sizeof token) < 0) return send_err(sock, 500, "INTERNAL", "生成会话失败");
time_t exp = time(NULL) + AUTH_TOKEN_TTL;
time_t exp = time(NULL) + g_auth_ttl; /* 固定 3 天(MEOW_AUTH_TTL 可覆盖测试用),不滑动续期 */
sess_add(token, exp);
sess_save();
sbuf_t s = {0};
sA(&s, "{\"token\":");
sA_str(&s, token);
@@ -2206,10 +2536,31 @@ static int api_auth_login(int sock, const req_t *r)
return rc;
}
/* POST /api/auth/setup(公开):首次设置 4 位 PIN(仅未设 PIN 时可用;设置后走 login)。
* 未设 PIN 前写端点一律拒绝;本端点即「设置流程」例外,用于引导建立首个访问屏障。 */
static int api_auth_setup(int sock, const req_t *r)
{
if (pin_set()) return send_err(sock, 409, "CONFLICT", "面板 PIN 已设置,请直接登录");
if (!r->body || !r->body[0]) return send_err(sock, 400, "BAD_REQUEST", "缺少 PIN");
jv_t *root = jv_parse(r->body, (int)r->body_len);
if (!root) return send_err(sock, 400, "BAD_REQUEST", "body 非合法 JSON");
jv_t *jpin = jv_obj_get(root, "pin");
char pin[8];
if (jpin && jpin->type == J_STR && jpin->str) snprintf(pin, sizeof pin, "%s", jpin->str);
else pin[0] = 0;
jv_free(root);
if (!pin_validate(pin)) return send_err(sock, 422, "UNSUPPORTED", "PIN 必须为 4 位数字");
char h[200];
if (auth_make_hash(pin, h, sizeof h) < 0) return send_err(sock, 500, "INTERNAL", "生成 PIN 哈希失败");
if (settings_write_one("panel.pin", h) < 0) return send_err(sock, 500, "INTERNAL", "写设置失败");
pin_lock_reset();
return send_json(sock, 200, "{\"pin_set\":true}");
}
static int api_auth_logout(int sock, const req_t *r)
{
char t[65];
if (extract_token(r, t, sizeof t) && t[0]) { sess_remove(t); sess_save(); }
if (extract_token(r, t, sizeof t) && t[0]) sess_remove(t);
return send_json(sock, 200, "{\"logged_out\":true}");
}
@@ -2554,8 +2905,8 @@ static void settings_item_json(sbuf_t *s, const setdef_t *d, const char *val)
} else if (d->type == ST_BOOL) {
sA(s, "%s", str_truthy(val) ? "true" : "false");
} else {
if (!strcmp(d->key, "panel.auth_pass")) {
/* 密码哈希绝不出现在响应里:value 恒空串,附 set 标志(是否已设密码) */
if (!strcmp(d->key, "panel.auth_pass") || !strcmp(d->key, "panel.pin")) {
/* 密码/PIN 哈希绝不出现在响应里:value 恒空串,附 set 标志(是否已设) */
sA(s, "\"\",\"set\":%s", val[0] ? "true" : "false");
} else {
sA_str(s, val);
@@ -2641,6 +2992,23 @@ static int api_settings_put(int sock, const char *key, const req_t *r)
}
jv_free(root);
char hashbuf[200];
/* panel.pin:4 位数字 PIN,只落带盐哈希;空串=清除(回未设状态);非法(非 4 位数字)→ 422 */
if (!strcmp(key, "panel.pin")) {
if (raw[0]) {
if (!pin_validate(raw)) return send_err(sock, 422, "UNSUPPORTED", "PIN 必须为 4 位数字");
if (auth_make_hash(raw, hashbuf, sizeof hashbuf) < 0)
return send_err(sock, 500, "INTERNAL", "生成 PIN 哈希失败");
} else {
hashbuf[0] = 0; /* 清空 → 回未设状态 */
}
if (settings_write_one(key, hashbuf) < 0) return send_err(sock, 500, "INTERNAL", "写设置失败");
pin_lock_reset();
sbuf_t s = {0};
settings_item_json(&s, d, hashbuf);
int rc = send_json(sock, 200, s.b ? s.b : "");
sbuf_free(&s);
return rc;
}
/* panel.auth_pass:只落带盐哈希,绝不存明文;空串=清除密码 */
if (!strcmp(key, "panel.auth_pass")) {
if (raw[0]) {
@@ -2656,13 +3024,13 @@ static int api_settings_put(int sock, const char *key, const req_t *r)
sbuf_free(&s);
return rc;
}
/* panel.auth_enabled:启用前必须先设密码 */
/* panel.auth_enabled(旧机制,兼容迁移):置 1 前须已有访问屏障(PIN 或旧密码) */
if (!strcmp(key, "panel.auth_enabled")) {
char coerced[256];
if (settings_coerce(d, raw, coerced, sizeof coerced) < 0)
return send_err(sock, 422, "UNSUPPORTED", "值域/枚举非法");
if (!strcmp(coerced, "1") && !auth_pass_set())
return send_err(sock, 422, "UNSUPPORTED", "请先设置面板密码");
if (!strcmp(coerced, "1") && !pin_set() && !auth_pass_set())
return send_err(sock, 422, "UNSUPPORTED", "请先设置面板 PIN 或密码");
if (settings_write_one(key, coerced) < 0) return send_err(sock, 500, "INTERNAL", "写设置失败");
sbuf_t s = {0};
settings_item_json(&s, d, coerced);
@@ -2706,6 +3074,17 @@ static int api_settings_batch(int sock, const req_t *r)
case J_BOOL: snprintf(raw, sizeof raw, "%d", val->bval ? 1 : 0); break;
default: if (nfail) sA(&fail, ","); sA(&fail, "{\"key\":"); sA_str(&fail, p->str); sA(&fail, ",\"code\":\"BAD_REQUEST\",\"message\":\"非法值类型\"}"); nfail++; continue;
}
/* panel.pin:只落带盐哈希(空=清除);非法(非 4 位数字)→ 422 */
if (!strcmp(p->str, "panel.pin")) {
char hashbuf[200];
if (raw[0]) {
if (!pin_validate(raw)) { if (nfail) sA(&fail, ","); sA(&fail, "{\"key\":"); sA_str(&fail, p->str); sA(&fail, ",\"code\":\"UNSUPPORTED\",\"message\":\"PIN 必须为 4 位数字\"}"); nfail++; continue; }
if (auth_make_hash(raw, hashbuf, sizeof hashbuf) < 0) { if (nfail) sA(&fail, ","); sA(&fail, "{\"key\":"); sA_str(&fail, p->str); sA(&fail, ",\"code\":\"INTERNAL\",\"message\":\"生成 PIN 哈希失败\"}"); nfail++; continue; }
} else hashbuf[0] = 0;
if (settings_write_one(p->str, hashbuf) == 0) { if (nok) sA(&ok, ","); sA_str(&ok, p->str); nok++; }
else { if (nfail) sA(&fail, ","); sA(&fail, "{\"key\":"); sA_str(&fail, p->str); sA(&fail, ",\"code\":\"INTERNAL\",\"message\":\"写失败\"}"); nfail++; }
continue;
}
/* panel.auth_pass:只落带盐哈希(空=清除);panel.auth_enabled:启用前须已有密码 */
if (!strcmp(p->str, "panel.auth_pass")) {
char hashbuf[200];
@@ -2722,10 +3101,10 @@ static int api_settings_batch(int sock, const req_t *r)
nfail++;
continue;
}
/* panel.auth_enabled:启用前必须先设密码 */
if (!strcmp(p->str, "panel.auth_enabled") && !strcmp(coerced, "1") && !auth_pass_set()) {
/* panel.auth_enabled(迁移):启用前须已有访问屏障(PIN 或旧密码) */
if (!strcmp(p->str, "panel.auth_enabled") && !strcmp(coerced, "1") && !pin_set() && !auth_pass_set()) {
if (nfail) sA(&fail, ",");
sA(&fail, "{\"key\":"); sA_str(&fail, p->str); sA(&fail, ",\"code\":\"UNSUPPORTED\",\"message\":\"请先设置面板密码\"}");
sA(&fail, "{\"key\":"); sA_str(&fail, p->str); sA(&fail, ",\"code\":\"UNSUPPORTED\",\"message\":\"请先设置面板 PIN 或密码\"}");
nfail++;
continue;
}
@@ -4677,8 +5056,9 @@ static int handle(int sock, req_t *r)
/* ---- 可选鉴权门禁(启用后除 /api/auth 系列外全 /api/ 端点需会话) ---- */
if (require_auth(sock, r) != 1) return 0;
/* ---- 鉴权端点 ---- */
/* ---- 鉴权端点(登录/设置/登出/状态:公开——访问面板即需登录,须先经此建立会话) ---- */
if (!strcmp(r->method, "POST") && !strcmp(p, "/api/auth/login")) return api_auth_login(sock, r);
if (!strcmp(r->method, "POST") && !strcmp(p, "/api/auth/setup")) return api_auth_setup(sock, r);
if (!strcmp(r->method, "POST") && !strcmp(p, "/api/auth/logout")) return api_auth_logout(sock, r);
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/auth/status")) return api_auth_status(sock, r);
@@ -4693,9 +5073,15 @@ static int handle(int sock, req_t *r)
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/fonts")) return api_fonts_list(sock);
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/fonts/export")) return api_fonts_export(sock, r);
/* ---- 音频状态(meow_audiod 快照,只读) ---- */
/* ---- 音频状态(meow_audiod 快照,只读;含 rms/peak/lvlseq 电平键) ---- */
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/audio")) return api_audio(sock);
/* ---- 批1 只读增强:功耗/进程服务/首页聚合(均走 PIN 鉴权读级) ---- */
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/power")) return api_power(sock);
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/ams")) return api_ams(sock);
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/svcd")) return api_svcd(sock);
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/overview")) return api_overview(sock);
/* ---- 网络管理(meow_netmgrd 期2 透传:读级任意调用者;flush 写级走 require_auth) ---- */
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/net/status")) return api_net_status(sock);
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/net/conns")) return api_net_conns(sock, r->query);
@@ -4833,10 +5219,11 @@ int main(int argc, char **argv)
else if (!strcmp(argv[i], "--sock-launcher") && i + 1 < argc) g_sock_launcher = argv[++i];
else if (!strcmp(argv[i], "--sock-audio") && i + 1 < argc) g_sock_audio = argv[++i];
else if (!strcmp(argv[i], "--sock-net") && i + 1 < argc) g_sock_net = argv[++i];
else if (!strcmp(argv[i], "--sock-pm") && i + 1 < argc) g_sock_pm = argv[++i];
else if (!strcmp(argv[i], "--sock") && i + 1 < argc) { g_sock_perf = argv[++i]; g_sock_power = g_sock_perf; }
else if (!strcmp(argv[i], "-h") || !strcmp(argv[i], "--help")) {
printf("MeowPanel backend %s\n", PANEL_VERSION);
printf("usage: meowpanel [-p PORT] [--sock-perf P] [--sock-power P] [--sock-launcher P] [--sock-audio P] [--sock-net P]\n");
printf("usage: meowpanel [-p PORT] [--sock-perf P] [--sock-power P] [--sock-launcher P] [--sock-audio P] [--sock-net P] [--sock-pm P]\n");
return 0;
}
}
@@ -4851,6 +5238,10 @@ int main(int argc, char **argv)
if (env && env[0]) g_sock_audio = env; /* 与 audiod CLI 同 env(宿主/qemu 测试用) */
env = getenv("MEOW_NET_SOCK");
if (env && env[0]) g_sock_net = env; /* 与 netmgrd/meow_net 同 env(宿主/qemu 测试用) */
env = getenv("MEOW_PM_SOCK");
if (env && env[0]) g_sock_pm = env; /* 与 powermgr/meow_power 同 env(宿主/qemu 测试用) */
env = getenv("MEOW_AUTH_TTL");
if (env && env[0]) g_auth_ttl = (time_t)atol(env); /* 会话 TTL 覆盖(宿主测试用) */
env = getenv("MEOW_SETTINGS_FILE");
if (env && env[0]) g_settings_file = env; /* 设置文件覆盖(宿主测试用;设备默认 /usr/data/meow/settings.conf) */
@@ -4867,7 +5258,6 @@ int main(int argc, char **argv)
signal(SIGPIPE, SIG_IGN);
web_root();
sess_load(); /* 加载卡上持久化的会话(防重启失效) */
panel_log("MeowPanel %s listening on port %d (web root %s)", PANEL_VERSION, g_port, web_root());
int lfd = socket(AF_INET, SOCK_STREAM, 0);
Binary file not shown.
+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);
})();