feat(meowpanel): 可选密码鉴权 (agent-196, 用户定案) — panel.auth_enabled(默认0)+panel.auth_pass(带盐sha256不明文); token会话(/dev/urandom,TTL12h,内存+卡持久化,静态屏蔽session文件); 未启用零回归,启用后 /api/* 全要会话; 前端登录页+401回跳+设置卡片; 顺带修 settings_write_one 容忍等号两侧空格(原会重复追加行)

meowpanel→f91856a4; 0 warning; 宿主curl 20例+headless Chromium 全过
This commit is contained in:
MeowOS committed 2026-08-31 05:25:28 +08:00
1 parent 5f7e7542bd
commit feb3df681c
7 files changed
+701 -35

No files matched your search

+33 -10
View File
@@ -28,9 +28,10 @@ cd /home/Aska/Project/R3ProII/patch_v2/meowpanel
- 两分支均以 `-Os -Wall -Wextra` 复核,**0 warning**。 - 两分支均以 `-Os -Wall -Wextra` 复核,**0 warning**。
- 产出(本次构建实测): - 产出(本次构建实测):
- `meowpanel`:mipsel 静态,`ELF 32-bit LSB executable, MIPS, MIPS32 rel2, statically linked, stripped`, - `meowpanel`:mipsel 静态,`ELF 32-bit LSB executable, MIPS, MIPS32 rel2, statically linked, stripped`,
体积约 **166 KB**(固件阈值内)。md5:`11ed76ecb2e1c5773a01f0fb056a2df5`。 体积约 **183 KB**(固件阈值内)。md5:`f91856a44de0c8d12aba00cd40b44b91`。
- `meowpanel_host`:aarch64 动态链接(宿主编译,仅供调试),体积约 **77 KB**。 - `meowpanel_host`:aarch64 动态链接(宿主编译,仅供调试),体积约 **142 KB**。
- 依赖:仅 libc + Linux 内核头(`linux/input.h`),零第三方库(设备无 python3/lua)。 - 依赖:仅 libc + Linux 内核头(`linux/input.h`),零第三方库(设备无 python3/lua)。鉴权用**内联 SHA-256**(拷贝自
`meow_verify/sha256.c` 公有领域参考写法,前缀 `mp_sha256_*` 避免符号冲突;**未改 `meow_verify/` 目录**)。
- 头文件引用:`-I patch_v2/meow_launcherd`(`meow_ipc.h` 帧格式 / `m_perf_*`/`m_power_*`/`POWER_*`, - 头文件引用:`-I patch_v2/meow_launcherd`(`meow_ipc.h` 帧格式 / `m_perf_*`/`m_power_*`/`POWER_*`,
**header 为权威源**,结构体字段以它为准)。 **header 为权威源**,结构体字段以它为准)。
@@ -64,7 +65,9 @@ http://<host>.local:8080/
- mDNS 由板载 `meow_mdns` 应答 `<host>.local`。 - mDNS 由板载 `meow_mdns` 应答 `<host>.local`。
- 端口默认 **8080**,可用 `-p PORT` / 环境变量 `MEOWPORT` / 与后端同目录 `meowpanel.conf`(`port=N`)覆盖。 - 端口默认 **8080**,可用 `-p PORT` / 环境变量 `MEOWPORT` / 与后端同目录 `meowpanel.conf`(`port=N`)覆盖。
(优先级:`-p` > `MEOWPORT` > `meowpanel.conf` > 默认 8080。) (优先级:`-p` > `MEOWPORT` > `meowpanel.conf` > 默认 8080。)
- 无鉴权(设备内网信任模型;token 鉴权为 MEOWPANEL_PROTOCOL §1.3 草案项,未实现)。 - 无鉴权(设备内网信任模型;**可选**密码鉴权已实现,见 §5.9)。默认关=零回归;开启后除登录/登出/状态外 `/api/*` 需会话。
- 上传/下载/管理类请求统一带 `X-MeowToken: <token>`(或 `Authorization: Bearer <token>` / `Cookie: meow_token=<token>`);
缺/错/过期 → 401 `UNAUTHORIZED`。**默认无鉴权时请勿把 8080 暴露公网**。
--- ---
@@ -75,6 +78,9 @@ http://<host>.local:8080/
| 方法 | 路径 | 说明 | 实现 | | 方法 | 路径 | 说明 | 实现 |
|---|---|---|---| |---|---|---|---|
| GET | `/`、`/meowpanel.css`、`/meowpanel.js`、`/assets/<f>` | 静态托管 | ✅ | | GET | `/`、`/meowpanel.css`、`/meowpanel.js`、`/assets/<f>` | 静态托管 | ✅ |
| GET | `/api/auth/status` | 鉴权状态(enabled/会话有效) | ✅(公开) |
| POST | `/api/auth/login` | 密码登录发 token(sha256 带盐校验) | ✅(公开;错→401) |
| POST | `/api/auth/logout` | 登出失效 token | ✅(公开;幂等) |
| GET | `/api/status` | 性能+系统(`meow_svc_perf` IPC subset=all 透传) | ✅ | | GET | `/api/status` | 性能+系统(`meow_svc_perf` IPC subset=all 透传) | ✅ |
| GET | `/api/perf` | 仅性能(subset=perf 透传) | ✅ | | GET | `/api/perf` | 仅性能(subset=perf 透传) | ✅ |
| GET | `/api/system` | perf IPC + boot/network/ota 局部补全(network 含蓝牙,无网口;`ota` 只读检查,勿误触升级) | ✅ | | GET | `/api/system` | perf IPC + boot/network/ota 局部补全(network 含蓝牙,无网口;`ota` 只读检查,勿误触升级) | ✅ |
@@ -211,6 +217,22 @@ http://<host>.local:8080/
- **并发**:面板清空/删除用「写临时 + rename」重写日志;launcher 侧仍以 `O_APPEND` 追加,二者为竞写(低并发、会话级日志, - **并发**:面板清空/删除用「写临时 + rename」重写日志;launcher 侧仍以 `O_APPEND` 追加,二者为竞写(低并发、会话级日志,
可接受),已写入「已知边界」(§7)。 可接受),已写入「已知边界」(§7)。
### 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` 输入)。
--- ---
## 6. 与协议的出入(「实现为准 + 原因」,供回改 MEOWPANEL_PROTOCOL.md) ## 6. 与协议的出入(「实现为准 + 原因」,供回改 MEOWPANEL_PROTOCOL.md)
@@ -243,8 +265,9 @@ http://<host>.local:8080/
## 7. 已知边界 / 风险 ## 7. 已知边界 / 风险
- **单线程串行**:面板访问量极小,但并发请求会被排队(长上传/下载期间其它请求等待)。对面板用途可接受。 - **单线程串行**:面板访问量极小,但并发请求会被排队(长上传/下载期间其它请求等待)。对面板用途可接受。
- **无鉴权**:设备内网信任模型(协议 §1.3 token 鉴权待定案)。**高危端点**(`reboot/shutdown/ota/apps/删除/上传)无鉴权, - **无鉴权(默认)**:设备内网信任模型(协议 §1.3)。**高危端点**(`reboot/shutdown/ota/apps/删除/上传)在默认关鉴权时无鉴权,
请勿把 8080 暴露到不可信网络。`meow_svc_power` 服务端做 `SO_PEERCRED uid==0` 基础校验(root 才受理动作),后端以 root 运行可过;launcher 电源菜单/后端同链路。 请勿把 8080 暴露到不可信网络。`meow_svc_power` 服务端做 `SO_PEERCRED uid==0` 基础校验(root 才受理动作),后端以 root 运行可过;launcher 电源菜单/后端同链路。
**可选鉴权**(§5.9)开启后可对内网作加固(登录密码 + 会话),但密码经**局域网明文 HTTP** 传输,不提供 TLS。
- **IPC 服务未起**:`meow_svc_perf`/`meow_svc_power` 为板载 `trigger=boot` 服务,正常恒在;若异常退出,读侧返回 500 `INTERNAL`, - **IPC 服务未起**:`meow_svc_perf`/`meow_svc_power` 为板载 `trigger=boot` 服务,正常恒在;若异常退出,读侧返回 500 `INTERNAL`,
动作侧返回 500/503,绝不静默。请勿拔除这两服务。 动作侧返回 500/503,绝不静默。请勿拔除这两服务。
- **升级保护**:`POWER_UPGRADE` 由 `meow_svc_power` 在写 mtd5 前校验卡根 `r3proii.upt`(存在/可读/非空/size>0),失败不写 mtd5;期望 md5/hash 校验留二期。 - **升级保护**:`POWER_UPGRADE` 由 `meow_svc_power` 在写 mtd5 前校验卡根 `r3proii.upt`(存在/可读/非空/size>0),失败不写 mtd5;期望 md5/hash 校验留二期。
@@ -266,15 +289,15 @@ http://<host>.local:8080/
``` ```
patch_v2/meowpanel/ patch_v2/meowpanel/
├── meowpanel.c # 后端源码(单文件,约 3150 行,-Wall -Wextra 0 warning) ├── meowpanel.c # 后端源码(单文件,约 3667 行,-Wall -Wextra 0 warning;含可选鉴权/内联 SHA-256)
├── build_meowpanel.sh # 交叉编译 + host 分支(-Wall -Wextra 0 warning) ├── build_meowpanel.sh # 交叉编译 + host 分支(-Wall -Wextra 0 warning)
├── meowpanel # 交叉产物(mipsel 静态,约 174 KB,md5 58a7b186…) ├── meowpanel # 交叉产物(mipsel 静态,约 183 KB,md5 f91856a4…)
├── meowpanel_host # 宿主编译(本机 ld,调试用) ├── meowpanel_host # 宿主编译(本机 ld,调试用)
├── services.d/ ├── services.d/
│ └── meowpanel.conf # 部署到 TF 卡 MeowOS/services.d/meowpanel.conf │ └── meowpanel.conf # 部署到 TF 卡 MeowOS/services.d/meowpanel.conf
├── web/ # 前端(已存在;部署时拷到后端同目录) ├── web/ # 前端(已存在;部署时拷到后端同目录)
│ ├── index.html │ ├── index.html # 含登录页 overlay + 设置密码对话框
│ ├── meowpanel.css │ ├── meowpanel.css # 含登录页样式
│ └── meowpanel.js │ └── meowpanel.js # API 客户端带 token/401 登录、设置页鉴权卡片
└── README.md # 本文件 └── README.md # 本文件
``` ```
Binary file not shown.
+522 -14
View File
@@ -56,6 +56,8 @@
#define DEFAULT_PORT 8080 #define DEFAULT_PORT 8080
#define SETTINGS_FILE "/usr/data/meow/settings.conf" #define SETTINGS_FILE "/usr/data/meow/settings.conf"
#define SETTINGS_HDR "# meow-settings 1" #define SETTINGS_HDR "# meow-settings 1"
/* settings.conf 路径(可被 env MEOW_SETTINGS_FILE 覆盖,宿主测试用;默认设备路径)。 */
static const char *g_settings_file = SETTINGS_FILE;
/* TF 卡路径(运行期可覆盖,宿主测试用 env MEOW_TF_ROOT;默认设备卡挂载点)。 /* TF 卡路径(运行期可覆盖,宿主测试用 env MEOW_TF_ROOT;默认设备卡挂载点)。
* TF_ROOT 保持为「数组宏」而非指针——让 -Wformat-truncation 能按 1024 尺寸分析拼接,避免误报。 */ * TF_ROOT 保持为「数组宏」而非指针——让 -Wformat-truncation 能按 1024 尺寸分析拼接,避免误报。 */
static char g_tf_root_buf[1024] = "/usr/data/mnt/sd_0"; static char g_tf_root_buf[1024] = "/usr/data/mnt/sd_0";
@@ -204,6 +206,7 @@ static int send_err(int sock, int status, const char *code, const char *msg);
static int send_json(int sock, int status, const char *data); static int send_json(int sock, int status, const char *data);
static int send_file(int sock, int status, const char *ctype, const char *abspath, const char *filename); static int send_file(int sock, int status, const char *ctype, const char *abspath, const char *filename);
static void panel_log(const char *fmt, ...); static void panel_log(const char *fmt, ...);
static const char *web_root(void); /* 定义在文件后部;此处前向声明供鉴权会话路径使用 */
/* ---------------- CSV(enum 名/标签)非改写取值;定义在设置列表区,先声明供 settings_coerce 用 ---------------- */ /* ---------------- CSV(enum 名/标签)非改写取值;定义在设置列表区,先声明供 settings_coerce 用 ---------------- */
static const char *csv_at(const char *s, int idx, char *buf, int buflen); static const char *csv_at(const char *s, int idx, char *buf, int buflen);
@@ -481,6 +484,9 @@ static const setdef_t g_sets[] = {
{ "network.bluetooth_enabled", ST_BOOL, "0", 0,0, NULL, NULL, "网络" }, { "network.bluetooth_enabled", ST_BOOL, "0", 0,0, NULL, NULL, "网络" },
/* 系统 */ /* 系统 */
{ "system.hostname", ST_STR, "meowos",0,0, NULL, NULL, "系统" }, { "system.hostname", ST_STR, "meowos",0,0, NULL, NULL, "系统" },
/* 面板(MeowPanel 自身,可选鉴权) */
{ "panel.auth_enabled", ST_BOOL, "0", 0,0, NULL, NULL, "面板" },
{ "panel.auth_pass", ST_STR, "", 0,0, NULL, NULL, "面板" },
}; };
#define SETS_COUNT ((int)(sizeof g_sets / sizeof g_sets[0])) #define SETS_COUNT ((int)(sizeof g_sets / sizeof g_sets[0]))
@@ -492,10 +498,40 @@ static const setdef_t *setdef_find(const char *key) {
/* ---------------- settings.conf 解析(照 meow_ui_vol_read / settings_get_int_trim) ---------------- /* ---------------- settings.conf 解析(照 meow_ui_vol_read / settings_get_int_trim) ----------------
* '=' 切分,key 去首尾空白精确匹配(防 audio.volume 误配 audio.volume_max)。 * '=' 切分,key 去首尾空白精确匹配(防 audio.volume 误配 audio.volume_max)。
* 路径取 g_settings_file(env MEOW_SETTINGS_FILE 可覆盖,宿主测试用)。
*/ */
static void settings_mkdir_p(void)
{
/* 逐级创建 settings.conf 所在目录(mkdir -p 语义,容忍已存在)。 */
char tmp[1024];
snprintf(tmp, sizeof tmp, "%s", g_settings_file);
char *slash = strrchr(tmp, '/');
if (!slash || slash == tmp) return; /* 无目录或根部 */
*slash = 0;
if (!tmp[0]) return;
char buf[1024]; buf[0] = 0;
size_t i = 0;
if (tmp[0] == '/') buf[i++] = '/';
const char *s = tmp + (tmp[0] == '/' ? 1 : 0);
while (*s) {
const char *tom = strchr(s, '/');
size_t l = tom ? (size_t)(tom - s) : strlen(s);
if (l) {
if (i && buf[i - 1] != '/') buf[i++] = '/';
if (i + l >= sizeof buf) break;
memcpy(buf + i, s, l);
i += l;
buf[i] = 0;
mkdir(buf, 0777);
}
if (!tom) break;
s = tom + 1;
}
}
static char *settings_get_value(const char *key) static char *settings_get_value(const char *key)
{ {
FILE *f = fopen(SETTINGS_FILE, "r"); FILE *f = fopen(g_settings_file, "r");
if (!f) return NULL; if (!f) return NULL;
size_t kl = strlen(key); size_t kl = strlen(key);
char line[256]; char line[256];
@@ -544,8 +580,8 @@ static int brightness_clamp(int v)
* O_TRUNC 重写。成功返回 0。 */ * O_TRUNC 重写。成功返回 0。 */
static int settings_write_one(const char *key, const char *val) static int settings_write_one(const char *key, const char *val)
{ {
mkdir("/usr/data/meow", 0777); /* 目录缺失自动建(与 meow_settings_save 同;失败由 fopen 兜底) */ settings_mkdir_p(); /* 目录缺失自动建(与 meow_settings_save 同;失败由 fopen 兜底) */
FILE *f = fopen(SETTINGS_FILE, "r"); FILE *f = fopen(g_settings_file, "r");
char tmp[2048]; char tmp[2048];
size_t n = 0; size_t n = 0;
int c; int c;
@@ -554,22 +590,28 @@ static int settings_write_one(const char *key, const char *val)
fclose(f); fclose(f);
} }
tmp[n] = 0; tmp[n] = 0;
FILE *nf = fopen(SETTINGS_FILE, "w"); FILE *nf = fopen(g_settings_file, "w");
if (!nf) return -1; if (!nf) return -1;
size_t klen = strlen(key); size_t klen = strlen(key);
int found = 0; int found = 0;
if (n == 0) fprintf(nf, "%s\n", SETTINGS_HDR); if (n == 0) fprintf(nf, "%s\n", SETTINGS_HDR);
/* 逐行重写:头照抄;命中 key 行替换;其余照抄 */ /* 逐行重写:头照抄;命中 key 行替换;其余照抄。匹配容忍 `key = value` 两侧空格(与 settings_get_value 同口径)。 */
char *save = NULL; char *save = NULL;
for (char *ln = strtok_r(tmp, "\n", &save); ln; ln = strtok_r(NULL, "\n", &save)) { for (char *ln = strtok_r(tmp, "\n", &save); ln; ln = strtok_r(NULL, "\n", &save)) {
char *p = ln; char *p = ln;
while (*p == ' ' || *p == '\t') p++; while (*p == ' ' || *p == '\t') p++;
if (!strncmp(p, key, klen) && p[klen] == '=') { char *eq = strchr(p, '=');
fprintf(nf, "%s = %s\n", key, val); if (eq) {
found = 1; char *k = p;
} else { size_t km = (size_t)(eq - k);
fprintf(nf, "%s\n", ln); while (km > 0 && (k[km - 1] == ' ' || k[km - 1] == '\t')) km--;
if (km == klen && strncmp(k, key, klen) == 0) {
fprintf(nf, "%s = %s\n", key, val);
found = 1;
continue;
}
} }
fprintf(nf, "%s\n", ln);
} }
if (!found) fprintf(nf, "%s = %s\n", key, val); if (!found) fprintf(nf, "%s = %s\n", key, val);
fclose(nf); fclose(nf);
@@ -630,8 +672,8 @@ static int settings_coerce(const setdef_t *d, const char *input, char *out, size
/* 恢复全部默认:重写整个文件为 头 + 所有键 def。 */ /* 恢复全部默认:重写整个文件为 头 + 所有键 def。 */
static int settings_reset_all(void) static int settings_reset_all(void)
{ {
mkdir("/usr/data/meow", 0777); settings_mkdir_p();
FILE *nf = fopen(SETTINGS_FILE, "w"); FILE *nf = fopen(g_settings_file, "w");
if (!nf) return -1; if (!nf) return -1;
fprintf(nf, "%s\n", SETTINGS_HDR); fprintf(nf, "%s\n", SETTINGS_HDR);
for (int i = 0; i < SETS_COUNT; i++) for (int i = 0; i < SETS_COUNT; i++)
@@ -1232,6 +1274,9 @@ typedef struct {
char *body; char *body;
size_t body_len; size_t body_len;
char content_type[128]; /* 请求 Content-Type(若非空) */ char content_type[128]; /* 请求 Content-Type(若非空) */
char authorization[256]; /* Authorization 头(Bearer <token>) */
char x_meow_token[256]; /* X-MeowToken 头 */
char cookie[512]; /* Cookie 头(meow_token=<token> 等) */
} req_t; } req_t;
static void req_free(req_t *r) { free(r->body); r->body = NULL; r->body_len = 0; } static void req_free(req_t *r) { free(r->body); r->body = NULL; r->body_len = 0; }
@@ -1364,6 +1409,21 @@ static int read_request(int sock, req_t *r)
while (*v == ' ') v++; while (*v == ' ') v++;
snprintf(r->content_type, sizeof r->content_type, "%s", v); snprintf(r->content_type, sizeof r->content_type, "%s", v);
} }
else if (!strncasecmp(hp, "Authorization:", 14)) {
char *v = hp + 14;
while (*v == ' ') v++;
snprintf(r->authorization, sizeof r->authorization, "%s", v);
}
else if (!strncasecmp(hp, "X-MeowToken:", 12)) {
char *v = hp + 12;
while (*v == ' ') v++;
snprintf(r->x_meow_token, sizeof r->x_meow_token, "%s", v);
}
else if (!strncasecmp(hp, "Cookie:", 7)) {
char *v = hp + 7;
while (*v == ' ') v++;
snprintf(r->cookie, sizeof r->cookie, "%s", v);
}
hp = he + 2; hp = he + 2;
} }
char *body_start = buf + hend + 4; /* body 实际起点 */ char *body_start = buf + hend + 4; /* body 实际起点 */
@@ -1396,6 +1456,391 @@ static int read_request(int sock, req_t *r)
return 0; 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),存内存 + 卡上文件持久化(防重启失效),带过期。
* - 未启用(默认)时鉴权门禁直接放行,行为与现状完全一致(零回归);危险端点不额外限制。
*/
#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;
}
/* ---- 最小 SHA-256(FIPS 180-4,公有领域参考写法;内联拷贝自 meow_verify/sha256.c,前缀 mp_ 避免符号冲突) ---- */
static const uint32_t mp_sha256_k[64] = {
0x428a2f98u,0x71374491u,0xb5c0fbcfu,0xe9b5dba5u,0x3956c25bu,0x59f111f1u,0x923f82a4u,0xab1c5ed5u,
0xd807aa98u,0x12835b01u,0x243185beu,0x550c7dc3u,0x72be5d74u,0x80deb1feu,0x9bdc06a7u,0xc19bf174u,
0xe49b69c1u,0xefbe4786u,0x0fc19dc6u,0x240ca1ccu,0x2de92c6fu,0x4a7484aau,0x5cb0a9dcu,0x76f988dau,
0x983e5152u,0xa831c66du,0xb00327c8u,0xbf597fc7u,0xc6e00bf3u,0xd5a79147u,0x06ca6351u,0x14292967u,
0x27b70a85u,0x2e1b2138u,0x4d2c6dfcu,0x53380d13u,0x650a7354u,0x766a0abbu,0x81c2c92eu,0x92722c85u,
0xa2bfe8a1u,0xa81a664bu,0xc24b8b70u,0xc76c51a3u,0xd192e819u,0xd6990624u,0xf40e3585u,0x106aa070u,
0x19a4c116u,0x1e376c08u,0x2748774cu,0x34b0bcb5u,0x391c0cb3u,0x4ed8aa4au,0x5b9cca4fu,0x682e6ff3u,
0x748f82eeu,0x78a5636fu,0x84c87814u,0x8cc70208u,0x90befffau,0xa4506cebu,0xbef9a3f7u,0xc67178f2u
};
#define MP_ROR(x,n) (((x) >> (n)) | ((x) << (32 - (n))))
#define MP_CH(x,y,z) (((x) & (y)) ^ (~(x) & (z)))
#define MP_MAJ(x,y,z) (((x) & (y)) ^ ((x) & (z)) ^ ((y) & (z)))
#define MP_BSIG0(x) (MP_ROR(x,2) ^ MP_ROR(x,13) ^ MP_ROR(x,22))
#define MP_BSIG1(x) (MP_ROR(x,6) ^ MP_ROR(x,11) ^ MP_ROR(x,25))
#define MP_SSIG0(x) (MP_ROR(x,7) ^ MP_ROR(x,18) ^ ((x) >> 3))
#define MP_SSIG1(x) (MP_ROR(x,17) ^ MP_ROR(x,19) ^ ((x) >> 10))
typedef struct { uint32_t state[8]; uint64_t count; uint8_t buf[64]; size_t buflen; } mp_sha256_ctx;
static void mp_sha256_init(mp_sha256_ctx *c)
{
c->state[0] = 0x6a09e667u; c->state[1] = 0xbb67ae85u;
c->state[2] = 0x3c6ef372u; c->state[3] = 0xa54ff53au;
c->state[4] = 0x510e527fu; c->state[5] = 0x9b05688cu;
c->state[6] = 0x1f83d9abu; c->state[7] = 0x5be0cd19u;
c->count = 0; c->buflen = 0;
}
static void mp_sha256_block(mp_sha256_ctx *c, const uint8_t *p)
{
uint32_t w[64];
int i;
for (i = 0; i < 16; i++)
w[i] = ((uint32_t)p[i*4] << 24) | ((uint32_t)p[i*4+1] << 16) |
((uint32_t)p[i*4+2] << 8) | (uint32_t)p[i*4+3];
for (i = 16; i < 64; i++)
w[i] = MP_SSIG1(w[i-2]) + w[i-7] + MP_SSIG0(w[i-15]) + w[i-16];
uint32_t a = c->state[0], b = c->state[1], cc = c->state[2], d = c->state[3];
uint32_t e = c->state[4], f = c->state[5], g = c->state[6], h = c->state[7];
for (i = 0; i < 64; i++) {
uint32_t t1 = h + MP_BSIG1(e) + MP_CH(e,f,g) + mp_sha256_k[i] + w[i];
uint32_t t2 = MP_BSIG0(a) + MP_MAJ(a,b,cc);
h = g; g = f; f = e; e = d + t1;
d = cc; cc = b; b = a; a = t1 + t2;
}
c->state[0] += a; c->state[1] += b; c->state[2] += cc; c->state[3] += d;
c->state[4] += e; c->state[5] += f; c->state[6] += g; c->state[7] += h;
}
static void mp_sha256_update(mp_sha256_ctx *c, const uint8_t *data, size_t len)
{
c->count += (uint64_t)len;
if (c->buflen) {
size_t need = 64 - c->buflen;
size_t take = len < need ? len : need;
memcpy(c->buf + c->buflen, data, take);
c->buflen += take; data += take; len -= take;
if (c->buflen == 64) { mp_sha256_block(c, c->buf); c->buflen = 0; }
}
while (len >= 64) { mp_sha256_block(c, data); data += 64; len -= 64; }
if (len) { memcpy(c->buf, data, len); c->buflen = len; }
}
static void mp_sha256_final(mp_sha256_ctx *c, uint8_t out[32])
{
uint64_t bitlen = c->count * 8;
uint8_t *b = c->buf;
size_t bl = c->buflen;
int i;
b[bl++] = 0x80;
if (bl > 56) {
while (bl < 64) b[bl++] = 0;
mp_sha256_block(c, b);
bl = 0;
}
while (bl < 56) b[bl++] = 0;
for (i = 0; i < 8; i++)
b[bl++] = (uint8_t)(bitlen >> (56 - i * 8));
mp_sha256_block(c, b);
for (i = 0; i < 8; i++) {
out[i*4] = (uint8_t)(c->state[i] >> 24);
out[i*4+1] = (uint8_t)(c->state[i] >> 16);
out[i*4+2] = (uint8_t)(c->state[i] >> 8);
out[i*4+3] = (uint8_t)(c->state[i]);
}
}
#undef MP_ROR
#undef MP_CH
#undef MP_MAJ
#undef MP_BSIG0
#undef MP_BSIG1
#undef MP_SSIG0
#undef MP_SSIG1
/* ---- 鉴权基础 ----
* 密码存储格式:`<salt_hex>:<sha256_hex(salt || password)>`。salt 由 /dev/urandom 生成(16B → 32 hex)。
* 校验时重算并比较,绝不反解、绝不在日志/响应中输出 hash 或明文。
*/
static int auth_read_random(unsigned char *buf, size_t n)
{
int fd = open("/dev/urandom", O_RDONLY);
if (fd < 0) return -1;
size_t got = 0;
while (got < n) {
ssize_t rd = read(fd, buf + got, n - got);
if (rd <= 0) { close(fd); return -1; }
got += (size_t)rd;
}
close(fd);
return 0;
}
static void auth_hex(const unsigned char *raw, size_t n, char *out)
{
static const char hex[] = "0123456789abcdef";
for (size_t i = 0; i < n; i++) {
out[i*2] = hex[raw[i] >> 4];
out[i*2+1] = hex[raw[i] & 0xf];
}
out[n*2] = 0;
}
static int auth_token_gen(char *out, int outcap) /* 32B → 64 hex */
{
unsigned char raw[32];
if (outcap < 65) return -1;
if (auth_read_random(raw, sizeof raw) < 0) return -1;
auth_hex(raw, sizeof raw, out);
return 0;
}
static int auth_make_salt(char *out, int outcap) /* 16B → 32 hex 盐 */
{
unsigned char raw[16];
if (outcap < 33) return -1;
if (auth_read_random(raw, sizeof raw) < 0) return -1;
auth_hex(raw, sizeof raw, out);
return 0;
}
static int auth_pass_hash(const char *salt, const char *password, char *out, int outcap)
{
mp_sha256_ctx c;
uint8_t digest[32];
if (outcap < AUTH_HASH_LEN + 1) return -1;
mp_sha256_init(&c);
mp_sha256_update(&c, (const uint8_t *)salt, strlen(salt));
mp_sha256_update(&c, (const uint8_t *)password, strlen(password));
mp_sha256_final(&c, digest);
auth_hex(digest, sizeof digest, out);
return 0;
}
static int auth_make_hash(const char *password, char *out, int outcap) /* salt:hex */
{
char salt[33], h[AUTH_HASH_LEN + 1];
if (auth_make_salt(salt, sizeof salt) < 0) return -1;
if (auth_pass_hash(salt, password, h, sizeof h) < 0) return -1;
snprintf(out, outcap, "%s:%s", salt, h);
return 0;
}
static int auth_enabled(void)
{
return settings_get_int("panel.auth_enabled", 0) != 0;
}
static int auth_pass_set(void)
{
char *v = settings_get_value("panel.auth_pass");
int s = (v && v[0]) ? 1 : 0;
free(v);
return s;
}
static int auth_verify(const char *password)
{
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;
}
}
free(stored);
return ok;
}
/* ---- 会话(内存 + 卡上持久化) ---- */
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;
static int sess_find(const char *token)
{
for (int i = 0; i < g_sess_n; i++)
if (!strcmp(g_sess[i].token, token)) return i;
return -1;
}
static int sess_valid(const char *token)
{
int i = sess_find(token);
if (i < 0) return 0;
if (g_sess[i].expires > 0 && time(NULL) >= g_sess[i].expires) return 0;
return 1;
}
static void sess_add(const char *token, time_t expires)
{
int i = sess_find(token);
if (i >= 0) { g_sess[i].expires = expires; return; }
if (g_sess_n >= AUTH_MAX_SESS) { /* 满:淘汰最早过期的一条 */
int e = 0;
for (int k = 1; k < g_sess_n; k++)
if (g_sess[k].expires < g_sess[e].expires) e = k;
if (e < g_sess_n - 1)
memmove(&g_sess[e], &g_sess[e + 1], (size_t)(g_sess_n - 1 - e) * sizeof(auth_sess_t));
g_sess_n--;
}
snprintf(g_sess[g_sess_n].token, sizeof g_sess[g_sess_n].token, "%s", token);
g_sess[g_sess_n].expires = expires;
g_sess_n++;
}
static void sess_remove(const char *token)
{
int i = sess_find(token);
if (i < 0) return;
if (i < g_sess_n - 1)
memmove(&g_sess[i], &g_sess[i + 1], (size_t)(g_sess_n - 1 - i) * sizeof(auth_sess_t));
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);
if (l >= (size_t)dcap) l = (size_t)dcap - 1;
memcpy(dst, src, l);
dst[l] = 0;
}
/* ---- 从请求提取 token(X-MeowToken → Authorization: Bearer → Cookie meow_token) ---- */
static int extract_token(const req_t *r, char *out, int outcap)
{
out[0] = 0;
if (r->x_meow_token[0]) { auth_cp(out, outcap, r->x_meow_token); return out[0] ? 1 : 0; }
if (!strncasecmp(r->authorization, "Bearer ", 7)) {
auth_cp(out, outcap, r->authorization + 7);
return out[0] ? 1 : 0;
}
if (r->cookie[0]) {
const char *p = strstr(r->cookie, "meow_token=");
if (p) {
p += 11;
const char *e = p;
while (*e && *e != ';' && *e != ' ' && *e != '\t') e++;
size_t l = (size_t)(e - p);
if (l >= (size_t)outcap) l = (size_t)outcap - 1;
memcpy(out, p, l);
out[l] = 0;
return out[0] ? 1 : 0;
}
}
return 0;
}
/* ---- 鉴权门禁:启用后除 /api/auth 系列外全部 /api/ 端点需会话 ---- */
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;
char t[65];
if (extract_token(r, t, sizeof t) && t[0] && sess_valid(t)) return 1;
return send_err(sock, 401, "UNAUTHORIZED", "未认证或会话已失效");
}
static int api_auth_status(int sock, const req_t *r)
{
int en = auth_enabled();
int sess = 0;
if (en) {
char t[65];
if (extract_token(r, t, sizeof t) && t[0]) sess = sess_valid(t);
}
sbuf_t s = {0};
sA(&s, "{\"auth_enabled\":%s,\"session\":%s}", en ? "true" : "false", sess ? "true" : "false");
int rc = send_json(sock, 200, s.b ? s.b : "{}");
sbuf_free(&s);
return rc;
}
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", "缺少密码");
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_free(root);
if (!ok) return send_err(sock, 401, "UNAUTHORIZED", "密码错误");
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;
sess_add(token, exp);
sess_save();
sbuf_t s = {0};
sA(&s, "{\"token\":");
sA_str(&s, token);
sA(&s, ",\"expires_at\":%lld,\"auth_enabled\":true}", (long long)exp);
int rc = send_json(sock, 200, s.b ? s.b : "{}");
sbuf_free(&s);
return rc;
}
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(); }
return send_json(sock, 200, "{\"logged_out\":true}");
}
/* ---------------- HTTP 响应写入 ---------------- */ /* ---------------- HTTP 响应写入 ---------------- */
static int write_full(int sock, const void *b, size_t n) static int write_full(int sock, const void *b, size_t n)
{ {
@@ -1721,7 +2166,12 @@ static void settings_item_json(sbuf_t *s, const setdef_t *d, const char *val)
} else if (d->type == ST_BOOL) { } else if (d->type == ST_BOOL) {
sA(s, "%s", str_truthy(val) ? "true" : "false"); sA(s, "%s", str_truthy(val) ? "true" : "false");
} else { } else {
sA_str(s, val); if (!strcmp(d->key, "panel.auth_pass")) {
/* 密码哈希绝不出现在响应里:value 恒空串,附 set 标志(是否已设密码) */
sA(s, "\"\",\"set\":%s", val[0] ? "true" : "false");
} else {
sA_str(s, val);
}
} }
sA(s, ",\"def\":"); sA(s, ",\"def\":");
sA_str(s, d->def); sA_str(s, d->def);
@@ -1802,6 +2252,36 @@ static int api_settings_put(int sock, const char *key, const req_t *r)
default: jv_free(root); return send_err(sock, 400, "BAD_REQUEST", "value 类型不支持"); default: jv_free(root); return send_err(sock, 400, "BAD_REQUEST", "value 类型不支持");
} }
jv_free(root); jv_free(root);
char hashbuf[200];
/* panel.auth_pass:只落带盐哈希,绝不存明文;空串=清除密码 */
if (!strcmp(key, "panel.auth_pass")) {
if (raw[0]) {
if (auth_make_hash(raw, hashbuf, sizeof hashbuf) < 0)
return send_err(sock, 500, "INTERNAL", "生成密码哈希失败");
} else {
hashbuf[0] = 0; /* 清空 → 禁用密码 */
}
if (settings_write_one(key, hashbuf) < 0) return send_err(sock, 500, "INTERNAL", "写设置失败");
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_enabled:启用前必须先设密码 */
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 (settings_write_one(key, coerced) < 0) return send_err(sock, 500, "INTERNAL", "写设置失败");
sbuf_t s = {0};
settings_item_json(&s, d, coerced);
int rc = send_json(sock, 200, s.b ? s.b : "");
sbuf_free(&s);
return rc;
}
char coerced[256]; char coerced[256];
if (settings_coerce(d, raw, coerced, sizeof coerced) < 0) { if (settings_coerce(d, raw, coerced, sizeof coerced) < 0) {
return send_err(sock, 422, "UNSUPPORTED", "值域/枚举非法"); return send_err(sock, 422, "UNSUPPORTED", "值域/枚举非法");
@@ -1838,6 +2318,15 @@ static int api_settings_batch(int sock, const req_t *r)
case J_BOOL: snprintf(raw, sizeof raw, "%d", val->bval ? 1 : 0); break; 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; default: if (nfail) sA(&fail, ","); sA(&fail, "{\"key\":"); sA_str(&fail, p->str); sA(&fail, ",\"code\":\"BAD_REQUEST\",\"message\":\"非法值类型\"}"); nfail++; continue;
} }
/* panel.auth_pass:只落带盐哈希(空=清除);panel.auth_enabled:启用前须已有密码 */
if (!strcmp(p->str, "panel.auth_pass")) {
char hashbuf[200];
if (raw[0]) { 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\":\"生成密码哈希失败\"}"); 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;
}
char coerced[256]; char coerced[256];
if (settings_coerce(d, raw, coerced, sizeof coerced) < 0) { if (settings_coerce(d, raw, coerced, sizeof coerced) < 0) {
if (nfail) sA(&fail, ","); if (nfail) sA(&fail, ",");
@@ -1845,6 +2334,13 @@ static int api_settings_batch(int sock, const req_t *r)
nfail++; nfail++;
continue; continue;
} }
/* panel.auth_enabled:启用前必须先设密码 */
if (!strcmp(p->str, "panel.auth_enabled") && !strcmp(coerced, "1") && !auth_pass_set()) {
if (nfail) sA(&fail, ",");
sA(&fail, "{\"key\":"); sA_str(&fail, p->str); sA(&fail, ",\"code\":\"UNSUPPORTED\",\"message\":\"请先设置面板密码\"}");
nfail++;
continue;
}
if (settings_write_one(p->str, coerced) == 0) { if (settings_write_one(p->str, coerced) == 0) {
if (nok) sA(&ok, ","); if (nok) sA(&ok, ",");
sA_str(&ok, p->str); sA_str(&ok, p->str);
@@ -2961,7 +3457,8 @@ static int serve_static(int sock, const char *path)
/* 敏感名拒绝 */ /* 敏感名拒绝 */
const char *base = strrchr(full, '/'); const char *base = strrchr(full, '/');
base = base ? base + 1 : full; base = base ? base + 1 : full;
if (!strcmp(base, "meowpanel") || !strcmp(base, "meowpanel_host") ) return send_err(sock, 403, "FORBIDDEN", "禁止访问可执行文件"); if (!strcmp(base, "meowpanel") || !strcmp(base, "meowpanel_host")
|| !strcmp(base, "meowpanel.session") ) return send_err(sock, 403, "FORBIDDEN", "禁止访问可执行/会话文件");
FILE *f = fopen(full, "rb"); FILE *f = fopen(full, "rb");
if (!f) return send_err(sock, 404, "NOT_FOUND", "资源不存在"); if (!f) return send_err(sock, 404, "NOT_FOUND", "资源不存在");
fclose(f); fclose(f);
@@ -2978,6 +3475,14 @@ static int handle(int sock, req_t *r)
return serve_static(sock, p); return serve_static(sock, p);
} }
/* ---- 可选鉴权门禁(启用后除 /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/logout")) return api_auth_logout(sock, r);
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/auth/status")) return api_auth_status(sock, r);
/* ---- 性能/状态 ---- */ /* ---- 性能/状态 ---- */
if (!strcmp(r->method, "GET") && (!strcmp(p, "/api/status") || !strcmp(p, "/api/perf"))) if (!strcmp(r->method, "GET") && (!strcmp(p, "/api/status") || !strcmp(p, "/api/perf")))
return api_perf(sock, p); return api_perf(sock, p);
@@ -3118,6 +3623,8 @@ int main(int argc, char **argv)
if (env && env[0]) g_sock_perf = env; if (env && env[0]) g_sock_perf = env;
env = getenv("MEOW_IPC_POWER_SOCK"); env = getenv("MEOW_IPC_POWER_SOCK");
if (env && env[0]) g_sock_power = env; if (env && env[0]) g_sock_power = env;
env = getenv("MEOW_SETTINGS_FILE");
if (env && env[0]) g_settings_file = env; /* 设置文件覆盖(宿主测试用;设备默认 /usr/data/meow/settings.conf) */
(void)read_port_config(); /* 应用 meowpanel.conf 的 port(env/-p 优先) */ (void)read_port_config(); /* 应用 meowpanel.conf 的 port(env/-p 优先) */
/* 优先级:-p 已设则保留;否则用 conf/env(read_port_config 会写 g_port) */ /* 优先级:-p 已设则保留;否则用 conf/env(read_port_config 会写 g_port) */
@@ -3132,6 +3639,7 @@ int main(int argc, char **argv)
signal(SIGPIPE, SIG_IGN); signal(SIGPIPE, SIG_IGN);
web_root(); web_root();
sess_load(); /* 加载卡上持久化的会话(防重启失效) */
panel_log("MeowPanel %s listening on port %d (web root %s)", PANEL_VERSION, g_port, web_root()); 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); int lfd = socket(AF_INET, SOCK_STREAM, 0);
Binary file not shown.
+24
View File
@@ -100,6 +100,30 @@
</div> </div>
</div> </div>
<!-- 登录页(面板鉴权启用后显示) -->
<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-err" id="login-err" hidden></p>
<button class="btn btn-primary login-btn" id="login-btn">登 录</button>
</div>
</div>
<!-- 设置面板密码对话框 -->
<div id="pwdlg" class="modal-scrim" hidden>
<div class="modal">
<h2 class="modal-title" id="pwdlg-title">设置面板密码</h2>
<p class="modal-body" id="pwdlg-body">请输入新密码(留空则清除密码)。</p>
<input type="password" id="pwdlg-input" class="login-input" placeholder="新密码" autocomplete="new-password">
<div class="modal-actions">
<button class="btn" id="pwdlg-cancel">取消</button>
<button class="btn btn-primary" id="pwdlg-ok">确定</button>
</div>
</div>
</div>
<div id="toast" class="toast" hidden></div> <div id="toast" class="toast" hidden></div>
<script src="meowpanel.js"></script> <script src="meowpanel.js"></script>
+13
View File
@@ -289,6 +289,19 @@ input[type=range]{ width:100%; accent-color:var(--primary); }
.modal-body{ color:var(--on-surface-variant); margin:0 0 18px; word-break:break-all; } .modal-body{ color:var(--on-surface-variant); margin:0 0 18px; word-break:break-all; }
.modal-actions{ display:flex; justify-content:flex-end; gap:10px; } .modal-actions{ display:flex; justify-content:flex-end; gap:10px; }
/* 登录页(面板鉴权) */
.login-scrim{ position:fixed; inset:0; z-index:70; background:var(--scrim); display:flex; align-items:center; justify-content:center; padding:20px; }
.login-scrim[hidden]{ display:none; }
.login-box{ background:var(--surface); border-radius:var(--radius-l); width:min(360px,100%); padding:28px 26px; box-shadow:var(--shadow); border:var(--outline-w) solid var(--outline-variant); text-align:center; }
.login-brand{ display:inline-flex; align-items:center; gap:8px; font-size:20px; font-weight:600; margin-bottom:6px; }
.login-brand-mark{ width:30px; height:30px; border-radius:8px; background:var(--primary); color:var(--on-primary); display:inline-flex; align-items:center; justify-content:center; font-weight:700; }
.login-sub{ color:var(--on-surface-variant); font-size:13px; margin:0 0 18px; }
.login-input{ width:100%; padding:11px 12px; border-radius:var(--radius-m); border:var(--outline-w) solid var(--outline-variant); background:var(--surface); color:var(--on-surface); font-size:14px; margin-bottom:12px; box-sizing:border-box; }
.login-input:focus{ outline:none; border-color:var(--primary); }
.login-err{ color:var(--error); font-size:13px; margin:0 0 12px; text-align:left; }
.login-btn{ display:block; width:100%; }
.modal .login-input{ margin-top:4px; }
/* 空态 / 加载 */ /* 空态 / 加载 */
.empty{ text-align:center; color:var(--on-surface-variant); padding:48px 0; } .empty{ text-align:center; color:var(--on-surface-variant); padding:48px 0; }
.skel{ color:var(--on-surface-variant); } .skel{ color:var(--on-surface-variant); }
+109 -11
View File
@@ -10,6 +10,10 @@
const $ = (sel, root) => (root || document).querySelector(sel); const $ = (sel, root) => (root || document).querySelector(sel);
const $$ = (sel, root) => Array.prototype.slice.call((root || document).querySelectorAll(sel)); const $$ = (sel, root) => Array.prototype.slice.call((root || document).querySelectorAll(sel));
function storageGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
function storageSet(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
function storageDel(k) { try { localStorage.removeItem(k); } catch (e) {} }
function esc(s) { function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, m => ({ return String(s == null ? '' : s).replace(/[&<>"']/g, m => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
@@ -38,6 +42,7 @@
filesRel: '/', filesAbs: null, filesRel: '/', filesAbs: null,
perfTimer: null, // 性能页轮询 perfTimer: null, // 性能页轮询
route: 'perf', route: 'perf',
unauth: false, // 鉴权未通过(弹登录页)
downloadNonce: 0 downloadNonce: 0
}; };
@@ -54,13 +59,17 @@
// 返回 {code,message,status} 形异常;网络/非 JSON → 视为断连 // 返回 {code,message,status} 形异常;网络/非 JSON → 视为断连
async function api(method, path, body) { async function api(method, path, body) {
let res; let res;
const headers = {};
const token = storageGet('meowpanel.token');
if (token) headers['X-MeowToken'] = token;
const init = { method: method || 'GET' }; const init = { method: method || 'GET' };
if (body instanceof FormData) { if (body instanceof FormData) {
init.body = body; init.body = body;
} else if (body !== undefined && body !== null) { } else if (body !== undefined && body !== null) {
init.headers = { 'Content-Type': 'application/json' }; headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(body); init.body = JSON.stringify(body);
} }
if (Object.keys(headers).length) init.headers = headers;
try { try {
res = await fetch(path, init); res = await fetch(path, init);
} catch (e) { } catch (e) {
@@ -77,6 +86,7 @@
if (!json.ok) { if (!json.ok) {
setConn(true); setConn(true);
const code = (json.error && json.error.code) || 'ERROR'; const code = (json.error && json.error.code) || 'ERROR';
if (code === 'UNAUTHORIZED') authRequired(); // 会话失效/未认证 → 弹登录页
throw { code: code, message: (json.error && json.error.message) || ('后端错误 ' + code), status: res.status }; throw { code: code, message: (json.error && json.error.message) || ('后端错误 ' + code), status: res.status };
} }
setConn(true); setConn(true);
@@ -86,7 +96,10 @@
// 下载(返回 blob URL) // 下载(返回 blob URL)
async function apiDownload(path) { async function apiDownload(path) {
let res; let res;
try { res = await fetch(path); } const headers = {};
const token = storageGet('meowpanel.token');
if (token) headers['X-MeowToken'] = token;
try { res = await fetch(path, Object.keys(headers).length ? { headers: headers } : undefined); }
catch (e) { setConn(false); throw { code: 'NETWORK', message: '后端未连接' }; } catch (e) { setConn(false); throw { code: 'NETWORK', message: '后端未连接' }; }
if (!res.ok) { setConn(true); throw { code: 'HTTP_' + res.status, message: '下载失败(HTTP ' + res.status + ')' }; } if (!res.ok) { setConn(true); throw { code: 'HTTP_' + res.status, message: '下载失败(HTTP ' + res.status + ')' }; }
setConn(true); setConn(true);
@@ -115,6 +128,65 @@
}); });
} }
/* ---------------- 鉴权(可选密码登录) ---------------- */
function authRequired() { // 任一路由收到 401 → 弹登录页
state.unauth = true;
showLogin();
}
function showLogin() {
state.unauth = true;
const lg = $('#login'); if (lg) lg.hidden = false;
const ip = $('#login-password'); if (ip) ip.focus();
}
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 btn = $('#login-btn'); if (btn) btn.disabled = true;
try {
const d = await api('POST', '/api/auth/login', { password: pw });
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; }
} 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(); });
}
function passwordDlg(title, body) {
return new Promise(res => {
$('#pwdlg-title').textContent = title || '设置面板密码';
$('#pwdlg-body').textContent = body || '请输入新密码(留空则清除密码)。';
const inp = $('#pwdlg-input'); inp.value = ''; inp.focus();
const scrim = $('#pwdlg'); scrim.hidden = false;
const okb = $('#pwdlg-ok'), cancel = $('#pwdlg-cancel');
const done = v => { scrim.hidden = true; okb.onclick = cancel.onclick = scrim.onclick = inp.onkeydown = null; res(v); };
okb.onclick = () => done(inp.value);
cancel.onclick = () => done(null);
scrim.onclick = e => { if (e.target === scrim) done(null); };
inp.onkeydown = e => { if (e.key === 'Enter') done(inp.value); if (e.key === 'Escape') done(null); };
});
}
async function setPanelPassword() {
const np = await passwordDlg('设置面板密码', '请输入新密码(留空则清除密码)。');
if (np === null) return; // 取消
try {
await api('PUT', '/api/settings/panel.auth_pass', { value: np });
toast(np ? '面板密码已设置' : '面板密码已清除');
renderSettings();
} catch (e) { toast('设置失败:' + errText(e)); }
}
function bindPwDlg() { /* #content 点击委托在 bindSettings 里处理 pass 按钮;此处无需额外绑定 */ }
/* ---------------- 主题(MD3/MIUIx × 深/浅) ---------------- */ /* ---------------- 主题(MD3/MIUIx × 深/浅) ---------------- */
function applyTheme(style, theme) { function applyTheme(style, theme) {
state.style = style; state.theme = theme; state.style = style; state.theme = theme;
@@ -487,6 +559,11 @@
/* ---------------- 5. 设置镜像页 ---------------- */ /* ---------------- 5. 设置镜像页 ---------------- */
function settingControl(it) { function settingControl(it) {
const key = it.key; const key = it.key;
if (key === 'panel.auth_pass') {
const txt = it.set ? '已设置' : '未设置';
return '<div class="field-row"><div style="flex:1" class="field-label">面板密码<span class="muted">(' + txt + ')</span></div>' +
'<button class="btn btn-primary" data-skey="panel.auth_pass" data-stype="pass">' + (it.set ? '修改密码' : '设置密码') + '</button></div>';
}
if (it.type === 'bool') { if (it.type === 'bool') {
return '<div class="field-row"><div style="flex:1" class="field-label"><div style="margin:0">' + esc(key) + '</div></div>' + return '<div class="field-row"><div style="flex:1" class="field-label"><div style="margin:0">' + esc(key) + '</div></div>' +
'<label class="switch"><input type="checkbox" data-skey="' + esc(key) + '" data-stype="bool" ' + (it.value ? 'checked' : '') + '>' + '<label class="switch"><input type="checkbox" data-skey="' + esc(key) + '" data-stype="bool" ' + (it.value ? 'checked' : '') + '>' +
@@ -515,11 +592,18 @@
'<button class="btn btn-danger" id="set-restore">恢复全部默认</button>' + '<button class="btn btn-danger" id="set-restore">恢复全部默认</button>' +
'<button class="btn" id="set-refresh">刷新</button></div>'; '<button class="btn" id="set-refresh">刷新</button></div>';
const items = d.items || []; const items = d.items || [];
// 面板鉴权(可选防护)单独一卡
const authItems = items.filter(x => x.key === 'panel.auth_enabled' || x.key === 'panel.auth_pass');
if (authItems.length) {
h += '<div class="card"><h3 class="card-title">面板鉴权(可选防护)</h3>' + authItems.map(settingControl).join('') +
'<p class="muted" style="margin-top:8px">开启后访问面板需密码登录;密码以加盐哈希存储,不落明文。重启/关机/OTA 等危险操作亦需会话。</p></div>';
}
const rest = items.filter(x => x.key !== 'panel.auth_enabled' && x.key !== 'panel.auth_pass');
// 按类型分组展示(bool 独立一卡,int/enum/str 各自卡片),便于查看 // 按类型分组展示(bool 独立一卡,int/enum/str 各自卡片),便于查看
const groups = [['开关/布尔', items.filter(x => x.type === 'bool')], const groups = [['开关/布尔', rest.filter(x => x.type === 'bool')],
['数值/滑条', items.filter(x => x.type === 'int')], ['数值/滑条', rest.filter(x => x.type === 'int')],
['枚举/选择', items.filter(x => x.type === 'enum')], ['枚举/选择', rest.filter(x => x.type === 'enum')],
['字符串/路径', items.filter(x => x.type === 'str')]]; ['字符串/路径', rest.filter(x => x.type === 'str')]];
groups.forEach(function (g) { groups.forEach(function (g) {
if (!g[1].length) return; if (!g[1].length) return;
h += '<div class="card"><h3 class="card-title">' + g[0] + '</h3>' + g[1].map(settingControl).join('') + '</div>'; h += '<div class="card"><h3 class="card-title">' + g[0] + '</h3>' + g[1].map(settingControl).join('') + '</div>';
@@ -542,6 +626,7 @@
else if (t.dataset.stype === 'str') updateSetting(sk, t.value, 'str'); else if (t.dataset.stype === 'str') updateSetting(sk, t.value, 'str');
}); });
c.addEventListener('click', async function (e) { c.addEventListener('click', async function (e) {
if (e.target.closest('[data-stype="pass"]')) { setPanelPassword(); return; }
if (e.target.closest('#set-restore')) { if (e.target.closest('#set-restore')) {
const ok = await confirmDlg('恢复全部默认', '将把全部设置恢复为出厂默认(含主题/亮度/音量)。继续?', true); const ok = await confirmDlg('恢复全部默认', '将把全部设置恢复为出厂默认(含主题/亮度/音量)。继续?', true);
if (!ok) return; if (!ok) return;
@@ -738,6 +823,7 @@
$('#menu-btn').addEventListener('click', () => $('#sidebar').classList.toggle('open')); $('#menu-btn').addEventListener('click', () => $('#sidebar').classList.toggle('open'));
} }
function route() { function route() {
if (state.unauth) { showLogin(); return; } // 未认证 → 登录页
const id = (location.hash || '#/perf').replace('#/', '').split('?')[0] || 'perf'; const id = (location.hash || '#/perf').replace('#/', '').split('?')[0] || 'perf';
const r = ROUTES[id] || ROUTES.perf; const r = ROUTES[id] || ROUTES.perf;
state.route = id; state.route = id;
@@ -748,14 +834,26 @@
} }
/* ---------------- 启动 ---------------- */ /* ---------------- 启动 ---------------- */
function init() { let started = false;
bindNav(); function startApp() {
bindThemeUi(); if (started) { route(); return; }
bindSlider(); started = true;
bindFiles(); bindApps(); bindSettings(); bindRemote(); bindSystem(); bindLogs(); bindNotif();
initTheme(); initTheme();
window.addEventListener('hashchange', route); window.addEventListener('hashchange', route);
route(); route();
} }
async function init() {
bindNav();
bindThemeUi();
bindSlider();
bindFiles(); bindApps(); bindSettings(); bindRemote(); bindSystem(); bindLogs(); bindNotif();
bindLogin();
bindPwDlg();
// 首次进入探鉴权状态:启用且无有效会话 → 登录页;否则直接进应用
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();
}
document.addEventListener('DOMContentLoaded', init); document.addEventListener('DOMContentLoaded', init);
})(); })();