diff --git a/patch_v2/meowpanel/README.md b/patch_v2/meowpanel/README.md index 47b67dd..76b057f 100644 --- a/patch_v2/meowpanel/README.md +++ b/patch_v2/meowpanel/README.md @@ -28,9 +28,10 @@ cd /home/Aska/Project/R3ProII/patch_v2/meowpanel - 两分支均以 `-Os -Wall -Wextra` 复核,**0 warning**。 - 产出(本次构建实测): - `meowpanel`:mipsel 静态,`ELF 32-bit LSB executable, MIPS, MIPS32 rel2, statically linked, stripped`, - 体积约 **166 KB**(固件阈值内)。md5:`11ed76ecb2e1c5773a01f0fb056a2df5`。 - - `meowpanel_host`:aarch64 动态链接(宿主编译,仅供调试),体积约 **77 KB**。 -- 依赖:仅 libc + Linux 内核头(`linux/input.h`),零第三方库(设备无 python3/lua)。 + 体积约 **183 KB**(固件阈值内)。md5:`f91856a44de0c8d12aba00cd40b44b91`。 + - `meowpanel_host`:aarch64 动态链接(宿主编译,仅供调试),体积约 **142 KB**。 +- 依赖:仅 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_*`, **header 为权威源**,结构体字段以它为准)。 @@ -64,7 +65,9 @@ http://.local:8080/ - mDNS 由板载 `meow_mdns` 应答 `.local`。 - 端口默认 **8080**,可用 `-p PORT` / 环境变量 `MEOWPORT` / 与后端同目录 `meowpanel.conf`(`port=N`)覆盖。 (优先级:`-p` > `MEOWPORT` > `meowpanel.conf` > 默认 8080。) -- 无鉴权(设备内网信任模型;token 鉴权为 MEOWPANEL_PROTOCOL §1.3 草案项,未实现)。 +- 无鉴权(设备内网信任模型;**可选**密码鉴权已实现,见 §5.9)。默认关=零回归;开启后除登录/登出/状态外 `/api/*` 需会话。 + - 上传/下载/管理类请求统一带 `X-MeowToken: `(或 `Authorization: Bearer ` / `Cookie: meow_token=`); + 缺/错/过期 → 401 `UNAUTHORIZED`。**默认无鉴权时请勿把 8080 暴露公网**。 --- @@ -75,6 +78,9 @@ http://.local:8080/ | 方法 | 路径 | 说明 | 实现 | |---|---|---|---| | GET | `/`、`/meowpanel.css`、`/meowpanel.js`、`/assets/` | 静态托管 | ✅ | +| 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/perf` | 仅性能(subset=perf 透传) | ✅ | | GET | `/api/system` | perf IPC + boot/network/ota 局部补全(network 含蓝牙,无网口;`ota` 只读检查,勿误触升级) | ✅ | @@ -211,6 +217,22 @@ http://.local:8080/ - **并发**:面板清空/删除用「写临时 + rename」重写日志;launcher 侧仍以 `O_APPEND` 追加,二者为竞写(低并发、会话级日志, 可接受),已写入「已知边界」(§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":`,**绝不回传存量哈希/明文**。 +- **门禁**:`require_auth` 在路由分发前执行——未启用直接放行(零回归);启用后除 `/api/auth/{login,logout,status}` 外全部 + `/api/` 需有效会话(token 头 / `X-MeowToken` / `Cookie meow_token`),缺/错/过期 → 401 `UNAUTHORIZED`。 + 危险端点(reboot/shutdown/ota、key 注入)在未开启时**不额外限制**(保持内网模型),开启后亦统一纳入会话校验。 +- **密码**:`PUT /api/settings/panel.auth_pass` 传明文,后端生成 16B 盐(/dev/urandom)→ `salt:hex`(sha256(salt||password)) + 落盘;空串=清除。校验时重算并恒定比较。**哈希/明文绝不进日志/响应**。`panel.auth_enabled` 置 1 时若未设密码 → 422 `请先设置面板密码`。 +- **会话**:`POST /api/auth/login` 校验后发 token(32B → 64 hex),存内存(`AUTH_MAX_SESS`=8,满淘汰最早过期)+ 卡上 + `/meowpanel.session`(` `,重启加载、防吊销失效),有效期 12h。`logout` 失效当前 token。 +- **静态屏蔽**:`serve_static` 额外拒绝后端可执行文件与 `meowpanel.session`(403),防 token 文件被 HTTP 读取。 +- **前端**:api 客户端统一带 `X-MeowToken`,收到 401 → 弹登录页(`#login` overlay);登录成功 token 存 `localStorage`; + 首次进入以 `/api/auth/status` 决定是否弹登录。设置页「面板鉴权」卡片含开关(`panel.auth_enabled`)+「设置/修改密码」 + 按钮(`panel.auth_pass`,走 `passwordDlg` 输入)。 + --- ## 6. 与协议的出入(「实现为准 + 原因」,供回改 MEOWPANEL_PROTOCOL.md) @@ -243,8 +265,9 @@ http://.local:8080/ ## 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 电源菜单/后端同链路。 + **可选鉴权**(§5.9)开启后可对内网作加固(登录密码 + 会话),但密码经**局域网明文 HTTP** 传输,不提供 TLS。 - **IPC 服务未起**:`meow_svc_perf`/`meow_svc_power` 为板载 `trigger=boot` 服务,正常恒在;若异常退出,读侧返回 500 `INTERNAL`, 动作侧返回 500/503,绝不静默。请勿拔除这两服务。 - **升级保护**:`POWER_UPGRADE` 由 `meow_svc_power` 在写 mtd5 前校验卡根 `r3proii.upt`(存在/可读/非空/size>0),失败不写 mtd5;期望 md5/hash 校验留二期。 @@ -266,15 +289,15 @@ http://.local:8080/ ``` 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) -├── meowpanel # 交叉产物(mipsel 静态,约 174 KB,md5 58a7b186…) +├── meowpanel # 交叉产物(mipsel 静态,约 183 KB,md5 f91856a4…) ├── meowpanel_host # 宿主编译(本机 ld,调试用) ├── services.d/ │ └── meowpanel.conf # 部署到 TF 卡 MeowOS/services.d/meowpanel.conf ├── web/ # 前端(已存在;部署时拷到后端同目录) -│ ├── index.html -│ ├── meowpanel.css -│ └── meowpanel.js +│ ├── index.html # 含登录页 overlay + 设置密码对话框 +│ ├── meowpanel.css # 含登录页样式 +│ └── meowpanel.js # API 客户端带 token/401 登录、设置页鉴权卡片 └── README.md # 本文件 ``` diff --git a/patch_v2/meowpanel/meowpanel b/patch_v2/meowpanel/meowpanel index 15cd164..e9a8058 100755 Binary files a/patch_v2/meowpanel/meowpanel and b/patch_v2/meowpanel/meowpanel differ diff --git a/patch_v2/meowpanel/meowpanel.c b/patch_v2/meowpanel/meowpanel.c index 9eb0d8d..c3625c4 100644 --- a/patch_v2/meowpanel/meowpanel.c +++ b/patch_v2/meowpanel/meowpanel.c @@ -56,6 +56,8 @@ #define DEFAULT_PORT 8080 #define SETTINGS_FILE "/usr/data/meow/settings.conf" #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_ROOT 保持为「数组宏」而非指针——让 -Wformat-truncation 能按 1024 尺寸分析拼接,避免误报。 */ 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_file(int sock, int status, const char *ctype, const char *abspath, const char *filename); static void panel_log(const char *fmt, ...); +static const char *web_root(void); /* 定义在文件后部;此处前向声明供鉴权会话路径使用 */ /* ---------------- CSV(enum 名/标签)非改写取值;定义在设置列表区,先声明供 settings_coerce 用 ---------------- */ 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, "网络" }, /* 系统 */ { "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])) @@ -492,10 +498,40 @@ static const setdef_t *setdef_find(const char *key) { /* ---------------- settings.conf 解析(照 meow_ui_vol_read / settings_get_int_trim) ---------------- * '=' 切分,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) { - FILE *f = fopen(SETTINGS_FILE, "r"); + FILE *f = fopen(g_settings_file, "r"); if (!f) return NULL; size_t kl = strlen(key); char line[256]; @@ -544,8 +580,8 @@ static int brightness_clamp(int v) * O_TRUNC 重写。成功返回 0。 */ static int settings_write_one(const char *key, const char *val) { - mkdir("/usr/data/meow", 0777); /* 目录缺失自动建(与 meow_settings_save 同;失败由 fopen 兜底) */ - FILE *f = fopen(SETTINGS_FILE, "r"); + settings_mkdir_p(); /* 目录缺失自动建(与 meow_settings_save 同;失败由 fopen 兜底) */ + FILE *f = fopen(g_settings_file, "r"); char tmp[2048]; size_t n = 0; int c; @@ -554,22 +590,28 @@ static int settings_write_one(const char *key, const char *val) fclose(f); } tmp[n] = 0; - FILE *nf = fopen(SETTINGS_FILE, "w"); + FILE *nf = fopen(g_settings_file, "w"); if (!nf) return -1; size_t klen = strlen(key); int found = 0; if (n == 0) fprintf(nf, "%s\n", SETTINGS_HDR); - /* 逐行重写:头照抄;命中 key 行替换;其余照抄 */ + /* 逐行重写:头照抄;命中 key 行替换;其余照抄。匹配容忍 `key = value` 两侧空格(与 settings_get_value 同口径)。 */ char *save = NULL; for (char *ln = strtok_r(tmp, "\n", &save); ln; ln = strtok_r(NULL, "\n", &save)) { char *p = ln; while (*p == ' ' || *p == '\t') p++; - if (!strncmp(p, key, klen) && p[klen] == '=') { - fprintf(nf, "%s = %s\n", key, val); - found = 1; - } else { - fprintf(nf, "%s\n", ln); + char *eq = strchr(p, '='); + if (eq) { + char *k = p; + size_t km = (size_t)(eq - k); + 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); fclose(nf); @@ -630,8 +672,8 @@ static int settings_coerce(const setdef_t *d, const char *input, char *out, size /* 恢复全部默认:重写整个文件为 头 + 所有键 def。 */ static int settings_reset_all(void) { - mkdir("/usr/data/meow", 0777); - FILE *nf = fopen(SETTINGS_FILE, "w"); + settings_mkdir_p(); + FILE *nf = fopen(g_settings_file, "w"); if (!nf) return -1; fprintf(nf, "%s\n", SETTINGS_HDR); for (int i = 0; i < SETS_COUNT; i++) @@ -1232,6 +1274,9 @@ typedef struct { char *body; size_t body_len; char content_type[128]; /* 请求 Content-Type(若非空) */ + char authorization[256]; /* Authorization 头(Bearer ) */ + char x_meow_token[256]; /* X-MeowToken 头 */ + char cookie[512]; /* Cookie 头(meow_token= 等) */ } req_t; 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++; 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; } char *body_start = buf + hend + 4; /* body 实际起点 */ @@ -1396,6 +1456,391 @@ static int read_request(int sock, req_t *r) return 0; } +/* ================= 可选鉴权(panel.auth_enabled / panel.auth_pass) ================= + * 设计(MEOWPANEL_PROTOCOL §1.3 落地): + * - 键 `panel.auth_enabled`(默认 0=关)开关;`panel.auth_pass` 存「sha256 带盐哈希」格式 `salt:hex`, + * 绝不落明文,也绝不进日志/响应。 + * - 启用后,除 /api/auth/{login,logout,status} 外,所有 /api/ 端点需有效会话 + * (`Authorization: Bearer ` / `X-MeowToken: ` / `Cookie: meow_token=`)。 + * - 会话 token 由 /dev/urandom 生成(32B → 64 hex),存内存 + 卡上文件持久化(防重启失效),带过期。 + * - 未启用(默认)时鉴权门禁直接放行,行为与现状完全一致(零回归);危险端点不额外限制。 + */ +#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 由 /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 响应写入 ---------------- */ 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) { sA(s, "%s", str_truthy(val) ? "true" : "false"); } 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_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 类型不支持"); } 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]; if (settings_coerce(d, raw, coerced, sizeof coerced) < 0) { 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; 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]; if (settings_coerce(d, raw, coerced, sizeof coerced) < 0) { if (nfail) sA(&fail, ","); @@ -1845,6 +2334,13 @@ 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()) { + 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 (nok) sA(&ok, ","); sA_str(&ok, p->str); @@ -2961,7 +3457,8 @@ static int serve_static(int sock, const char *path) /* 敏感名拒绝 */ const char *base = strrchr(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"); if (!f) return send_err(sock, 404, "NOT_FOUND", "资源不存在"); fclose(f); @@ -2978,6 +3475,14 @@ static int handle(int sock, req_t *r) 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"))) return api_perf(sock, p); @@ -3118,6 +3623,8 @@ int main(int argc, char **argv) if (env && env[0]) g_sock_perf = env; env = getenv("MEOW_IPC_POWER_SOCK"); 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 优先) */ /* 优先级:-p 已设则保留;否则用 conf/env(read_port_config 会写 g_port) */ @@ -3132,6 +3639,7 @@ int main(int argc, char **argv) signal(SIGPIPE, SIG_IGN); web_root(); + sess_load(); /* 加载卡上持久化的会话(防重启失效) */ panel_log("MeowPanel %s listening on port %d (web root %s)", PANEL_VERSION, g_port, web_root()); int lfd = socket(AF_INET, SOCK_STREAM, 0); diff --git a/patch_v2/meowpanel/meowpanel_host b/patch_v2/meowpanel/meowpanel_host index 1096255..8962f61 100755 Binary files a/patch_v2/meowpanel/meowpanel_host and b/patch_v2/meowpanel/meowpanel_host differ diff --git a/patch_v2/meowpanel/web/index.html b/patch_v2/meowpanel/web/index.html index b2c7b73..95d2d78 100644 --- a/patch_v2/meowpanel/web/index.html +++ b/patch_v2/meowpanel/web/index.html @@ -100,6 +100,30 @@ + + + + + + diff --git a/patch_v2/meowpanel/web/meowpanel.css b/patch_v2/meowpanel/web/meowpanel.css index a5ce810..998e950 100644 --- a/patch_v2/meowpanel/web/meowpanel.css +++ b/patch_v2/meowpanel/web/meowpanel.css @@ -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-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; } .skel{ color:var(--on-surface-variant); } diff --git a/patch_v2/meowpanel/web/meowpanel.js b/patch_v2/meowpanel/web/meowpanel.js index 58e284d..65d6caf 100644 --- a/patch_v2/meowpanel/web/meowpanel.js +++ b/patch_v2/meowpanel/web/meowpanel.js @@ -10,6 +10,10 @@ const $ = (sel, root) => (root || document).querySelector(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) { return String(s == null ? '' : s).replace(/[&<>"']/g, m => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' @@ -38,6 +42,7 @@ filesRel: '/', filesAbs: null, perfTimer: null, // 性能页轮询 route: 'perf', + unauth: false, // 鉴权未通过(弹登录页) downloadNonce: 0 }; @@ -54,13 +59,17 @@ // 返回 {code,message,status} 形异常;网络/非 JSON → 视为断连 async function api(method, path, body) { let res; + const headers = {}; + const token = storageGet('meowpanel.token'); + if (token) headers['X-MeowToken'] = token; const init = { method: method || 'GET' }; if (body instanceof FormData) { init.body = body; } else if (body !== undefined && body !== null) { - init.headers = { 'Content-Type': 'application/json' }; + headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(body); } + if (Object.keys(headers).length) init.headers = headers; try { res = await fetch(path, init); } catch (e) { @@ -77,6 +86,7 @@ if (!json.ok) { setConn(true); 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 }; } setConn(true); @@ -86,7 +96,10 @@ // 下载(返回 blob URL) async function apiDownload(path) { 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: '后端未连接' }; } if (!res.ok) { setConn(true); throw { code: 'HTTP_' + res.status, message: '下载失败(HTTP ' + res.status + ')' }; } 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 × 深/浅) ---------------- */ function applyTheme(style, theme) { state.style = style; state.theme = theme; @@ -487,6 +559,11 @@ /* ---------------- 5. 设置镜像页 ---------------- */ function settingControl(it) { const key = it.key; + if (key === 'panel.auth_pass') { + const txt = it.set ? '已设置' : '未设置'; + return '
面板密码(' + txt + ')
' + + '
'; + } if (it.type === 'bool') { return '
' + esc(key) + '
' + '
'; const items = d.items || []; + // 面板鉴权(可选防护)单独一卡 + const authItems = items.filter(x => x.key === 'panel.auth_enabled' || x.key === 'panel.auth_pass'); + if (authItems.length) { + h += '

面板鉴权(可选防护)

' + authItems.map(settingControl).join('') + + '

开启后访问面板需密码登录;密码以加盐哈希存储,不落明文。重启/关机/OTA 等危险操作亦需会话。

'; + } + const rest = items.filter(x => x.key !== 'panel.auth_enabled' && x.key !== 'panel.auth_pass'); // 按类型分组展示(bool 独立一卡,int/enum/str 各自卡片),便于查看 - const groups = [['开关/布尔', items.filter(x => x.type === 'bool')], - ['数值/滑条', items.filter(x => x.type === 'int')], - ['枚举/选择', items.filter(x => x.type === 'enum')], - ['字符串/路径', items.filter(x => x.type === 'str')]]; + const groups = [['开关/布尔', rest.filter(x => x.type === 'bool')], + ['数值/滑条', rest.filter(x => x.type === 'int')], + ['枚举/选择', rest.filter(x => x.type === 'enum')], + ['字符串/路径', rest.filter(x => x.type === 'str')]]; groups.forEach(function (g) { if (!g[1].length) return; h += '

' + g[0] + '

' + g[1].map(settingControl).join('') + '
'; @@ -542,6 +626,7 @@ else if (t.dataset.stype === 'str') updateSetting(sk, t.value, 'str'); }); c.addEventListener('click', async function (e) { + if (e.target.closest('[data-stype="pass"]')) { setPanelPassword(); return; } if (e.target.closest('#set-restore')) { const ok = await confirmDlg('恢复全部默认', '将把全部设置恢复为出厂默认(含主题/亮度/音量)。继续?', true); if (!ok) return; @@ -738,6 +823,7 @@ $('#menu-btn').addEventListener('click', () => $('#sidebar').classList.toggle('open')); } function route() { + if (state.unauth) { showLogin(); return; } // 未认证 → 登录页 const id = (location.hash || '#/perf').replace('#/', '').split('?')[0] || 'perf'; const r = ROUTES[id] || ROUTES.perf; state.route = id; @@ -748,14 +834,26 @@ } /* ---------------- 启动 ---------------- */ - function init() { - bindNav(); - bindThemeUi(); - bindSlider(); - bindFiles(); bindApps(); bindSettings(); bindRemote(); bindSystem(); bindLogs(); bindNotif(); + let started = false; + function startApp() { + if (started) { route(); return; } + started = true; initTheme(); window.addEventListener('hashchange', 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); })();