会员服务器:文档补齐 v1.5.1(README 版本/功能更新,补缺失的 CHANGELOG v1.5.1 条目)
README:产物名 1.4.0→1.5.1;开头补当前版本说明与 1.5.0/1.5.1 特性; 项目云管理页补「删除选中」批量删除(1.5.1 新增,此前未记录); 修正过期的「已知边界:传输为 HTTP」(1.4.0 起已全站 HTTPS,仅剩自签证书告警边界)。 CHANGELOG:补写缺失的 v1.5.1 条目(此前最高只到 v1.5.0)。 新增首次上传时因 root 000 权限漏掉的 客户端对接示例.cs; 示例注释中的真实手机号/密码替换为占位符(仓库公开可读)。
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
# 更新日志
|
||||
|
||||
## v1.5.1 (2026-09-24 01:05:00)
|
||||
|
||||
### 新增(用户要求:项目云批量删除)
|
||||
|
||||
- **项目云「删除选中」**:`app/www/xiangmu-cloud.html` 此前每行只有单个「删除」按钮,清理某会员名下的多个项目需逐个点击确认,操作繁琐。
|
||||
已在工具栏新增红色「删除选中」按钮,表头加全选/全不选复选框,每行渲染时写入 `data-bh`(项目编号)供勾选行定位;
|
||||
点按钮后 `删除选中()` 收集所有 `.云sel` 勾选行,二次 `confirm` 确认后**串行**逐条调用 `POST /api/proj/delete`(串行而非并发,避免同一会员目录下多个删除请求争用文件系统);
|
||||
执行中显示 `正在删除 i/N ...` 进度,结束后提示成功/失败计数并自动调 `云查询()` 刷新列表。
|
||||
- **配套客户端对接文档**:新增 `客户端-删除接口对接文档.txt`,说明 `/api/proj/delete` 的两种调用方式(信封加密通道 / HTTPS 明文 JSON)与鉴权要求。
|
||||
|
||||
### 打包
|
||||
|
||||
- `manifest` 与 `app/server/server.cjs` 的 `VERSION` 同步升至 `1.5.1`,产物 `fnnas.huiyuan_1.5.1_all.fpk`。
|
||||
- 服务端仅版本号变更、无接口改动——客户端零改动即可继续使用。
|
||||
|
||||
### 说明
|
||||
|
||||
- 库备份管理页 `lib-cloud.html` 本次未加同款批量删除,需要时可照 `xiangmu-cloud.html` 的模式再做一份。
|
||||
|
||||
---
|
||||
|
||||
## v1.5.0 (2026-09-17 22:40:00)
|
||||
|
||||
### 新增(用户要求:正规 SSL 证书可直接导入使用)
|
||||
|
||||
- **SSL 证书导入**:设置页新增「SSL 证书」卡片——选择证书文件(fullchain 全链 .pem/.crt)+ 配对私钥(.key/.pem)→ 导入并立即生效(监听器整体热切换,毫秒级、无需重启;SNI 域名访问与 IP 直访全部覆盖)。导入前服务端校验:证书可解析、私钥与证书公钥匹配,不匹配拒绝并提示。卡片显示当前证书来源(自签/已导入)、域名(CN/SAN)、签发者、有效期、指纹。
|
||||
- **恢复自签**:一键回到自动生成的自签证书。
|
||||
- **证书/密钥跨升级保留**:`cmd/uninstall_init` 卸载前把 `tls.key/tls.crt/huiyuan_rsa.pem/app.env` 备份到数据目录 `.huiyuan-密钥备份/`,`cmd/install_init` 安装时自动恢复——升级重装不再丢失已导入的正规证书(此前每次重装 TLS/RSA 密钥都会重新生成、指纹变化)。
|
||||
|
||||
### 修复
|
||||
|
||||
- 导入/重置接口先响应本请求、再执行监听热切换(此前切换会掐断响应自身连接,客户端收不到结果)。
|
||||
- 设置页加载时补调证书信息接口(此前显示"加载中…"不刷新)。
|
||||
|
||||
### 验证
|
||||
|
||||
- 本地:导入 CN=ss61.odn.cc 证书 → 热切换后 SNI/非 SNI(IP 直访)新连接均为新证书;不匹配私钥/垃圾文件/缺文件/无 token 全部拒绝;重启后证书持久;重置回自签正常;GUI 上传导入全流程通过。
|
||||
- NAS 部署 1.5.0 验证通过,会员数据完好。
|
||||
|
||||
### 说明(签发 ss61.odn.cc 证书的前置条件)
|
||||
|
||||
- ss61.odn.cc 是 odn 分配的子域名,用户无法做 DNS 验证;签发正规证书需要 HTTP-01:在 odn 控制台把该域名 80 端口转发到 NAS 任一端口,然后 `acme.sh --issue -d ss61.odn.cc --standalone --httpport 该端口 --server letsencrypt`,把 `fullchain.cer` 与 `ss61.odn.cc.key` 在设置页导入。若 odn 不支持转发 80 端口,则只能信任自签(设置页下载证书安装)或让 odn 提供边缘证书。
|
||||
|
||||
---
|
||||
|
||||
## v1.4.2 (2026-09-17 22:10:00)
|
||||
|
||||
### 修复
|
||||
|
||||
- **静态页面无缓存策略导致升级后浏览器跑旧脚本**:`admin.html` 等页面此前无 `Cache-Control` 头,浏览器启发式缓存可能在应用升级后仍使用旧版页面(v1.4.1 的建号修复因此未在用户浏览器生效,表现为"点确定无反应")。静态资源统一加 `Cache-Control: no-cache`(每次使用前必须向服务器取新版),API 响应加 `Cache-Control: no-store`。
|
||||
- 用户浏览器需强制刷新一次(Ctrl+F5)以甩掉历史缓存,此后升级即时生效。
|
||||
|
||||
### 验证
|
||||
|
||||
- 在真实 NAS 上以全新浏览器完整复现建号流程(v1.4.1 修复本身有效):登录→新增会员→确定→提示"已创建会员"→列表刷新,无 JS 错误;响应头确认 `Cache-Control: no-cache` 生效。
|
||||
|
||||
---
|
||||
|
||||
## v1.4.1 (2026-09-17 22:00:00)
|
||||
|
||||
### 修复
|
||||
|
||||
- **管理页无法手动创建会员(点击确定无反应,用户报告)**:v1.4.0 将 `/api/register` 迁为客户端信封加密专用通道后,管理页"确定新增"仍在调用该已删除的直连接口——请求 404(text/plain)导致 `r.json()` 抛异常,且代码无错误捕获,表现为静默无反应。新增专用管理接口 `POST /api/admin/addmember`(管理密码保护,不受"关闭注册"开关影响,密码直接哈希入库,支持一次指定会员类型/有效期),管理页改用该接口一次调用完成建号(同时去掉了旧的"先注册再整表保存补类型"两步流程);`保存修改` 等请求补充失败提示,不再静默。
|
||||
- 验证:本地 GUI 完整建号流程(弹窗→确定→列表刷新→新会员信封登录,类型/有效期/哈希全部正确)、重复/坏手机号/短密码/无 token 拒绝、关闭注册不影响管理员建号。
|
||||
|
||||
---
|
||||
|
||||
## v1.4.0 (2026-09-17 21:50:00)
|
||||
|
||||
### 新增(用户要求:所有端口强制 HTTPS + 更高级加密通讯)
|
||||
|
||||
- **全站强制 HTTPS,HTTP 彻底停用**:唯一监听为 HTTPS 端口(默认 12701,向导可改);自签证书首次启动由 OpenSSL 自动生成(存配置目录,升级保留);TLS 证书初始化失败服务拒绝启动。桌面入口 `app/ui/config` protocol 改为 https。HTTP 明文通道不存在——浏览器/客户端必须使用 https。
|
||||
- **安全响应头**:HSTS(max-age 1 年)、`X-Content-Type-Options: nosniff`、`X-Frame-Options: SAMEORIGIN`。
|
||||
- **应用层信封加密 `/api/enc`(第 2 层,凭据类通讯双保险)**:客户端随机 AES-256 密钥加密业务 JSON,AES 密钥用 RSA-2048 公钥封装(OAEP-SHA1)——私钥永不出服务器;HMAC-SHA256 验签防篡改;时间戳 ±5 分钟窗口 + nonce 缓存防重放。覆盖 登录/注册/查会员/改密/项目云列表/删除;项目 zip 上传/下载走 TLS 直连(大文件)。服务端 RSA 密钥对首次启动生成(PKCS8 私钥 600 权限存配置目录)。
|
||||
- **`/api/pubkey`**(下发公钥+指纹)、**`/api/cert`**(下载 TLS 证书供安装信任)、`GET /api/settings` 增加 `tlsfp`/`keyfp` 指纹。
|
||||
- **设置页「加密通道」卡片**:加密地址(自动取当前域名)、TLS/RSA 指纹、下载证书按钮。
|
||||
- **客户端对接参考实现**:项目内新增 `客户端对接示例.cs`(.NET 3.5+ 零依赖:证书信任、TLS1.2、RSA-OAEP、AES-CBC、HMAC、信封打包解包、上传/下载,含调用示例)。
|
||||
|
||||
### 变更(破坏性)
|
||||
|
||||
- **客户端必须升级**:登录/注册/查会员/改密/项目云列表/删除 只能通过 `/api/enc` 信封加密调用;上传/下载改为 https 直连(带 `密码` 参数);不再有任何明文 HTTP 回退。业务字段与响应格式不变。
|
||||
- 浏览器首次访问出现自签证书警告(下载证书安装到受信任根后消除)。
|
||||
|
||||
### 验证
|
||||
|
||||
- 本地 python 模拟客户端:信封注册/登录/改密/查会员/项目云列表删除全流程通过;重放 nonce 拒绝、篡改密文拒绝、过期时间戳拒绝;`http://` 直连失败;证书下载正常;GUI 四页 https 回归无 JS 错误。
|
||||
- NAS 部署实测:证书/RSA 自动生成(新指纹)、真实会员 envelope 登录成功、项目云 7 条、磁盘无明文、http 连接被拒。
|
||||
|
||||
---
|
||||
|
||||
## v1.3.1 (2026-09-17 21:20:00)
|
||||
|
||||
### 修复
|
||||
|
||||
- **飞牛 ACL 下备份覆盖失败(EACCES)**:`备份库()` 原用 `copyFileSync` 直接覆盖已有 `.bak`,但飞牛 ACL 环境下已存在文件权限显示为 000,打开即 `EACCES`——v1.3.0 部署时迁移备份两次失败,导致 `.bak` 残留了升级前的明文密码。改为与 `写库` 一致的 tmp+rename 原子写(只需目录可写)。部署后已触发保存刷新备份,残留明文已清除(实测主库与 .bak 均无明文)。
|
||||
|
||||
---
|
||||
|
||||
## v1.3.0 (2026-09-17 21:11:00)
|
||||
|
||||
### 新增
|
||||
|
||||
- **会员密码哈希存储(客户端零改动)**:存储格式 `盐(32hex)$哈希(64hex)`,每会员独立随机盐,`sha256(盐+密码)`。传输协议完全不变——客户端仍发明文,服务端哈希后比对/存储;客户端的修改密码(/api/changepwd)、登录、项目云带密码鉴权全部照常。
|
||||
- **启动自动迁移**:服务启动扫描会员库,明文密码一次性升级为哈希(先备份,成功后用哈希版刷新 `.bak`,磁盘不残留明文);NAS 真实数据已自动迁移并验证原密码可登录。
|
||||
- **`/api/save` 密码语义升级**:空=保持现有密码;明文=管理员重置该会员密码(哈希入库);符合哈希格式=原样保留。
|
||||
- **管理页密码列改重置式**:`type="password"` 空输入框(占位"留空不变"),不再显示任何密码内容;表头加说明提示。
|
||||
|
||||
### 验证
|
||||
|
||||
- 磁盘主库与备份全库无明文;注册/登录/改密/项目云鉴权/管理页重置密码全流程本地+NAS 实测通过;客户端协议零改动实测(原密码登录、changepwd、项目云带密码)。
|
||||
|
||||
---
|
||||
|
||||
## v1.2.0 (2026-09-17 21:00:00)
|
||||
|
||||
### 新增(安全加固)
|
||||
|
||||
- **管理密码登录(核心)**:安装向导必填 `wizard_admin_pwd`(≥6 位,sha256+盐存 `app.env`,不存明文)。`POST /api/admin/login` 换随机 token(内存 Map,24h 过期),后续请求带 `X-Admin-Token` 头。**受保护接口**:`/api/list`(会员明文密码表)、`/api/save`、`/api/settings`(GET+POST)、`/api/admin/changepwd`——查看会员密码、改库、迁移目录都必须先登录。自定义头跨站请求带不了,同时天然防 CSRF。
|
||||
- **共享登录门 `www/auth.js`**:四页引入,无有效 token 时全屏遮罩要求输入管理密码;包装 fetch 对同源 `/api/` 请求自动附加 token(页面业务代码零改动);登录态存 localStorage,退出登录/改密后可重新登录。
|
||||
- **项目云接口会员鉴权(需客户端配合升级)**:`/api/proj/upload`(JSON 头加 `密码`)、`/api/proj/list`、`/api/proj/download`(URL 加 `密码=`)、`/api/proj/delete`(body 加 `密码`)——会员密码匹配或管理 token 二选一通过,失败统一返回"手机号或密码错误"。管理页走 token 查任意会员数据不受影响。
|
||||
- **设置页"安全"卡片**:修改管理密码、开放注册开关(`HY_REG_OPEN` 持久化到 app.env)、退出登录。
|
||||
- **`GET /api/version` 公开接口**:仅返回版本号,四页徽章用,避免登录前请求敏感接口。
|
||||
|
||||
### 加固
|
||||
|
||||
- **限速**:`/api/login`、`/api/register`、`/api/admin/login` 失败按 IP 计数(内存),1 分钟内失败 ≥10 次锁 60 秒。
|
||||
- **注册校验**:手机号必须 11 位(^1\d{10}$)、密码 4-64 位;开放注册可关闭。
|
||||
- **上传配额**:每会员项目云总容量上限 1GB(写入前估算目录大小),超限拒绝。
|
||||
- **数据备份**:整表保存(/api/save)与目录迁移前滚动写 `huiyuan.json.bak`。
|
||||
|
||||
### 修改
|
||||
|
||||
- **`cmd/install_callback` 改合并式写 app.env**:原全量重写会抹掉设置页保存的 `HY_REG_OPEN` 等键;现只覆盖 `HY_PORT`/`HY_DB_DIR`/`ADMIN_*`,其余键保留。`cmd/config_callback` 支持更新管理密码。
|
||||
- **向导**:install 增加管理密码(必填);config 增加管理密码(留空=不修改)。
|
||||
|
||||
### 决策记录
|
||||
|
||||
- 会员密码哈希存储:用户确认**本期保持明文**(管理页可查看),下次要求时再改造(登录协议不变、存量自动升级的方案已评估可行)。
|
||||
- 传输保持 HTTP:客户端直连无法改 TLS;管理页可远期接飞牛统一网关获得系统级保护。
|
||||
- 客户端升级前项目云四接口会返回"手机号或密码错误"(老协议不带密码),客户端需按 README 对照表同步加参数。
|
||||
|
||||
---
|
||||
|
||||
## v1.1.2 (2026-09-17 13:00:00)
|
||||
|
||||
### 优化
|
||||
|
||||
- **四页面视觉统一(用户反馈布局乱)**:新增共享样式 `www/style.css`(导航/徽章/卡片/工具条/表格/按钮/输入/弹窗/设置选项块全套),admin、xiangmu-cloud、lib-cloud、settings 四页统一为同一外壳——左标题+版本徽章、右侧胶囊导航(当前页蓝色高亮)。JS 业务逻辑未动。
|
||||
- **导航补齐**:admin 原来缺"库备份管理"入口,现四页导航一致(会员管理|项目云管理|库备份管理|设置);每页标题旁自动显示版本号。
|
||||
- **表格页统一卡片式容器**(圆角+内嵌工具条),admin 会员表补上缺失的"操作"列表头;新增会员弹窗改为圆角+阴影+主按钮样式。
|
||||
|
||||
---
|
||||
|
||||
## v1.1.1 (2026-09-17 12:00:00)
|
||||
|
||||
### 删除
|
||||
|
||||
- **安装包不再携带任何会员数据(用户要求)**:v1.1.0 及之前的包内 `app/server/huiyuan.json` 含 2 名真实会员(手机号/密码明文),会随 fpk 分发泄露。彻底移除种子机制——删除包内种子文件、`server.cjs` 懒加载种子逻辑、`cmd/install_init` 种子复制、`.huiyuan-seeded` 标记。改为服务首次启动时若数据库缺失则创建空会员库 `{"会员": []}`。
|
||||
- 清理数据目录中已无用途的 `.huiyuan-seeded` 标记写入。
|
||||
|
||||
### 验证
|
||||
|
||||
- 全新环境启动 → 自动创建空会员库;注册/登录/项目云上传/列表/迁移(连项目云)/重启持久化全流程回归通过。
|
||||
- fpk 解包核验:`app.tgz` 内 server/ 仅剩 `server.cjs`,无任何会员数据。
|
||||
- NAS 已重装 v1.1.1:应用目录无会员文件,用户真实数据(2 名会员 + 7 条项目云)在数据目录中原样保留。
|
||||
|
||||
---
|
||||
|
||||
## v1.1.0 (2026-09-17 00:20:00)
|
||||
|
||||
### 新增
|
||||
|
||||
- **项目云(移植自源码 2026-09-16 更新)**:4 个新 API——`/api/proj/upload`(JSON 头+空行+zip 二进制,覆盖式)、`/api/proj/list`、`/api/proj/download`、`/api/proj/delete`;上传前校验会员已注册,手机号/编号文件名安全化+防路径穿越,元数据存各项目目录 `meta.json`。
|
||||
- **两个管理页(移植)**:`xiangmu-cloud.html` 项目云管理(按会员查看/下载/删除项目,过滤 LIB_ 前缀)、`lib-cloud.html` 库备份管理(只显示 LIB_ 前缀条目,解析"库名|原始路径");admin 导航合并为 会员管理|项目云管理|⚙设置,新页面也加设置链接。
|
||||
- **端口/主机环境变量对齐源码**:支持 `MEMBER_PORT`/`MEMBER_HOST`(优先级 HY_PORT → MEMBER_PORT → TRIM_SERVICE_PORT → 12701)。
|
||||
- **设置页显示项目云目录**:`GET /api/settings` 新增 `projroot` 字段,服务信息卡片增加"项目云目录"行。
|
||||
|
||||
### 修改
|
||||
|
||||
- **项目云存储位置改为 `DB_DIR/项目云`**(跟随数据目录设置),不放源码目录——升级会覆盖 target 目录导致数据丢失;源码版本为 ROOT/项目云,仅本地 Windows 用。
|
||||
- **切换数据目录时项目云整目录一并迁移**(rename 失败自动 cpSync+rmSync 跨卷兜底,失败不回滚仅提示)。
|
||||
- **设置页措辞"数据库目录"→"数据目录"**:迁移确认框说明同时迁移数据库与项目云。
|
||||
|
||||
### 加固
|
||||
|
||||
- **上传 2GB 上限**:流式累计超限即断开连接,防磁盘打满(超出正常项目/库体积,不影响使用)。
|
||||
- **下载响应加 `Content-Disposition`**:`filename*` UTF-8 百分号编码 + ASCII fallback,规避中文编号文件名在响应头崩溃/乱码的坑。
|
||||
|
||||
### 部署记录
|
||||
|
||||
- NAS 升级沿用 uninstall + `install-fpk -e env`(`wizard_dbdir` 直接带入数据目录,免配置恢复);用户真实项目云数据(1 项目 + 6 库备份)已迁至 `/vol2/1000/成套报价软件数据库/项目云/`,下载字节 md5 比对一致。
|
||||
- 踩坑:root 复制 ACL 目录数据到目标后必须 `chown -R fnnas.huiyuan + chmod`,且要核对层级(曾因弄平手机号一层导致列表为空)。
|
||||
|
||||
---
|
||||
|
||||
## v1.0.1 (2026-09-09 23:35:00)
|
||||
|
||||
### 修复
|
||||
|
||||
- **设置页"保存并迁移/授权新目录"按钮点击无任何效果**:`settings.html` 因 import SDK 使用了 `<script type="module">`,但按钮沿用内联 `onclick="..."`。module 作用域内定义的函数不在全局 window 上,内联 onclick 解析即抛 `ReferenceError`,POST `/api/settings` 从未发出——表现为"迁移不过去、一刷新又变回默认目录"(NAS 上 `info.log` 证实服务端从未收到迁移请求)。已改为模块内 `$('#btn-save').onclick = ...` 绑定,SDK 改为动态 `import()` 加载失败不影响其余功能。
|
||||
- **自定义路径行单选按钮被拉宽错位**:CSS `.custom input { width:420px }` 误伤行内 radio(实际渲染 420px 宽)。重写设置页布局:目录选项改为可点击整行的选项块(选中高亮、radio 固定 15px、徽章标签),服务信息两栏网格,按钮分主次,响应式适配。
|
||||
- **在自定义输入框打字不勾选对应单选**:新增 focus/input 捕获监听,输入即自动选中"自定义路径"并高亮。
|
||||
- **安装后会员数据丢失(NAS 实测 0 名会员)**:`cmd/install_init` 的种子复制在真实安装流程中未生效(生命周期阶段数据目录/路径时序不可靠)。改为服务启动时懒加载:数据库文件缺失且无 `.huiyuan-seeded` 标记时从安装包恢复初始数据,每目录仅一次;切换目录时若新目录无库文件则落空库+标记,防止已删会员"复活"。
|
||||
|
||||
### 优化
|
||||
|
||||
- **迁移顺序改为先写新文件→回读校验→再删旧文件**:任何一步失败都不丢数据;旧文件删不掉(ACL 异常)只提示不整体回滚。
|
||||
- **迁移/权限错误带具体原因**:返回错误码+消息(如 `ENOTDIR ...`),不再是笼统提示。
|
||||
- **配置目录回退可视化**:`GET /api/settings` 新增 `configuredDir` 字段;配置目录不可写回退默认时,设置页显示黄色警告条说明原因。
|
||||
|
||||
### 打包
|
||||
|
||||
- **`cmd/main` 引号笔误**:`printf ... > ${PID_FILE}"` 行尾多余引号导致 bash 语法错误(v1.0.0 已修,此处记录)。
|
||||
- **升级安装发现**:`appcenter-cli install-fpk` 对已装应用不执行升级(只提示 already installed),`install-local` 会先卸载导致应用丢失;正确做法是 `uninstall` + `install-fpk -e env文件`(注意:卸载会回收目录授权,需用户重新授权一次)。
|
||||
|
||||
---
|
||||
|
||||
## v1.0.0 (2026-09-09 23:00:00)
|
||||
|
||||
### 新增
|
||||
|
||||
- **首个版本**:将 `会员服务器/`(成套报价软件会员系统)打包为飞牛 fnOS 原生应用 `fnnas.huiyuan`。
|
||||
- **安装向导可改端口**:`wizard/install` 收集 `wizard_port`(默认 12701),`app/ui/config` 用 `${wizard_port}` 占位符使桌面入口跟随端口;`wizard/config` 支持装后在应用设置中修改(留空=不修改)。
|
||||
- **数据库目录可设置**:向导可填 `wizard_dbdir`(可选);运行时解析顺序 `HY_DB_DIR` → `TRIM_DATA_SHARE_PATHS`(应用数据目录)→ `TRIM_PKGVAR/files`,目录不可写自动降级;配置持久化于 `TRIM_PKGETC/app.env`。
|
||||
- **目录授权集成**:`config/resource` 声明 `api-scope: trim.file.sharedAccess` + `micro_app=true`,后端经 Unix Socket 调 `trim.file.getSharedAccessibleFolders` 查询管理员授权目录,网页设置页可调 SDK `pickSharedFile` 一键授权。
|
||||
- **网页设置页**:新增 `www/settings.html`——显示端口/版本/会员数/数据库文件完整路径,支持在默认目录/授权目录/自定义路径间切换并自动迁移 `huiyuan.json`(跨卷 rename 失败自动 copy+unlink);`www/admin.html` 加"设置"入口。
|
||||
|
||||
### 修改
|
||||
|
||||
- **绑定地址改为 0.0.0.0**:原版只绑 127.0.0.1,装到飞牛后局域网内的成套报价软件客户端无法连接。
|
||||
- **6 个客户端 API 完全兼容原版**(login/register/member/changepwd/list/save),中文 JSON 字段不变。
|
||||
- **服务监听失败优雅退出**:新增 `server.on('error')`,端口占用等错误输出中文原因后 exit 1(配合 `cmd/main` 启动检测)。
|
||||
|
||||
### 打包
|
||||
|
||||
- **源文件权限位修复**:从 `会员服务器/` 复制的 `huiyuan.json`、`admin.html` 等文件权限为 000(fnOS ACL 目录特性),fnpack 原样保留会导致应用用户读不到文件;打包前统一 `chmod 644`。
|
||||
- **`cmd/main` 引号笔误修复**:`printf ... > ${PID_FILE}"` 行尾多余引号导致 bash 语法错误。
|
||||
|
||||
---
|
||||
|
||||
## v1.0.0 设计决定记录
|
||||
|
||||
- 管理页暂不加密码(用户决定,后续可升级);`/api/list`、`/api/save` 与原版一致无鉴权,依赖内网可信。
|
||||
- 种子数据库 `app/server/huiyuan.json` 含现有 2 名会员,仅当目标目录无 `huiyuan.json` 时由 `cmd/install_init` 复制。
|
||||
- Windows 端成套报价软件需把服务器地址从 `127.0.0.1:12701` 改为 `飞牛主机IP:端口`。
|
||||
- 目录授权功能需 fnOS ≥ 1.2.0401、App ≥ 1.34.0;旧系统优雅降级(授权列表查询失败时仍可用默认目录/自定义路径)。
|
||||
Executable → Regular
+4
-4
@@ -1,8 +1,8 @@
|
||||
# fnnas.huiyuan 会员服务器(飞牛 fnOS 应用)
|
||||
|
||||
成套报价软件的会员系统服务器,打包为飞牛 fnOS 原生应用(.fpk)。v1.4.0 起全站强制 HTTPS(自签证书)+ 应用层信封加密。
|
||||
成套报价软件的会员系统服务器,打包为飞牛 fnOS 原生应用(.fpk)。v1.4.0 起全站强制 HTTPS(自签证书)+ 应用层信封加密;当前版本 v1.5.1(v1.5.0 起支持导入正规 SSL 证书,v1.5.1 新增项目云批量删除)。
|
||||
|
||||
- **产物**:`fnnas.huiyuan_1.4.0_all.fpk`
|
||||
- **产物**:`fnnas.huiyuan_1.5.1_all.fpk`
|
||||
- **默认端口**:12701(安装向导可改)
|
||||
- **运行时**:飞牛自带 Node.js(`install_dep_apps=nodejs_v22`),零第三方依赖
|
||||
- **源码来源**:`/vol2/1000/fnosyy/会员服务器/`(server.cjs 改造 + www 页面移植)
|
||||
@@ -14,7 +14,7 @@
|
||||
| 客户端 API | 凭据类(登录/注册/查会员/改密)走 /api/enc 信封加密;项目云走 TLS 直连;业务字段与原版一致 |
|
||||
| 项目云 API | `/api/proj/upload`(JSON头+空行+zip 二进制,覆盖式,上限 2GB)、`/api/proj/list`、`/api/proj/download`、`/api/proj/delete`;上传校验会员、文件名安全化、防路径穿越 |
|
||||
| 会员管理页 | `http://NAS_IP:端口/`(admin.html) |
|
||||
| 项目云管理页 | `/xiangmu-cloud.html` 按会员查看/下载/删除云上项目 |
|
||||
| 项目云管理页 | `/xiangmu-cloud.html` 按会员查看/下载/删除云上项目;v1.5.1 起支持勾选后**删除选中**(表头复选框全选,逐条调用 `/api/proj/delete`,完成后显示成功/失败计数并刷新列表) |
|
||||
| 库备份管理页 | `/lib-cloud.html` 只显示 LIB_ 前缀条目(方案库/用户库/柜体库/铜排铝排库/企业信息) |
|
||||
| 管理登录 | 四页面需管理密码登录(安装向导设置),设置页可改密码/关闭注册 |
|
||||
| 设置页 | `/settings.html` 显示端口/版本/会员数/数据库文件/项目云目录,切换数据目录(连库+项目云一起迁移) |
|
||||
@@ -90,7 +90,7 @@
|
||||
- **限速**:登录/注册/管理登录失败按 IP 限速(1 分钟 10 次锁 60 秒,重启清零)。
|
||||
- **注册开关**:设置页可关闭开放注册(`HY_REG_OPEN`)。
|
||||
- **配额与备份**:每会员项目云 1GB;整表保存/迁移前自动 `huiyuan.json.bak`(tmp+rename 原子写,兼容飞牛 ACL 000 权限文件)。
|
||||
- 已知边界:传输为 HTTP(密码在局域网传输中可见,客户端限制;哈希解决的是数据库文件泄露问题);限速/token 在内存(重启清零)。
|
||||
- 已知边界:TLS 证书为自签时浏览器会告警(可在设置页下载证书安装到"受信任的根证书颁发机构";v1.5.0 起也可导入正规证书);限速/token 在内存(重启清零)。
|
||||
|
||||
## 数据目录解析顺序(数据库 huiyuan.json 与 项目云/ 都在这里)
|
||||
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
// ============================================================================
|
||||
// 会员服务器 · 客户端加密通讯参考实现 (C# / .NET Framework 3.5+,零第三方依赖)
|
||||
// ============================================================================
|
||||
// 用法概览(配合 fnnas.huiyuan v1.4.0 全站 HTTPS + 信封加密):
|
||||
// var cli = new HuiyuanSecureClient("https://192.168.9.21:12701");
|
||||
// string resp = cli.EncryptedCall("/api/login", "{\"手机号\":\"138...\",\"密码\":\"xx\"}");
|
||||
// cli.UploadProject("138...", "密码", "20260917001", "项目名", zipBytes);
|
||||
// byte[] zip = cli.DownloadProject("138...", "密码", "20260917001");
|
||||
//
|
||||
// 加密协议(与服务器 /api/enc 对应):
|
||||
// 1. GET /api/pubkey → {"ok":true,"pubkey":"-----BEGIN PUBLIC KEY..."}
|
||||
// 2. 每次调用:
|
||||
// aesKey = 随机 32 字节; iv = 随机 16 字节; nonce = 随机 16 字节 hex
|
||||
// data = BASE64( AES-256-CBC( iv, 业务JSON ) ) [PKCS7 填充]
|
||||
// k = BASE64( RSA-OAEP-SHA1( 公钥, aesKey ) )
|
||||
// mac = HEX( HMAC-SHA256( aesKey, ts + "." + nonce + "." + iv + "." + data ) )
|
||||
// POST /api/enc {"k":k,"iv":iv,"ts":毫秒时间戳,"nonce":nonce,"mac":mac,"data":data}
|
||||
// 3. 响应: {"ok":true,"iv":hex,"mac":HEX( HMAC( aesKey, iv+"."+data ) ),"data":BASE64(AES(结果JSON))}
|
||||
// → AES 解密得到业务结果(与直连 HTTP 时代的接口字段完全一致)
|
||||
// 注意:
|
||||
// · 业务字段与旧协议完全一致,只是外面套了加密信封
|
||||
// · 首次使用请先在浏览器打开 https://NAS:12701/settings.html 下载证书并安装到
|
||||
// "受信任的根证书颁发机构"(或保留下面的证书校验跳过回调)
|
||||
// ============================================================================
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
|
||||
namespace HuiyuanClient
|
||||
{
|
||||
public class HuiyuanSecureClient
|
||||
{
|
||||
private string _baseUrl; // 如 https://192.168.9.21:12701
|
||||
private string _pubKeyPem; // 服务器 RSA 公钥(PEM)
|
||||
private RSACryptoServiceProvider _rsa;
|
||||
|
||||
static HuiyuanSecureClient()
|
||||
{
|
||||
// 信任自签证书(已安装服务器证书到受信任根的可删除此回调)
|
||||
ServicePointManager.ServerCertificateValidationCallback =
|
||||
(object s, X509Certificate cert, X509Chain chain, SslPolicyErrors err) => true;
|
||||
// 启用 TLS 1.2(值 3072;老系统默认只开 TLS1.0)
|
||||
try { ServicePointManager.SecurityProtocol = (SecurityProtocolType)3072; } catch { }
|
||||
ServicePointManager.Expect100Continue = false;
|
||||
}
|
||||
|
||||
public HuiyuanSecureClient(string baseUrl)
|
||||
{
|
||||
_baseUrl = baseUrl.TrimEnd('/');
|
||||
}
|
||||
|
||||
// ---------- 公开方法 ----------
|
||||
|
||||
/// <summary>初始化:拉取服务器 RSA 公钥(每次程序启动调用一次)</summary>
|
||||
public void Init()
|
||||
{
|
||||
string json = HttpRaw("GET", _baseUrl + "/api/pubkey", null, null);
|
||||
_pubKeyPem = ExtractString(json, "pubkey");
|
||||
if (string.IsNullOrEmpty(_pubKeyPem)) throw new Exception("获取服务器公钥失败: " + json);
|
||||
_rsa = PemToRsa(_pubKeyPem);
|
||||
}
|
||||
|
||||
/// <summary>加密调用业务接口(登录/注册/查会员/改密/项目云列表/删除)。
|
||||
/// body 为 JSON 字符串,返回解密后的结果 JSON 字符串。</summary>
|
||||
public string EncryptedCall(string apiPath, string bodyJson)
|
||||
{
|
||||
if (_rsa == null) throw new Exception("请先调用 Init()");
|
||||
|
||||
// 1. 组内层业务报文
|
||||
string inner = "{\"path\":\"" + apiPath + "\",\"body\":" + (bodyJson == null ? "null" : bodyJson) + "}";
|
||||
|
||||
// 2. 随机 AES-256 密钥 + IV + nonce
|
||||
byte[] aesKey = new byte[32]; Random.NextBytes(aesKey);
|
||||
byte[] iv = new byte[16]; Random.NextBytes(iv);
|
||||
byte[] nonce = new byte[16]; Random.NextBytes(nonce);
|
||||
string nonceHex = ToHex(nonce);
|
||||
|
||||
// 3. AES 加密业务数据
|
||||
string data = ToBase64(AesEncrypt(Encoding.UTF8.GetBytes(inner), aesKey, iv));
|
||||
|
||||
// 4. RSA-OAEP 封装 AES 密钥
|
||||
string k = ToBase64(_rsa.Encrypt(aesKey, true)); // true = OAEP-SHA1
|
||||
|
||||
// 5. HMAC 签名: ts + "." + nonce + "." + iv + "." + data
|
||||
string ts = DateTimeOffset.Now.ToUnixTimeMilliseconds().ToString();
|
||||
string ivHex = ToHex(iv);
|
||||
string mac = HmacHex(aesKey, ts + "." + nonceHex + "." + ivHex + "." + data);
|
||||
|
||||
string envelope = "{\"k\":\"" + k + "\",\"iv\":\"" + ivHex + "\",\"ts\":" + ts
|
||||
+ ",\"nonce\":\"" + nonceHex + "\",\"mac\":\"" + mac + "\",\"data\":\"" + data + "\"}";
|
||||
|
||||
// 6. 发送并解密响应
|
||||
string respJson = HttpRaw("POST", _baseUrl + "/api/enc", envelope,
|
||||
new Dictionary<string, string> { { "Content-Type", "application/json" } });
|
||||
|
||||
if (respJson.IndexOf("\"ok\":true") != 0 && respJson.IndexOf("{\"ok\":true") != 0)
|
||||
return respJson; // 信封层错误(时间戳过期/重放/签名失败等),原文返回给上层判断
|
||||
|
||||
string respIv = ExtractString(respJson, "iv");
|
||||
string respMac = ExtractString(respJson, "mac");
|
||||
string respData = ExtractString(respJson, "data");
|
||||
string wantMac = HmacHex(aesKey, respIv + "." + respData);
|
||||
if (wantMac != respMac) throw new Exception("响应签名校验失败(数据可能被篡改)");
|
||||
return Encoding.UTF8.GetString(AesDecrypt(FromBase64(respData), aesKey, FromHex(respIv)));
|
||||
}
|
||||
|
||||
/// <summary>上传项目 zip(走 TLS 直连,body = JSON头 + \n\n + zip 二进制)</summary>
|
||||
public string UploadProject(string phone, string password, string code, string name, byte[] zipBytes)
|
||||
{
|
||||
string head = "{\"手机号\":\"" + phone + "\",\"编号\":\"" + code + "\",\"名称\":\"" + name
|
||||
+ "\",\"密码\":\"" + password + "\",\"时间\":\""
|
||||
+ DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + "\"}";
|
||||
byte[] headB = Encoding.UTF8.GetBytes(head + "\n\n");
|
||||
byte[] all = new byte[headB.Length + zipBytes.Length];
|
||||
Array.Copy(headB, 0, all, 0, headB.Length);
|
||||
Array.Copy(zipBytes, 0, all, headB.Length, zipBytes.Length);
|
||||
return HttpRaw("POST", _baseUrl + "/api/proj/upload", all,
|
||||
new Dictionary<string, string> { { "Content-Type", "application/octet-stream" } });
|
||||
}
|
||||
|
||||
/// <summary>下载项目 zip(走 TLS 直连)</summary>
|
||||
public byte[] DownloadProject(string phone, string password, string code)
|
||||
{
|
||||
string url = _baseUrl + "/api/proj/download?手机号=" + Uri.EscapeDataString(phone)
|
||||
+ "&编号=" + Uri.EscapeDataString(code)
|
||||
+ "&密码=" + Uri.EscapeDataString(password);
|
||||
using (WebClient wc = new WebClient()) { return wc.DownloadData(url); }
|
||||
}
|
||||
|
||||
// ---------- 内部工具 ----------
|
||||
|
||||
private static readonly Random Random = new Random();
|
||||
|
||||
private string HttpRaw(string method, string url, byte[] body, Dictionary<string, string> headers)
|
||||
{
|
||||
HttpWebRequest req = (HttpWebRequest)WebRequest.Create(url);
|
||||
req.Method = method;
|
||||
req.Timeout = 30000;
|
||||
if (headers != null) foreach (KeyValuePair<string, string> kv in headers) req.Headers[kv.Key] = kv.Value;
|
||||
if (body != null)
|
||||
{
|
||||
req.ContentLength = body.Length;
|
||||
using (Stream s = req.GetRequestStream()) s.Write(body, 0, body.Length);
|
||||
}
|
||||
using (HttpWebResponse resp = (HttpWebResponse)req.GetResponse())
|
||||
using (StreamReader r = new StreamReader(resp.GetResponseStream(), Encoding.UTF8))
|
||||
return r.ReadToEnd();
|
||||
}
|
||||
|
||||
private static byte[] AesEncrypt(byte[] plain, byte[] key, byte[] iv)
|
||||
{
|
||||
using (Aes aes = Aes.Create())
|
||||
{
|
||||
aes.KeySize = 256; aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.PKCS7;
|
||||
aes.Key = key; aes.IV = iv;
|
||||
using (ICryptoTransform enc = aes.CreateEncryptor()) return enc.TransformFinalBlock(plain, 0, plain.Length);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] AesDecrypt(byte[] cipher, byte[] key, byte[] iv)
|
||||
{
|
||||
using (Aes aes = Aes.Create())
|
||||
{
|
||||
aes.KeySize = 256; aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.PKCS7;
|
||||
aes.Key = key; aes.IV = iv;
|
||||
using (ICryptoTransform dec = aes.CreateDecryptor()) return dec.TransformFinalBlock(cipher, 0, cipher.Length);
|
||||
}
|
||||
}
|
||||
|
||||
private static RSACryptoServiceProvider PemToRsa(string pem)
|
||||
{
|
||||
string b64 = pem.Replace("-----BEGIN PUBLIC KEY-----", "")
|
||||
.Replace("-----END PUBLIC KEY-----", "")
|
||||
.Replace("\r", "").Replace("\n", "").Trim();
|
||||
byte[] der = Convert.FromBase64String(b64);
|
||||
try { return RsaFromSpki(der); }
|
||||
catch { throw new Exception("公钥解析失败,请确认服务器版本 >= v1.4.0"); }
|
||||
}
|
||||
|
||||
/// <summary>解析 SubjectPublicKeyInfo(PKCS#1 内嵌)为 RSA(通用写法,2048 位)</summary>
|
||||
private static RSACryptoServiceProvider RsaFromSpki(byte[] der)
|
||||
{
|
||||
// SubjectPublicKeyInfo ::= { algorithm AlgorithmIdentifier, subjectPublicKey BIT STRING }
|
||||
// 定位 BIT STRING,内层为 PKCS#1 RSAPublicKey
|
||||
int i = 0;
|
||||
if (der[i++] != 0x30) throw new Exception();
|
||||
int len = der[i++]; if (len > 0x80) { int n = len & 0x7f; len = 0; for (int j = 0; j < n; j++) len = len * 256 + der[i++]; }
|
||||
// AlgorithmIdentifier 跳过
|
||||
if (der[i++] != 0x30) throw new Exception();
|
||||
int alen = der[i++]; if (alen > 0x80) { int n = alen & 0x7f; alen = 0; for (int j = 0; j < n; j++) alen = alen * 256 + der[i++]; }
|
||||
i += alen;
|
||||
if (der[i++] != 0x03) throw new Exception(); // BIT STRING
|
||||
int blen = der[i++]; if (blen > 0x80) { int n = blen & 0x7f; blen = 0; for (int j = 0; j < n; j++) blen = blen * 256 + der[i++]; }
|
||||
i++; // 未使用位 0x00
|
||||
// PKCS#1 RSAPublicKey ::= { modulus INTEGER, publicExponent INTEGER }
|
||||
if (der[i++] != 0x30) throw new Exception();
|
||||
int plen = der[i++]; if (plen > 0x80) { int n = plen & 0x7f; plen = 0; for (int j = 0; j < n; j++) plen = plen * 256 + der[i++]; }
|
||||
if (der[i++] != 0x02) throw new Exception(); // modulus
|
||||
int mlen = der[i++]; if (mlen > 0x80) { int n = mlen & 0x7f; mlen = 0; for (int j = 0; j < n; j++) mlen = mlen * 256 + der[i++]; }
|
||||
byte[] modulus = new byte[mlen]; Array.Copy(der, i, modulus, 0, mlen); i += mlen;
|
||||
if (modulus[0] == 0) { byte[] t = new byte[mlen - 1]; Array.Copy(modulus, 1, t, 0, mlen - 1); modulus = t; mlen--; }
|
||||
if (der[i++] != 0x02) throw new Exception(); // exponent
|
||||
int elen = der[i++]; if (elen > 0x80) { int n = elen & 0x7f; elen = 0; for (int j = 0; j < n; j++) elen = elen * 256 + der[i++]; }
|
||||
byte[] exponent = new byte[elen]; Array.Copy(der, i, exponent, 0, elen);
|
||||
|
||||
RSAParameters p = new RSAParameters { Modulus = modulus, Exponent = exponent };
|
||||
RSACryptoServiceProvider rsa = new RSACryptoServiceProvider(2048);
|
||||
rsa.ImportParameters(p);
|
||||
return rsa;
|
||||
}
|
||||
|
||||
private static string HmacHex(byte[] key, string message)
|
||||
{
|
||||
using (HMACSHA256 h = new HMACSHA256(key))
|
||||
return ToHex(h.ComputeHash(Encoding.UTF8.GetBytes(message)));
|
||||
}
|
||||
|
||||
private static string ToHex(byte[] b)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder(b.Length * 2);
|
||||
foreach (byte x in b) sb.Append(x.ToString("x2"));
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static byte[] FromHex(string s)
|
||||
{
|
||||
byte[] b = new byte[s.Length / 2];
|
||||
for (int i = 0; i < b.Length; i++) b[i] = Convert.ToByte(s.Substring(i * 2, 2), 16);
|
||||
return b;
|
||||
}
|
||||
|
||||
private static string ToBase64(byte[] b) { return Convert.ToBase64String(b); }
|
||||
private static byte[] FromBase64(string s) { return Convert.FromBase64String(s); }
|
||||
|
||||
/// <summary>从扁平 JSON 取顶层字符串值(本协议响应结构固定,够用;复杂解析建议用 JSON 库)</summary>
|
||||
private static string ExtractString(string json, string key)
|
||||
{
|
||||
string k = "\"" + key + "\":\"";
|
||||
int i = json.IndexOf(k, StringComparison.Ordinal);
|
||||
if (i < 0) return null;
|
||||
i += k.Length;
|
||||
StringBuilder sb = new StringBuilder();
|
||||
while (i < json.Length && json[i] != '"')
|
||||
{
|
||||
if (json[i] == '\\' && i + 1 < json.Length) { i++; sb.Append(json[i] == 'n' ? '\n' : json[i]); }
|
||||
else sb.Append(json[i]);
|
||||
i++;
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
// ======================= 调用示例 =======================
|
||||
// class Demo {
|
||||
// static void Main() {
|
||||
// var cli = new HuiyuanSecureClient("https://192.168.9.21:12701");
|
||||
// cli.Init();
|
||||
// // 登录(信封加密)
|
||||
// Console.WriteLine(cli.EncryptedCall("/api/login",
|
||||
// "{\"手机号\":\"示例手机号\",\"密码\":\"示例密码\"}"));
|
||||
// // 项目云列表(信封加密)
|
||||
// Console.WriteLine(cli.EncryptedCall("/api/proj/list",
|
||||
// "{\"手机号\":\"示例手机号\",\"密码\":\"示例密码\"}"));
|
||||
// // 上传项目 zip(TLS 直连)
|
||||
// // Console.WriteLine(cli.UploadProject("示例手机号", "示例密码", "20260917001", "项目名", File.ReadAllBytes("d:/x.zip")));
|
||||
// // 下载项目 zip(TLS 直连)
|
||||
// // byte[] zip = cli.DownloadProject("示例手机号", "示例密码", "20260917001");
|
||||
// }
|
||||
// }
|
||||
}
|
||||
Reference in New Issue
Block a user