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:
1 parent
5f7e7542bd
commit
feb3df681c
7 files changed
+701
-35
No files matched your search
@@ -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://<host>.local:8080/
|
||||
- mDNS 由板载 `meow_mdns` 应答 `<host>.local`。
|
||||
- 端口默认 **8080**,可用 `-p PORT` / 环境变量 `MEOWPORT` / 与后端同目录 `meowpanel.conf`(`port=N`)覆盖。
|
||||
(优先级:`-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 | `/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://<host>.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":<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)
|
||||
@@ -243,8 +265,9 @@ http://<host>.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://<host>.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 # 本文件
|
||||
```
|
||||
Binary file not shown.
+522
-14
@@ -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 <token>) */
|
||||
char x_meow_token[256]; /* X-MeowToken 头 */
|
||||
char cookie[512]; /* Cookie 头(meow_token=<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 <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 响应写入 ---------------- */
|
||||
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);
|
||||
|
||||
Binary file not shown.
@@ -100,6 +100,30 @@
|
||||
</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>
|
||||
|
||||
<script src="meowpanel.js"></script>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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 '<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') {
|
||||
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' : '') + '>' +
|
||||
@@ -515,11 +592,18 @@
|
||||
'<button class="btn btn-danger" id="set-restore">恢复全部默认</button>' +
|
||||
'<button class="btn" id="set-refresh">刷新</button></div>';
|
||||
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 各自卡片),便于查看
|
||||
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 += '<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');
|
||||
});
|
||||
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);
|
||||
})();
|
||||
Reference in new issue
Block a user