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:
1 parent
30e2ec901f
commit
d12164305d
6 files changed
+627
-155
No files matched your search
@@ -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
@@ -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.
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
Reference in new issue
Block a user