diff --git a/patch_v2/meowpanel/README.md b/patch_v2/meowpanel/README.md index e75307d..1b7f46d 100644 --- a/patch_v2/meowpanel/README.md +++ b/patch_v2/meowpanel/README.md @@ -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://.local:8080/ - mDNS 由板载 `meow_mdns` 应答 `.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: `(或 `Authorization: Bearer ` / `Cookie: meow_token=`); - 缺/错/过期 → 401 `UNAUTHORIZED`。**默认无鉴权时请勿把 8080 暴露公网**。 + 缺/错/过期 → 401 `UNAUTHORIZED`。**HTTP 明文(无 TLS),内网使用;请勿把 8080 暴露公网**。 --- @@ -88,8 +90,9 @@ http://.local:8080/ | GET | `/`、`/meowpanel.css`、`/meowpanel.js`、`/assets/` | 静态托管 | ✅ | | 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://.local:8080/ | GET | `/api/fonts` | 字体库列表(扫描 `MeowOS/fonts///` 三件套齐) | ✅(无卡→503) | | POST | `/api/fonts/import` | 上传字体库 `.tar.gz`(multipart `file`,可带 `?force=1` 覆盖) | ✅(缺件/坏json/穿越→400,冲突→409,超限→413) | | GET | `/api/fonts/export` | 导出 `//` 三件套为 `.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://.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":`,**绝不回传存量哈希/明文**。 -- **门禁**:`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,满淘汰最早过期)+ 卡上 - `/meowpanel.session`(` `,重启加载、防吊销失效),有效期 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":`,**绝不回传哈希/明文**。 +- **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://.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://.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) diff --git a/patch_v2/meowpanel/meowpanel b/patch_v2/meowpanel/meowpanel index 9b0768f..965d598 100755 Binary files a/patch_v2/meowpanel/meowpanel and b/patch_v2/meowpanel/meowpanel differ diff --git a/patch_v2/meowpanel/meowpanel.c b/patch_v2/meowpanel/meowpanel.c index ec35d80..ee95283 100644 --- a/patch_v2/meowpanel/meowpanel.c +++ b/patch_v2/meowpanel/meowpanel.c @@ -94,8 +94,13 @@ static const char *g_notif_log = NULL; /* 通知转储日志:/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 哈希(`:`,绝不存明文)。 + * 与未来 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,返回个数;= 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 + * 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 ` / `X-MeowToken: ` / `Cookie: meow_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(格式 `:`, + * 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); diff --git a/patch_v2/meowpanel/meowpanel_host b/patch_v2/meowpanel/meowpanel_host index 6281466..5f940a3 100755 Binary files a/patch_v2/meowpanel/meowpanel_host and b/patch_v2/meowpanel/meowpanel_host differ diff --git a/patch_v2/meowpanel/web/index.html b/patch_v2/meowpanel/web/index.html index f16b695..fec98d3 100644 --- a/patch_v2/meowpanel/web/index.html +++ b/patch_v2/meowpanel/web/index.html @@ -116,12 +116,13 @@ - +