commit 94f4f174f4149601a35f1200ddd230db5952c81e Author: qwq Date: Fri Jul 24 11:34:38 2026 +0800 first commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1d35fe6 --- /dev/null +++ b/.env.example @@ -0,0 +1,48 @@ +# ===== 服务配置 ===== +PORT=3000 +NODE_ENV=production +# 管理后台密码(首次部署务必修改) +ADMIN_PASSWORD=change-me-to-strong-password + +# ===== 飞书配置 ===== +# 在飞书开放平台创建企业自建应用后获取 +FEISHU_APP_ID=cli_xxxxxxxxxxxx +FEISHU_APP_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +# 事件订阅的 Verification Token 和 Encrypt Key +FEISHU_VERIFICATION_TOKEN=xxxxxxxxxxxxxxxx +FEISHU_ENCRYPT_KEY= +# 飞书审批定义code(在飞书管理后台查看) +FEISHU_APPROVAL_FILE_IN=approval-code-file-in +FEISHU_APPROVAL_FILE_OUT=approval-code-file-out +FEISHU_APPROVAL_GROUP_PERM=approval-code-group-perm +# 告警通知群 webhook(群机器人) +FEISHU_ALERT_WEBHOOK=https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxx + +# ===== 群晖 NAS 配置 ===== +SYNOLOGY_HOST=https://192.168.1.100:5001 +SYNOLOGY_USERNAME=admin +SYNOLOGY_PASSWORD=your-nas-password +# 是否跳过TLS证书验证(自签证书时设为true) +SYNOLOGY_SKIP_TLS=true + +# ===== JumpServer 配置 ===== +JUMPSERVER_HOST=https://jumpserver.yourcompany.com +JUMPSERVER_KEY_ID=your-key-id +JUMPSERVER_KEY_SECRET=your-key-secret +# ic1 资产的 asset_id(在JumpServer控制台查看) +JUMPSERVER_ASSET_IC1=asset-id-of-ic1 +# 系统用户(用于连接ic1的账号) +JUMPSERVER_SYSTEM_USER=root + +# ===== Nextcloud 配置 ===== +NEXTCLOUD_HOST=https://nextcloud.yourcompany.com +NEXTCLOUD_USERNAME=automation +NEXTCLOUD_PASSWORD=your-nextcloud-password +# 共享默认权限(1=读, 15=读写删, 17=读+分享) +NEXTCLOUD_SHARE_PERMISSIONS=1 + +# ===== 安全配置 ===== +# 白名单目录(逗号分隔) +WHITELIST_DIRS=/IN_R,/OUT_R,/OUT,/wingsemi,/WCPS-Files,/OUT-RED +# 操作日志保留天数 +LOG_RETENTION_DAYS=30 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..97725b4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +data/ +.env +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..01ceb92 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM node:18-alpine + +WORKDIR /app + +# 安装 better-sqlite3 需要的构建依赖 +RUN apk add --no-cache python3 make g++ + +COPY package.json ./ +RUN npm install --production + +COPY . . + +# 创建数据和日志目录 +RUN mkdir -p data/logs + +EXPOSE 3000 + +CMD ["node", "src/index.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..16b7fc1 --- /dev/null +++ b/README.md @@ -0,0 +1,316 @@ +# 文件传输自动化系统 + +飞书审批驱动的企业文件传输与权限自动化管理系统。 + +## 系统架构 + +``` +飞书审批事件 ──→ Express 服务 ──→ 群晖 NAS API(文件复制) + │ ──→ JumpServer API(远程命令) + │ ──→ Nextcloud API(文件共享) + │ + ├── SQLite(操作日志) + ├── 管理后台(Web UI) + └── 飞书机器人(告警通知) +``` + +## 快速开始 + +### 1. 环境要求 + +- Node.js >= 18 +- 可访问群晖 NAS、JumpServer、Nextcloud 的网络 +- 公网 IP 或域名(用于飞书事件回调) + +### 2. 安装部署 + +```bash +# 克隆项目 +git clone file-transfer-automation +cd file-transfer-automation + +# 安装依赖 +npm install + +# 复制配置文件并填写 +cp .env.example .env +# 编辑 .env 填入实际的 API 密钥和地址 + +# 启动 +npm start +``` + +或使用 Docker: + +```bash +cp .env.example .env +# 编辑 .env +docker-compose up -d +``` + +### 3. 验证 + +访问 `http://你的服务器IP:3000/health` 确认服务正常。 + +--- + +## 飞书应用创建指南(从零开始) + +### 第一步:创建企业自建应用 + +1. 打开 [飞书开放平台](https://open.feishu.cn/app),用管理员账号登录 +2. 点击「创建企业自建应用」 +3. 填写应用名称(如"文件传输自动化")和描述 +4. 创建后进入应用详情页,记录 **App ID** 和 **App Secret** + +### 第二步:配置权限 + +在应用详情页 →「权限管理」中,搜索并开通以下权限: + +| 权限名称 | 权限标识 | 用途 | +|---------|---------|------| +| 获取审批实例详情 | approval:instance:read | 读取审批表单内容 | +| 同意审批 | approval:instance:approve | 自动提交审批意见 | +| 查看审批定义 | approval:approval:read | 获取审批配置 | +| 添加审批评论 | approval:instance:comment | 添加审批评论 | + +### 第三步:配置事件订阅 + +1. 进入应用详情页 →「事件订阅」 +2. 请求地址填写:`http://你的公网IP:3000/api/feishu/event` +3. 系统会发送验证请求,确保服务已启动 +4. 添加事件:搜索「审批实例状态变更」(`approval_instance`) +5. 记录 **Verification Token**(页面顶部显示) + +> 注意:飞书要求回调地址必须是公网可访问的 HTTP/HTTPS 地址。 + +### 第四步:发布应用 + +1. 进入「版本管理与发布」 +2. 创建版本 → 提交审核 +3. 管理员在飞书管理后台审核通过 + +### 第五步:配置审批流程 + +在飞书管理后台 → 审批管理中: + +1. 创建/编辑三个审批表单(文件传入、文件传出、红区组权限) +2. 在审批流程设计中,添加「机器人审批」节点(让应用自动处理) +3. 记录每个审批的 **审批定义 Code**(在审批设置页面的 URL 中可以看到) + +### 第六步:配置告警机器人 + +1. 在飞书中创建一个告警通知群 +2. 群设置 → 群机器人 → 添加机器人 → 自定义机器人 +3. 记录 Webhook 地址,填入 `.env` 的 `FEISHU_ALERT_WEBHOOK` + +--- + +## API 接入说明 + +### 群晖 NAS (Synology DSM) + +**前提条件:** +- DSM 6.0+ 或 7.0+ +- 开启 FileStation API(默认开启) +- 准备一个有文件管理权限的账号 + +**获取配置:** +- `SYNOLOGY_HOST`: NAS 地址,如 `https://192.168.1.100:5001` +- 使用自签证书时设 `SYNOLOGY_SKIP_TLS=true` +- 确保运行本服务的服务器能访问 NAS 的 5001 端口 + +**验证连接:** +```bash +curl -k "https://NAS_IP:5001/webapi/auth.cgi?api=SYNO.API.Auth&version=6&method=login&account=admin&passwd=xxx&format=sid" +``` + +### JumpServer 3.x + +**前提条件:** +- JumpServer 3.x 版本 +- 有 API Key 权限(在 JumpServer 控制台 → 系统设置 → API Key 中创建) +- ic1 服务器已作为资产录入 JumpServer + +**获取配置:** +- `JUMPSERVER_HOST`: JumpServer 地址 +- `JUMPSERVER_KEY_ID` / `JUMPSERVER_KEY_SECRET`: API Key +- `JUMPSERVER_ASSET_IC1`: 在 JumpServer 控制台 → 资产管理 → 找到 ic1 → URL 中的 UUID +- `JUMPSERVER_SYSTEM_USER`: 连接 ic1 使用的系统用户(如 root) + +**验证连接:** +```bash +curl -H "Authorization: Token KEY_ID:KEY_SECRET" https://JUMPSERVER_HOST/api/v1/assets/hosts/ +``` + +### Nextcloud + +**前提条件:** +- Nextcloud 已启用 WebDAV(默认启用) +- 准备一个有文件管理权限的账号 +- 如需共享功能,确保 files_sharing 应用已启用 + +**获取配置:** +- `NEXTCLOUD_HOST`: Nextcloud 地址 +- 用户名/密码用于 WebDAV 认证 + +**验证连接:** +```bash +curl -u user:pass https://NEXTCLOUD_HOST/remote.php/dav/files/user/ -X PROPFIND +``` + +--- + +## 部署方案 + +### 方案 A:公网 IP 服务器(推荐) + +本服务非常轻量(Node.js + SQLite,内存占用约 50-100MB),适合部署在有公网 IP 的服务器上。 + +```bash +# 使用 Docker 部署 +docker-compose up -d + +# 或直接运行 +npm install --production +NODE_ENV=production node src/index.js +``` + +配合 Nginx 反向代理(可选,用于 HTTPS): + +```nginx +server { + listen 443 ssl; + server_name automation.yourcompany.com; + + ssl_certificate /path/to/cert.pem; + ssl_certificate_key /path/to/key.pem; + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} +``` + +### 方案 B:无公网 IP 的内网服务器 + +如果服务必须部署在内网(比如需要直接访问群晖 NAS),有两种方式接收飞书事件: + +**方式 1:内网穿透(frp)** + +在公网服务器上部署 frps,内网服务器部署 frpc: + +```ini +# frpc.ini(内网服务器) +[server] +server_addr = 公网服务器IP +server_port = 7000 + +[file-automation] +type = http +local_port = 3000 +custom_domains = automation.yourcompany.com +``` + +飞书事件回调地址填:`http://automation.yourcompany.com/api/feishu/event` + +**方式 2:飞书长连接模式(推荐)** + +飞书支持 WebSocket 长连接模式,不需要公网回调地址。修改事件接收方式: + +```javascript +// 使用 @larksuiteoapi/node-sdk 的长连接模式 +const { Client, EventDispatcher } = require('@larksuiteoapi/node-sdk'); + +const client = new Client({ appId, appSecret }); +const wsClient = client.event.wsClient; // WebSocket 长连接 +wsClient.start(); // 主动连接飞书,无需公网IP +``` + +> 长连接模式需要安装 `@larksuiteoapi/node-sdk` 包,适合内网部署场景。 + +### 方案 C:混合部署 + +公网服务器放一个轻量转发层(只接收飞书回调),内网服务器跑主业务逻辑: + +``` +飞书 ──→ 公网服务器(转发) ──→ 内网服务器(业务处理) ──→ 群晖/JumpServer/Nextcloud +``` + +--- + +## 审批表单字段设计 + +为了让自动化系统正确解析,审批表单字段名需要与代码中的映射一致: + +### 文件传入审批 + +| 字段名 | 类型 | 说明 | +|--------|------|------| +| 申请人 | 文本/人员 | 申请人姓名 | +| 文件名称 | 文本 | 要传入的文件名 | + +### 文件传出审批 + +| 字段名 | 类型 | 说明 | +|--------|------|------| +| 申请人 | 文本/人员 | 申请人姓名 | +| 文件路径 | 文本 | 文件在服务器上的完整路径 | +| 传出目的 | 单选 | 内部使用 / 客户发布 | +| 客户名缩写 | 文本 | 仅客户发布时填写 | +| 文件过期日期 | 日期 | 仅客户发布时填写 | + +### 红区组权限审批 + +| 字段名 | 类型 | 说明 | +|--------|------|------| +| 待添加人员 | 文本/人员 | 人员姓名 | +| 申请类型 | 单选 | 添加至已有group / 新建group | +| group名称 | 文本 | Linux 组名 | + +> 如果你的表单字段名不同,修改 `src/routes/feishu-event.js` 中的字段映射即可。 + +--- + +## 管理后台 + +访问 `http://服务器IP:3000/admin`,使用 `.env` 中配置的 `ADMIN_PASSWORD` 登录。 + +功能包括: +- 仪表盘:今日操作统计、系统状态 +- 操作日志:按类型/状态/时间/关键词筛选,保留30天 +- 预设命令:管理可在 ic1 上执行的预设命令 +- API配置:动态管理配置项 + +--- + +## 安全机制 + +1. **白名单目录**:所有文件操作路径必须在白名单内(`/IN_R`, `/OUT_R`, `/OUT`, `/wingsemi`, `/WCPS-Files`, `/OUT-RED`) +2. **路径穿越防护**:自动规范化路径,阻止 `../` 攻击 +3. **命令注入防护**:禁止 `;`、`$(`、反引号、`rm -rf` 等危险模式 +4. **审批绑定**:每次操作记录审批单号、申请人、时间,保留一个月 +5. **防重复处理**:同一审批实例不会重复执行 +6. **管理接口限速**:15分钟内最多100次请求 + +--- + +## 日志 + +- 运行日志:`data/logs/combined.log`(自动轮转,单文件10MB) +- 错误日志:`data/logs/error.log` +- 操作记录:SQLite 数据库 `data/automation.db`(可通过管理后台查看) + +--- + +## 故障排查 + +| 问题 | 可能原因 | 解决方法 | +|------|---------|---------| +| 飞书事件收不到 | 回调地址不通/应用未发布 | 检查公网访问、确认应用已审核通过 | +| 群晖操作失败 | Session 过期/权限不足 | 检查账号权限、确认 FileStation API 开启 | +| JumpServer 命令超时 | 资产连接慢/网络问题 | 增加 timeout、检查 ic1 连通性 | +| Nextcloud 403 | 权限不足/共享功能未启用 | 检查用户权限、启用 files_sharing | +| 审批意见提交失败 | 无待处理任务 | 确认审批流程中有机器人审批节点 | diff --git a/admin-ui/index.html b/admin-ui/index.html new file mode 100644 index 0000000..2351e95 --- /dev/null +++ b/admin-ui/index.html @@ -0,0 +1,267 @@ + + + + + + 文件传输自动化 - 管理后台 + + + + + + + + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8af07c4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +version: '3.8' + +services: + file-transfer-automation: + build: . + container_name: file-transfer-automation + restart: unless-stopped + ports: + - "3000:3000" + env_file: + - .env + volumes: + - ./data:/app/data # 持久化数据库和日志 + # 如果需要访问内网设备(群晖、JumpServer),确保网络互通 + # network_mode: host # 可选:直接使用宿主机网络 diff --git a/package.json b/package.json new file mode 100644 index 0000000..a70cf88 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "file-transfer-automation", + "version": "1.0.0", + "description": "企业文件传输与权限自动化管理系统 - 飞书审批驱动", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "node --watch src/index.js", + "migrate": "node src/db/migrate.js" + }, + "dependencies": { + "express": "^4.18.2", + "axios": "^1.6.0", + "better-sqlite3": "^9.4.3", + "pinyin-pro": "^3.18.0", + "crypto-js": "^4.2.0", + "dotenv": "^16.3.1", + "winston": "^3.11.0", + "dayjs": "^1.11.10", + "express-rate-limit": "^7.1.4" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..3b82a27 --- /dev/null +++ b/src/config.js @@ -0,0 +1,49 @@ +require('dotenv').config(); + +const config = { + port: parseInt(process.env.PORT) || 3000, + nodeEnv: process.env.NODE_ENV || 'development', + adminPassword: process.env.ADMIN_PASSWORD || 'admin123', + + feishu: { + appId: process.env.FEISHU_APP_ID, + appSecret: process.env.FEISHU_APP_SECRET, + verificationToken: process.env.FEISHU_VERIFICATION_TOKEN, + encryptKey: process.env.FEISHU_ENCRYPT_KEY || '', + approvalCodes: { + fileIn: process.env.FEISHU_APPROVAL_FILE_IN, + fileOut: process.env.FEISHU_APPROVAL_FILE_OUT, + groupPerm: process.env.FEISHU_APPROVAL_GROUP_PERM, + }, + alertWebhook: process.env.FEISHU_ALERT_WEBHOOK, + }, + + synology: { + host: process.env.SYNOLOGY_HOST, + username: process.env.SYNOLOGY_USERNAME, + password: process.env.SYNOLOGY_PASSWORD, + skipTls: process.env.SYNOLOGY_SKIP_TLS === 'true', + }, + + jumpserver: { + host: process.env.JUMPSERVER_HOST, + keyId: process.env.JUMPSERVER_KEY_ID, + keySecret: process.env.JUMPSERVER_KEY_SECRET, + assetIc1: process.env.JUMPSERVER_ASSET_IC1, + systemUser: process.env.JUMPSERVER_SYSTEM_USER || 'root', + }, + + nextcloud: { + host: process.env.NEXTCLOUD_HOST, + username: process.env.NEXTCLOUD_USERNAME, + password: process.env.NEXTCLOUD_PASSWORD, + sharePermissions: parseInt(process.env.NEXTCLOUD_SHARE_PERMISSIONS) || 1, + }, + + security: { + whitelistDirs: (process.env.WHITELIST_DIRS || '/IN_R,/OUT_R,/OUT,/wingsemi,/WCPS-Files,/OUT-RED').split(','), + logRetentionDays: parseInt(process.env.LOG_RETENTION_DAYS) || 30, + }, +}; + +module.exports = config; diff --git a/src/db/index.js b/src/db/index.js new file mode 100644 index 0000000..5551897 --- /dev/null +++ b/src/db/index.js @@ -0,0 +1,54 @@ +const Database = require('better-sqlite3'); +const path = require('path'); +const fs = require('fs'); + +const DB_DIR = path.join(__dirname, '..', 'data'); +if (!fs.existsSync(DB_DIR)) { + fs.mkdirSync(DB_DIR, { recursive: true }); +} + +const db = new Database(path.join(DB_DIR, 'automation.db')); + +// 启用 WAL 模式提升并发性能 +db.pragma('journal_mode = WAL'); + +// 初始化表结构 +db.exec(` + CREATE TABLE IF NOT EXISTS operation_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + approval_code TEXT NOT NULL, + approval_instance_id TEXT NOT NULL, + workflow_type TEXT NOT NULL, + applicant_name TEXT, + applicant_pinyin TEXT, + action TEXT NOT NULL, + detail TEXT, + status TEXT DEFAULT 'running', + error_message TEXT, + created_at TEXT DEFAULT (datetime('now', 'localtime')), + finished_at TEXT + ); + + CREATE TABLE IF NOT EXISTS api_configs ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + description TEXT, + updated_at TEXT DEFAULT (datetime('now', 'localtime')) + ); + + CREATE TABLE IF NOT EXISTS preset_commands ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + command TEXT NOT NULL, + target TEXT DEFAULT 'ic1', + description TEXT, + enabled INTEGER DEFAULT 1, + created_at TEXT DEFAULT (datetime('now', 'localtime')) + ); + + CREATE INDEX IF NOT EXISTS idx_logs_instance ON operation_logs(approval_instance_id); + CREATE INDEX IF NOT EXISTS idx_logs_created ON operation_logs(created_at); + CREATE INDEX IF NOT EXISTS idx_logs_type ON operation_logs(workflow_type); +`); + +module.exports = db; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..c355e9d --- /dev/null +++ b/src/index.js @@ -0,0 +1,45 @@ +const app = require('./server'); +const config = require('./config'); +const logger = require('./utils/logger'); +const synology = require('./services/synology'); + +async function start() { + logger.info('=== 文件传输自动化系统启动 ==='); + logger.info(`环境: ${config.nodeEnv}, 端口: ${config.port}`); + + // 预登录群晖(验证配置) + try { + await synology.login(); + logger.info('群晖 NAS 连接验证通过'); + } catch (e) { + logger.warn(`群晖 NAS 连接失败(将在首次使用时重试): ${e.message}`); + } + + // 启动 HTTP 服务 + app.listen(config.port, () => { + logger.info(`服务已启动: http://0.0.0.0:${config.port}`); + logger.info(`管理后台: http://0.0.0.0:${config.port}/admin`); + logger.info(`飞书事件回调: http://0.0.0.0:${config.port}/api/feishu/event`); + logger.info(`健康检查: http://0.0.0.0:${config.port}/health`); + }); +} + +// 优雅退出 +process.on('SIGTERM', () => { + logger.info('收到 SIGTERM,正在关闭...'); + process.exit(0); +}); + +process.on('SIGINT', () => { + logger.info('收到 SIGINT,正在关闭...'); + process.exit(0); +}); + +process.on('unhandledRejection', (reason) => { + logger.error(`未处理的 Promise 拒绝: ${reason}`); +}); + +start().catch(err => { + logger.error(`启动失败: ${err.message}`); + process.exit(1); +}); diff --git a/src/middleware/operation-log.js b/src/middleware/operation-log.js new file mode 100644 index 0000000..0fc100f --- /dev/null +++ b/src/middleware/operation-log.js @@ -0,0 +1,105 @@ +const db = require('../db'); +const config = require('../config'); +const logger = require('../utils/logger'); + +/** + * 记录操作日志 + */ +function createLog({ approvalCode, instanceId, workflowType, applicantName, applicantPinyin, action, detail }) { + const stmt = db.prepare(` + INSERT INTO operation_logs + (approval_code, approval_instance_id, workflow_type, applicant_name, applicant_pinyin, action, detail, status) + VALUES (?, ?, ?, ?, ?, ?, ?, 'running') + `); + const result = stmt.run(approvalCode, instanceId, workflowType, applicantName, applicantPinyin, action, detail); + return result.lastInsertRowid; +} + +/** + * 更新日志状态 + */ +function updateLog(logId, status, errorMessage = null) { + const stmt = db.prepare(` + UPDATE operation_logs + SET status = ?, error_message = ?, finished_at = datetime('now', 'localtime') + WHERE id = ? + `); + stmt.run(status, errorMessage, logId); +} + +/** + * 查询日志(带分页和过滤) + */ +function queryLogs({ page = 1, pageSize = 20, workflowType, status, startDate, endDate, keyword } = {}) { + let where = 'WHERE 1=1'; + const params = []; + + if (workflowType) { + where += ' AND workflow_type = ?'; + params.push(workflowType); + } + if (status) { + where += ' AND status = ?'; + params.push(status); + } + if (startDate) { + where += ' AND created_at >= ?'; + params.push(startDate); + } + if (endDate) { + where += ' AND created_at <= ?'; + params.push(endDate + ' 23:59:59'); + } + if (keyword) { + where += ' AND (applicant_name LIKE ? OR detail LIKE ? OR approval_instance_id LIKE ?)'; + params.push(`%${keyword}%`, `%${keyword}%`, `%${keyword}%`); + } + + const countStmt = db.prepare(`SELECT COUNT(*) as total FROM operation_logs ${where}`); + const { total } = countStmt.get(...params); + + const offset = (page - 1) * pageSize; + const dataStmt = db.prepare(` + SELECT * FROM operation_logs ${where} + ORDER BY created_at DESC + LIMIT ? OFFSET ? + `); + const rows = dataStmt.all(...params, pageSize, offset); + + return { total, page, pageSize, rows }; +} + +/** + * 清理过期日志(保留 N 天) + */ +function cleanupOldLogs() { + const retentionDays = config.security.logRetentionDays; + const stmt = db.prepare(` + DELETE FROM operation_logs + WHERE created_at < datetime('now', 'localtime', '-' || ? || ' days') + `); + const result = stmt.run(retentionDays); + if (result.changes > 0) { + logger.info(`已清理 ${result.changes} 条过期日志(保留${retentionDays}天)`); + } +} + +/** + * 检查审批是否已处理(防重复) + */ +function isApprovalProcessed(instanceId) { + const stmt = db.prepare(` + SELECT COUNT(*) as count FROM operation_logs + WHERE approval_instance_id = ? AND status = 'success' + `); + const { count } = stmt.get(instanceId); + return count > 0; +} + +module.exports = { + createLog, + updateLog, + queryLogs, + cleanupOldLogs, + isApprovalProcessed, +}; diff --git a/src/middleware/security.js b/src/middleware/security.js new file mode 100644 index 0000000..7be299f --- /dev/null +++ b/src/middleware/security.js @@ -0,0 +1,104 @@ +const config = require('../config'); +const logger = require('../utils/logger'); + +/** + * 路径安全验证 - 白名单机制 + * 只允许访问预设目录 + */ +function validatePath(path) { + if (!path || typeof path !== 'string') { + throw new Error('路径不能为空'); + } + + // 规范化路径(防止 ../ 穿越) + const normalized = normalizePath(path); + + // 检查是否在白名单内 + const allowed = config.security.whitelistDirs.some(dir => { + const normalizedDir = normalizePath(dir); + return normalized === normalizedDir || normalized.startsWith(normalizedDir + '/'); + }); + + if (!allowed) { + logger.warn(`[安全] 路径被拒绝: ${path} (规范化: ${normalized})`); + throw new Error(`路径不在白名单内: ${path}。允许的目录: ${config.security.whitelistDirs.join(', ')}`); + } + + return normalized; +} + +/** + * 路径规范化(防止路径穿越攻击) + */ +function normalizePath(path) { + // 移除多余的斜杠 + let normalized = path.replace(/\/+/g, '/'); + // 解析 .. 和 . + const parts = normalized.split('/'); + const result = []; + for (const part of parts) { + if (part === '' || part === '.') continue; + if (part === '..') { + result.pop(); // 回退一级 + } else { + result.push(part); + } + } + return '/' + result.join('/'); +} + +/** + * 命令安全验证 + * 防止命令注入 + */ +function validateCommand(command) { + if (!command || typeof command !== 'string') { + throw new Error('命令不能为空'); + } + + // 禁止的危险字符和模式 + const dangerousPatterns = [ + /;/, // 命令分隔 + /&&/, // 命令链接(允许在预设命令中) + /\|\|/, // 或链接 + /\$\(/, // 命令替换 + /`/, // 反引号命令替换 + />\s*\//, // 重定向到绝对路径 + /rm\s+-rf/, // 危险删除 + /rm\s+-r/, // 递归删除 + /dd\s+if=/, // dd 命令 + /mkfs/, // 格式化 + /shutdown/, // 关机 + /reboot/, // 重启 + /curl.*\|/, // 管道执行 + /wget.*\|/, // 管道执行 + ]; + + for (const pattern of dangerousPatterns) { + if (pattern.test(command)) { + logger.warn(`[安全] 危险命令被拒绝: ${command}`); + throw new Error(`命令包含危险模式,已被拒绝: ${command}`); + } + } + + return command; +} + +/** + * 验证文件名安全性 + */ +function validateFileName(fileName) { + if (!fileName || typeof fileName !== 'string') { + throw new Error('文件名不能为空'); + } + // 禁止路径分隔符和特殊字符 + if (/[/\\:*?"<>|]/.test(fileName)) { + throw new Error(`文件名包含非法字符: ${fileName}`); + } + if (fileName.includes('..')) { + throw new Error(`文件名包含路径穿越: ${fileName}`); + } + return fileName; +} + +module.exports = { validatePath, validateCommand, validateFileName, normalizePath }; diff --git a/src/routes/admin.js b/src/routes/admin.js new file mode 100644 index 0000000..67a185a --- /dev/null +++ b/src/routes/admin.js @@ -0,0 +1,163 @@ +const express = require('express'); +const router = express.Router(); +const db = require('../db'); +const config = require('../config'); +const { queryLogs, cleanupOldLogs } = require('../middleware/operation-log'); +const { sendAlert } = require('../utils/alert'); +const logger = require('../utils/logger'); + +/** + * 管理后台鉴权中间件 + */ +function adminAuth(req, res, next) { + const password = req.headers['x-admin-password'] || req.query.password; + if (password !== config.adminPassword) { + return res.status(401).json({ error: '管理密码错误' }); + } + next(); +} + +router.use(adminAuth); + +// ===== 操作日志 ===== + +/** + * GET /api/admin/logs - 查询操作日志 + */ +router.get('/logs', (req, res) => { + const { page, pageSize, workflowType, status, startDate, endDate, keyword } = req.query; + const result = queryLogs({ + page: parseInt(page) || 1, + pageSize: parseInt(pageSize) || 20, + workflowType, + status, + startDate, + endDate, + keyword, + }); + res.json(result); +}); + +/** + * DELETE /api/admin/logs/cleanup - 手动清理过期日志 + */ +router.delete('/logs/cleanup', (req, res) => { + cleanupOldLogs(); + res.json({ message: '清理完成' }); +}); + +// ===== API 配置管理 ===== + +/** + * GET /api/admin/config - 获取所有配置 + */ +router.get('/config', (req, res) => { + const rows = db.prepare('SELECT * FROM api_configs ORDER BY key').all(); + // 敏感字段脱敏 + const masked = rows.map(r => { + if (r.key.toLowerCase().includes('password') || r.key.toLowerCase().includes('secret')) { + return { ...r, value: '******' }; + } + return r; + }); + res.json(masked); +}); + +/** + * PUT /api/admin/config - 更新配置 + */ +router.put('/config', (req, res) => { + const { key, value, description } = req.body; + if (!key || !value) { + return res.status(400).json({ error: '缺少 key 或 value' }); + } + + const stmt = db.prepare(` + INSERT INTO api_configs (key, value, description, updated_at) + VALUES (?, ?, ?, datetime('now', 'localtime')) + ON CONFLICT(key) DO UPDATE SET value = ?, description = ?, updated_at = datetime('now', 'localtime') + `); + stmt.run(key, value, description || '', value, description || ''); + logger.info(`[管理] 配置已更新: ${key}`); + res.json({ message: `配置 ${key} 已更新` }); +}); + +// ===== 预设命令管理 ===== + +/** + * GET /api/admin/commands - 获取预设命令列表 + */ +router.get('/commands', (req, res) => { + const rows = db.prepare('SELECT * FROM preset_commands ORDER BY id DESC').all(); + res.json(rows); +}); + +/** + * POST /api/admin/commands - 添加预设命令 + */ +router.post('/commands', (req, res) => { + const { name, command, target, description } = req.body; + if (!name || !command) { + return res.status(400).json({ error: '缺少 name 或 command' }); + } + + const stmt = db.prepare(` + INSERT INTO preset_commands (name, command, target, description) VALUES (?, ?, ?, ?) + `); + const result = stmt.run(name, command, target || 'ic1', description || ''); + logger.info(`[管理] 预设命令已添加: ${name}`); + res.json({ id: result.lastInsertRowid, message: '命令已添加' }); +}); + +/** + * PUT /api/admin/commands/:id - 更新预设命令 + */ +router.put('/commands/:id', (req, res) => { + const { name, command, target, description, enabled } = req.body; + const stmt = db.prepare(` + UPDATE preset_commands SET name = ?, command = ?, target = ?, description = ?, enabled = ? + WHERE id = ? + `); + stmt.run(name, command, target || 'ic1', description || '', enabled !== undefined ? (enabled ? 1 : 0) : 1, req.params.id); + res.json({ message: '命令已更新' }); +}); + +/** + * DELETE /api/admin/commands/:id - 删除预设命令 + */ +router.delete('/commands/:id', (req, res) => { + db.prepare('DELETE FROM preset_commands WHERE id = ?').run(req.params.id); + res.json({ message: '命令已删除' }); +}); + +// ===== 系统状态 ===== + +/** + * GET /api/admin/status - 系统状态 + */ +router.get('/status', (req, res) => { + const todayLogs = db.prepare(` + SELECT COUNT(*) as total, + SUM(CASE WHEN status='success' THEN 1 ELSE 0 END) as success, + SUM(CASE WHEN status='failed' THEN 1 ELSE 0 END) as failed, + SUM(CASE WHEN status='running' THEN 1 ELSE 0 END) as running + FROM operation_logs WHERE date(created_at) = date('now', 'localtime') + `).get(); + + res.json({ + uptime: process.uptime(), + memory: process.memoryUsage(), + todayStats: todayLogs, + version: require('../../package.json').version, + }); +}); + +/** + * POST /api/admin/test-alert - 测试告警 + */ +router.post('/test-alert', async (req, res) => { + await sendAlert('测试告警', '这是一条测试告警消息,系统运行正常。', 'info'); + res.json({ message: '测试告警已发送' }); +}); + +module.exports = router; diff --git a/src/routes/feishu-event.js b/src/routes/feishu-event.js new file mode 100644 index 0000000..6fc823c --- /dev/null +++ b/src/routes/feishu-event.js @@ -0,0 +1,210 @@ +const express = require('express'); +const router = express.Router(); +const config = require('../config'); +const feishu = require('../services/feishu'); +const { handleFileIn } = require('../workflows/file-in'); +const { handleFileOut } = require('../workflows/file-out'); +const { handleGroupPermission } = require('../workflows/group-permission'); +const { nameToPinyin } = require('../utils/pinyin'); +const { createLog, updateLog, isApprovalProcessed } = require('../middleware/operation-log'); +const { sendAlert } = require('../utils/alert'); +const logger = require('../utils/logger'); + +/** + * POST /api/feishu/event + * 飞书事件订阅回调入口 + */ +router.post('/event', async (req, res) => { + const body = req.body; + + // 1. URL 验证挑战(首次配置事件订阅时飞书会发送) + if (body.type === 'url_verification') { + return res.json({ challenge: body.challenge }); + } + + // 2. 验证 token + const token = body.token || body.header?.token; + if (token && token !== config.feishu.verificationToken) { + logger.warn('[飞书事件] token 验证失败'); + return res.status(403).json({ error: 'token mismatch' }); + } + + // 3. 立即返回 200(飞书要求3秒内响应) + res.json({ code: 0 }); + + // 4. 异步处理事件 + try { + await processEvent(body); + } catch (err) { + logger.error(`[飞书事件] 处理失败: ${err.message}`, { stack: err.stack }); + await sendAlert('事件处理失败', `错误: ${err.message}`, 'error'); + } +}); + +/** + * 处理飞书事件 + */ +async function processEvent(body) { + // 飞书事件订阅 v2 格式 + const header = body.header || {}; + const event = body.event || {}; + const eventType = header.event_type; + + // 只处理审批实例状态变更事件 + if (eventType !== 'approval_instance') return; + + const instanceCode = event.instance_code; + const approvalCode = event.approval_code; + const status = event.status; // APPROVED / REJECTED / PENDING + + // 只处理"已通过"的审批(即审批人同意后触发自动化) + // 根据你的流程设计,也可能是 PENDING 时触发(机器人自动审批) + if (status !== 'APPROVED' && status !== 'PENDING') return; + + // 防重复处理 + if (isApprovalProcessed(instanceCode)) { + logger.info(`[飞书事件] 审批已处理过,跳过: ${instanceCode}`); + return; + } + + logger.info(`[飞书事件] 收到审批事件: code=${approvalCode}, instance=${instanceCode}, status=${status}`); + + // 根据审批定义 code 路由到不同工作流 + if (approvalCode === config.feishu.approvalCodes.fileIn) { + await processFileIn(approvalCode, instanceCode); + } else if (approvalCode === config.feishu.approvalCodes.fileOut) { + await processFileOut(approvalCode, instanceCode); + } else if (approvalCode === config.feishu.approvalCodes.groupPerm) { + await processGroupPermission(approvalCode, instanceCode); + } else { + logger.info(`[飞书事件] 非目标审批类型,忽略: ${approvalCode}`); + } +} + +/** + * 处理文件传入审批 + */ +async function processFileIn(approvalCode, instanceId) { + // 获取审批详情 + const instance = await feishu.getApprovalInstance(approvalCode, instanceId); + const form = feishu.parseApprovalForm(instance.form); + + const approvalData = { + applicantName: form['申请人'] || instance.user_name || '', + fileName: form['文件名称'] || form['文件名'] || '', + }; + + if (!approvalData.applicantName || !approvalData.fileName) { + throw new Error(`审批表单缺少必要字段: 申请人=${approvalData.applicantName}, 文件=${approvalData.fileName}`); + } + + const pinyin = nameToPinyin(approvalData.applicantName); + const logId = createLog({ + approvalCode, + instanceId, + workflowType: 'file_in', + applicantName: approvalData.applicantName, + applicantPinyin: pinyin, + action: '文件传入', + detail: `文件: ${approvalData.fileName}`, + }); + + try { + const result = await handleFileIn(approvalData, logId); + updateLog(logId, 'success'); + + // 提交审批意见 + await feishu.approveInstance(approvalCode, instanceId, result.message); + logger.info(`[文件传入] 完成: ${result.detail}`); + } catch (err) { + updateLog(logId, 'failed', err.message); + await sendAlert('文件传入失败', `审批单: ${instanceId}\n申请人: ${approvalData.applicantName}\n错误: ${err.message}`, 'error'); + throw err; + } +} + +/** + * 处理文件传出审批 + */ +async function processFileOut(approvalCode, instanceId) { + const instance = await feishu.getApprovalInstance(approvalCode, instanceId); + const form = feishu.parseApprovalForm(instance.form); + + const approvalData = { + applicantName: form['申请人'] || instance.user_name || '', + filePath: form['文件路径'] || form['文件位置'] || '', + destination: form['传出目的'] || form['用途'] || '', + customerAbbr: form['客户名缩写'] || form['客户简称'] || '', + expireDate: form['文件过期日期'] || form['过期日期'] || '', + }; + + if (!approvalData.applicantName || !approvalData.filePath || !approvalData.destination) { + throw new Error('审批表单缺少必要字段(申请人/文件路径/传出目的)'); + } + + const pinyin = nameToPinyin(approvalData.applicantName); + const logId = createLog({ + approvalCode, + instanceId, + workflowType: 'file_out', + applicantName: approvalData.applicantName, + applicantPinyin: pinyin, + action: `文件传出-${approvalData.destination}`, + detail: `文件: ${approvalData.filePath}, 目的: ${approvalData.destination}`, + }); + + try { + const result = await handleFileOut(approvalData, logId); + updateLog(logId, 'success'); + + await feishu.approveInstance(approvalCode, instanceId, result.message); + logger.info(`[文件传出] 完成: ${result.detail}`); + } catch (err) { + updateLog(logId, 'failed', err.message); + await sendAlert('文件传出失败', `审批单: ${instanceId}\n申请人: ${approvalData.applicantName}\n错误: ${err.message}`, 'error'); + throw err; + } +} + +/** + * 处理红区组权限审批 + */ +async function processGroupPermission(approvalCode, instanceId) { + const instance = await feishu.getApprovalInstance(approvalCode, instanceId); + const form = feishu.parseApprovalForm(instance.form); + + const approvalData = { + personnelName: form['待添加人员'] || form['人员'] || '', + requestType: form['申请类型'] || form['类型'] || '', + groupName: form['group名称'] || form['组名'] || form['Group'] || '', + }; + + if (!approvalData.personnelName || !approvalData.requestType || !approvalData.groupName) { + throw new Error('审批表单缺少必要字段(待添加人员/申请类型/group名称)'); + } + + const pinyin = nameToPinyin(approvalData.personnelName); + const logId = createLog({ + approvalCode, + instanceId, + workflowType: 'group_permission', + applicantName: approvalData.personnelName, + applicantPinyin: pinyin, + action: `红区组权限-${approvalData.requestType}`, + detail: `人员: ${approvalData.personnelName}, group: ${approvalData.groupName}`, + }); + + try { + const result = await handleGroupPermission(approvalData, logId); + updateLog(logId, 'success'); + + await feishu.approveInstance(approvalCode, instanceId, result.message); + logger.info(`[红区组权限] 完成: ${result.detail}`); + } catch (err) { + updateLog(logId, 'failed', err.message); + await sendAlert('红区组权限操作失败', `审批单: ${instanceId}\n人员: ${approvalData.personnelName}\n错误: ${err.message}`, 'error'); + throw err; + } +} + +module.exports = router; diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..5d66b0d --- /dev/null +++ b/src/server.js @@ -0,0 +1,65 @@ +const express = require('express'); +const path = require('path'); +const rateLimit = require('express-rate-limit'); +const config = require('./config'); +const logger = require('./utils/logger'); +const feishuEventRouter = require('./routes/feishu-event'); +const adminRouter = require('./routes/admin'); +const { cleanupOldLogs } = require('./middleware/operation-log'); + +const app = express(); + +// ===== 中间件 ===== +app.use(express.json({ limit: '1mb' })); + +// 请求日志 +app.use((req, res, next) => { + const start = Date.now(); + res.on('finish', () => { + const duration = Date.now() - start; + logger.info(`${req.method} ${req.path} ${res.statusCode} ${duration}ms`); + }); + next(); +}); + +// 飞书事件回调不限速(飞书可能重试) +// 管理接口限速 +const adminLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15分钟 + max: 100, + message: { error: '请求过于频繁,请稍后再试' }, +}); + +// ===== 路由 ===== +app.use('/api/feishu', feishuEventRouter); +app.use('/api/admin', adminLimiter, adminRouter); + +// 健康检查 +app.get('/health', (req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString(), uptime: process.uptime() }); +}); + +// 管理后台前端 +app.use('/admin', express.static(path.join(__dirname, '..', 'admin-ui'))); + +// 404 +app.use((req, res) => { + res.status(404).json({ error: 'Not Found' }); +}); + +// 全局错误处理 +app.use((err, req, res, next) => { + logger.error(`未捕获错误: ${err.message}`, { stack: err.stack }); + res.status(500).json({ error: '服务器内部错误' }); +}); + +// ===== 定时任务:清理过期日志 ===== +setInterval(() => { + try { + cleanupOldLogs(); + } catch (e) { + logger.error(`日志清理失败: ${e.message}`); + } +}, 24 * 60 * 60 * 1000); // 每24小时 + +module.exports = app; diff --git a/src/services/feishu.js b/src/services/feishu.js new file mode 100644 index 0000000..10475d1 --- /dev/null +++ b/src/services/feishu.js @@ -0,0 +1,164 @@ +const axios = require('axios'); +const config = require('../config'); +const logger = require('../utils/logger'); + +const BASE_URL = 'https://open.feishu.cn/open-apis'; + +let tenantAccessToken = null; +let tokenExpireAt = 0; + +/** + * 获取 tenant_access_token(自动缓存和刷新) + */ +async function getTenantToken() { + const now = Date.now(); + if (tenantAccessToken && now < tokenExpireAt) { + return tenantAccessToken; + } + + const res = await axios.post(`${BASE_URL}/auth/v3/tenant_access_token/internal`, { + app_id: config.feishu.appId, + app_secret: config.feishu.appSecret, + }); + + if (res.data.code !== 0) { + throw new Error(`获取飞书token失败: ${res.data.msg}`); + } + + tenantAccessToken = res.data.tenant_access_token; + // 提前5分钟过期 + tokenExpireAt = now + (res.data.expire - 300) * 1000; + logger.info('飞书 tenant_access_token 已刷新'); + return tenantAccessToken; +} + +/** + * 通用飞书 API 请求 + */ +async function feishuRequest(method, path, data = null, params = null) { + const token = await getTenantToken(); + const res = await axios({ + method, + url: `${BASE_URL}${path}`, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + data, + params, + timeout: 30000, + }); + + if (res.data.code !== 0) { + throw new Error(`飞书API错误 [${path}]: code=${res.data.code}, msg=${res.data.msg}`); + } + return res.data.data; +} + +/** + * 获取审批实例详情 + * @param {string} approvalCode - 审批定义code + * @param {string} instanceId - 审批实例ID + */ +async function getApprovalInstance(approvalCode, instanceId) { + return feishuRequest('GET', `/approval/v4/instances/${instanceId}`, null, { + approval_code: approvalCode, + }); +} + +/** + * 解析审批表单字段 + * 飞书审批表单是 JSON 数组格式: [{id, type, value, ...}] + */ +function parseApprovalForm(formJson) { + try { + const fields = JSON.parse(formJson); + const result = {}; + for (const field of fields) { + // 字段名作为key,值作为value + if (field.name) { + result[field.name] = field.value; + } + } + return result; + } catch (e) { + logger.error(`解析审批表单失败: ${e.message}`); + return {}; + } +} + +/** + * 同意审批(通过) + * @param {string} approvalCode - 审批定义code + * @param {string} instanceId - 审批实例ID + * @param {string} userId - 操作人的 user_id(应用机器人则用 app_id 对应的 open_id) + * @param {string} comment - 审批意见 + */ +async function approveInstance(approvalCode, instanceId, comment) { + // 先获取审批实例中的当前审批节点 task_id + const instance = await getApprovalInstance(approvalCode, instanceId); + const taskList = instance.task_list || []; + // 找到 PENDING 状态的 task + const pendingTask = taskList.find(t => t.status === 'PENDING'); + if (!pendingTask) { + throw new Error(`审批实例 ${instanceId} 无待处理任务`); + } + + return feishuRequest('POST', '/approval/v4/instances/approve', { + approval_code: approvalCode, + instance_code: instanceId, + user_id: pendingTask.user_id, + task_id: pendingTask.task_id, + comment: comment, + }); +} + +/** + * 添加审批评论(不改变审批状态,仅添加意见) + * 如果流程设计是由机器人节点自动通过,可以用此方法 + */ +async function addApprovalComment(approvalCode, instanceId, comment) { + return feishuRequest('POST', '/approval/v4/instances/comment', { + approval_code: approvalCode, + instance_code: instanceId, + comment, + }); +} + +/** + * 查询审批实例列表(用于轮询模式备用) + */ +async function listApprovalInstances(approvalCode, startTime, endTime) { + return feishuRequest('POST', '/approval/v4/instances/search', { + approval_code: approvalCode, + start_time: startTime, + end_time: endTime, + page_size: 50, + }); +} + +/** + * 验证飞书事件订阅请求 + */ +function verifyEventRequest(body) { + // URL 验证挑战 + if (body.type === 'url_verification') { + return { challenge: body.challenge }; + } + // 验证 token + if (body.token && body.token !== config.feishu.verificationToken) { + return null; // token 不匹配 + } + return body; +} + +module.exports = { + getTenantToken, + feishuRequest, + getApprovalInstance, + parseApprovalForm, + approveInstance, + addApprovalComment, + listApprovalInstances, + verifyEventRequest, +}; diff --git a/src/services/jumpserver.js b/src/services/jumpserver.js new file mode 100644 index 0000000..ea19017 --- /dev/null +++ b/src/services/jumpserver.js @@ -0,0 +1,140 @@ +const axios = require('axios'); +const crypto = require('crypto'); +const config = require('../config'); +const logger = require('../utils/logger'); + +/** + * JumpServer 3.x API 客户端 + * 通过 API Key 认证,使用作业执行(Job Execution)功能远程执行命令 + */ + +function getAuthHeaders() { + // JumpServer 3.x 使用 API Key 认证 + // Header: Authorization: Token : + // 或者使用 Signature 方式 + return { + 'Authorization': `Token ${config.jumpserver.keyId}:${config.jumpserver.keySecret}`, + 'Content-Type': 'application/json', + 'X-JMS-ORG': '00000000-0000-0000-0000-000000000002', // DEFAULT组织 + }; +} + +function getClient() { + return axios.create({ + baseURL: config.jumpserver.host, + timeout: 60000, + headers: getAuthHeaders(), + }); +} + +/** + * 在 ic1 服务器上执行命令 + * 使用 JumpServer 的 Job API(作业管理) + * @param {string} command - 要执行的命令 + * @param {number} timeout - 超时时间(秒) + * @returns {object} 执行结果 { output, exitCode } + */ +async function executeCommand(command, timeout = 60) { + const client = getClient(); + + logger.info(`[JumpServer] 准备执行命令: ${command}`); + + // 1. 创建作业(Ad-hoc 命令) + const jobRes = await client.post('/api/v1/ops/jobs/', { + name: `auto_${Date.now()}`, + type: 'adhoc', + module: 'shell', + args: command, + runas: config.jumpserver.systemUser, + runas_policy: 'skip', + assets: [config.jumpserver.assetIc1], + timeout: timeout, + }); + + const jobId = jobRes.data.id; + logger.info(`[JumpServer] 作业已创建: ${jobId}`); + + // 2. 执行作业 + const execRes = await client.post(`/api/v1/ops/jobs/${jobId}/run/`); + const executionId = execRes.data.id; + logger.info(`[JumpServer] 作业执行中: execution=${executionId}`); + + // 3. 轮询执行结果 + const result = await waitForExecution(client, executionId, timeout); + return result; +} + +/** + * 等待作业执行完成 + */ +async function waitForExecution(client, executionId, timeout) { + const maxWait = (timeout + 30) * 1000; // 额外30秒缓冲 + const startTime = Date.now(); + + while (Date.now() - startTime < maxWait) { + const res = await client.get(`/api/v1/ops/executions/${executionId}/`); + const status = res.data.status; + + if (status === 'success') { + // 获取详细输出 + const resultRes = await client.get( + `/api/v1/ops/executions/${executionId}/result/` + ); + const rawResult = resultRes.data; + // 解析各资产的执行结果 + const assetResult = rawResult[config.jumpserver.assetIc1] || {}; + return { + success: true, + exitCode: assetResult.rc ?? 0, + output: assetResult.result || JSON.stringify(rawResult), + }; + } + + if (status === 'failed' || status === 'error') { + const resultRes = await client.get( + `/api/v1/ops/executions/${executionId}/result/` + ).catch(() => ({ data: {} })); + return { + success: false, + exitCode: -1, + output: JSON.stringify(resultRes.data), + }; + } + + // 等待2秒后重试 + await new Promise(r => setTimeout(r, 2000)); + } + + throw new Error(`[JumpServer] 命令执行超时: executionId=${executionId}`); +} + +/** + * 执行多条命令(顺序执行) + * @param {string[]} commands - 命令列表 + */ +async function executeCommands(commands) { + const results = []; + for (const cmd of commands) { + const result = await executeCommand(cmd); + results.push({ command: cmd, ...result }); + if (!result.success) { + throw new Error(`命令执行失败 [${cmd}]: ${result.output}`); + } + } + return results; +} + +/** + * 获取资产信息(验证连接) + */ +async function getAssetInfo() { + const client = getClient(); + const res = await client.get(`/api/v1/assets/hosts/${config.jumpserver.assetIc1}/`); + return res.data; +} + +module.exports = { + executeCommand, + executeCommands, + getAssetInfo, +}; diff --git a/src/services/nextcloud.js b/src/services/nextcloud.js new file mode 100644 index 0000000..8a122a8 --- /dev/null +++ b/src/services/nextcloud.js @@ -0,0 +1,151 @@ +const axios = require('axios'); +const config = require('../config'); +const logger = require('../utils/logger'); + +/** + * Nextcloud WebDAV + OCS API 客户端 + */ + +function getDavClient() { + return axios.create({ + baseURL: `${config.nextcloud.host}/remote.php/dav/files/${config.nextcloud.username}`, + auth: { + username: config.nextcloud.username, + password: config.nextcloud.password, + }, + timeout: 60000, + }); +} + +function getOcsClient() { + return axios.create({ + baseURL: `${config.nextcloud.host}/ocs/v2.php/apps/files_sharing/api/v1`, + auth: { + username: config.nextcloud.username, + password: config.nextcloud.password, + }, + headers: { + 'OCS-APIRequest': 'true', + 'Content-Type': 'application/json', + }, + timeout: 30000, + }); +} + +/** + * 检查文件/目录是否存在 + */ +async function exists(path) { + const client = getDavClient(); + try { + await client.request({ method: 'PROPFIND', url: encodeURI(path), maxRedirects: 0 }); + return true; + } catch (e) { + if (e.response?.status === 404) return false; + throw e; + } +} + +/** + * 创建目录(MKCOL) + */ +async function createFolder(path) { + const client = getDavClient(); + try { + await client.request({ method: 'MKCOL', url: encodeURI(path) }); + logger.info(`[Nextcloud] 目录已创建: ${path}`); + } catch (e) { + // 405 表示已存在 + if (e.response?.status !== 405) throw e; + } +} + +/** + * 确保目录存在(逐级创建) + */ +async function ensureFolder(path) { + const parts = path.split('/').filter(Boolean); + let current = ''; + for (const part of parts) { + current += `/${part}`; + await createFolder(current); + } +} + +/** + * 复制文件(WebDAV COPY) + * @param {string} srcPath - 源文件路径(相对于用户根目录) + * @param {string} destPath - 目标路径(含文件名) + */ +async function copyFile(srcPath, destPath) { + const client = getDavClient(); + const destUrl = `${config.nextcloud.host}/remote.php/dav/files/${config.nextcloud.username}${encodeURI(destPath)}`; + + await client.request({ + method: 'COPY', + url: encodeURI(srcPath), + headers: { + Destination: destUrl, + Overwrite: 'T', + }, + }); + logger.info(`[Nextcloud] 文件复制完成: ${srcPath} -> ${destPath}`); +} + +/** + * 创建共享链接或用户共享 + * @param {string} path - 文件/目录路径 + * @param {string} shareWith - 共享给谁(用户名/组名) + * @param {number} shareType - 共享类型: 0=用户, 1=组, 3=公开链接 + * @param {string} expireDate - 过期日期 YYYY-MM-DD + * @param {number} permissions - 权限 + */ +async function createShare(path, shareWith, shareType = 0, expireDate = null, permissions = null) { + const client = getOcsClient(); + + const shareData = { + path, + shareType, + shareWith, + permissions: permissions || config.nextcloud.sharePermissions, + }; + + if (expireDate) { + shareData.expireDate = expireDate; + } + + const res = await client.post('/shares', shareData); + const shareId = res.data?.ocs?.data?.id; + logger.info(`[Nextcloud] 共享已创建: path=${path}, shareWith=${shareWith}, id=${shareId}`); + return res.data?.ocs?.data; +} + +/** + * 复制文件并设置共享(组合操作) + * @param {string} srcPath - 源路径 + * @param {string} destDir - 目标目录 + * @param {string} fileName - 文件名 + * @param {string} shareWith - 共享对象 + * @param {string} expireDate - 过期日期 YYYY-MM-DD + */ +async function copyAndShare(srcPath, destDir, fileName, shareWith, expireDate) { + // 确保目标目录存在 + await ensureFolder(destDir); + + const destPath = `${destDir}/${fileName}`; + // 复制文件 + await copyFile(srcPath, destPath); + + // 创建共享 + const share = await createShare(destPath, shareWith, 0, expireDate); + return { destPath, share }; +} + +module.exports = { + exists, + createFolder, + ensureFolder, + copyFile, + createShare, + copyAndShare, +}; diff --git a/src/services/synology.js b/src/services/synology.js new file mode 100644 index 0000000..9897b97 --- /dev/null +++ b/src/services/synology.js @@ -0,0 +1,209 @@ +const axios = require('axios'); +const https = require('https'); +const config = require('../config'); +const logger = require('../utils/logger'); + +let sid = null; // Session ID + +function getAxiosInstance() { + return axios.create({ + baseURL: config.synology.host, + timeout: 30000, + httpsAgent: config.synology.skipTls + ? new https.Agent({ rejectUnauthorized: false }) + : undefined, + }); +} + +/** + * 登录群晖 DSM,获取 SID + */ +async function login() { + const client = getAxiosInstance(); + const res = await client.get('/webapi/auth.cgi', { + params: { + api: 'SYNO.API.Auth', + version: 6, + method: 'login', + account: config.synology.username, + passwd: config.synology.password, + format: 'sid', + session: 'FileTransfer', + }, + }); + + if (!res.data.success) { + throw new Error(`群晖登录失败: error code ${res.data.error?.code}`); + } + sid = res.data.data.sid; + logger.info('群晖 NAS 登录成功'); + return sid; +} + +/** + * 确保已登录 + */ +async function ensureLogin() { + if (!sid) { + await login(); + } +} + +/** + * 列出目录内容 + * @param {string} folderPath - 完整路径,如 /wingsemi/zhangsan + */ +async function listFiles(folderPath) { + await ensureLogin(); + const client = getAxiosInstance(); + const res = await client.get('/webapi/entry.cgi', { + params: { + api: 'SYNO.FileStation.List', + version: 2, + method: 'list', + folder_path: folderPath, + _sid: sid, + }, + }); + + if (!res.data.success) { + // 如果是 session 过期,重新登录 + if (res.data.error?.code === 105 || res.data.error?.code === 106) { + sid = null; + return listFiles(folderPath); + } + throw new Error(`群晖列目录失败 [${folderPath}]: error ${res.data.error?.code}`); + } + return res.data.data.files || []; +} + +/** + * 在指定目录中查找文件 + * @param {string} dirPath - 目录路径 + * @param {string} fileName - 文件名(支持模糊匹配) + * @returns {object|null} 文件信息 + */ +async function findFile(dirPath, fileName) { + const files = await listFiles(dirPath); + // 精确匹配优先 + let found = files.find(f => f.name === fileName); + if (!found) { + // 模糊匹配(包含关系) + found = files.find(f => f.name.includes(fileName) || fileName.includes(f.name)); + } + return found || null; +} + +/** + * 复制文件/文件夹 + * @param {string} srcPath - 源路径(完整路径含文件名) + * @param {string} destFolderPath - 目标文件夹路径 + * @param {boolean} overwrite - 是否覆盖 + */ +async function copyFile(srcPath, destFolderPath, overwrite = true) { + await ensureLogin(); + const client = getAxiosInstance(); + + // 先确保目标文件夹存在 + await createFolderIfNotExists(destFolderPath); + + const res = await client.get('/webapi/entry.cgi', { + params: { + api: 'SYNO.FileStation.CopyMove', + version: 3, + method: 'start', + path: srcPath, + dest_folder_path: destFolderPath, + overwrite: overwrite, + _sid: sid, + }, + }); + + if (!res.data.success) { + throw new Error(`群晖复制失败 [${srcPath} -> ${destFolderPath}]: error ${res.data.error?.code}`); + } + + // 异步任务,需要轮询完成状态 + const taskId = res.data.data.taskid; + await waitForTaskComplete(taskId); + logger.info(`群晖文件复制完成: ${srcPath} -> ${destFolderPath}`); +} + +/** + * 等待异步任务完成 + */ +async function waitForTaskComplete(taskId, maxRetries = 30) { + const client = getAxiosInstance(); + for (let i = 0; i < maxRetries; i++) { + const res = await client.get('/webapi/entry.cgi', { + params: { + api: 'SYNO.FileStation.CopyMove', + version: 3, + method: 'status', + taskid: taskId, + _sid: sid, + }, + }); + + if (res.data.success && res.data.data.finished) { + if (res.data.data.progress === 1) return; + throw new Error(`群晖任务异常终止: ${JSON.stringify(res.data.data)}`); + } + // 等待1秒后重试 + await new Promise(r => setTimeout(r, 1000)); + } + throw new Error(`群晖任务超时: taskId=${taskId}`); +} + +/** + * 创建文件夹(如果不存在) + */ +async function createFolderIfNotExists(folderPath) { + await ensureLogin(); + const client = getAxiosInstance(); + + // 逐级创建 + const parts = folderPath.split('/').filter(Boolean); + let current = ''; + for (const part of parts) { + const parent = current || '/'; + current = `${current}/${part}`; + try { + const res = await client.get('/webapi/entry.cgi', { + params: { + api: 'SYNO.FileStation.CreateFolder', + version: 2, + method: 'create', + folder_path: parent, + name: part, + force_parent: false, + _sid: sid, + }, + }); + // 如果已存在会返回错误,忽略即可 + } catch (e) { + // 忽略已存在的错误 + } + } +} + +/** + * 检查文件/目录是否存在 + */ +async function exists(path) { + try { + await listFiles(path); + return true; + } catch { + return false; + } +} + +module.exports = { + login, + listFiles, + findFile, + copyFile, + createFolderIfNotExists, + exists, +}; diff --git a/src/utils/alert.js b/src/utils/alert.js new file mode 100644 index 0000000..72ff1bd --- /dev/null +++ b/src/utils/alert.js @@ -0,0 +1,47 @@ +const axios = require('axios'); +const config = require('../config'); +const logger = require('../utils/logger'); + +/** + * 通过飞书群机器人 Webhook 发送告警 + */ +async function sendAlert(title, content, level = 'warning') { + const webhookUrl = config.feishu.alertWebhook; + if (!webhookUrl) { + logger.warn('未配置告警 Webhook,跳过告警发送'); + return; + } + + const colorMap = { info: 'blue', warning: 'orange', error: 'red' }; + + const card = { + msg_type: 'interactive', + card: { + header: { + title: { tag: 'plain_text', content: `[${level.toUpperCase()}] ${title}` }, + template: colorMap[level] || 'orange', + }, + elements: [ + { + tag: 'div', + text: { tag: 'lark_md', content }, + }, + { + tag: 'note', + elements: [ + { tag: 'plain_text', content: `文件传输自动化系统 · ${new Date().toLocaleString('zh-CN')}` }, + ], + }, + ], + }, + }; + + try { + await axios.post(webhookUrl, card, { timeout: 10000 }); + logger.info(`告警已发送: ${title}`); + } catch (err) { + logger.error(`告警发送失败: ${err.message}`); + } +} + +module.exports = { sendAlert }; diff --git a/src/utils/date.js b/src/utils/date.js new file mode 100644 index 0000000..b7ad2b2 --- /dev/null +++ b/src/utils/date.js @@ -0,0 +1,17 @@ +const dayjs = require('dayjs'); + +/** + * 获取当天日期字符串,格式 YYYYMMDD + */ +function getTodayStr() { + return dayjs().format('YYYYMMDD'); +} + +/** + * 获取当前时间字符串 + */ +function nowStr() { + return dayjs().format('YYYY-MM-DD HH:mm:ss'); +} + +module.exports = { getTodayStr, nowStr }; diff --git a/src/utils/logger.js b/src/utils/logger.js new file mode 100644 index 0000000..a6ffc78 --- /dev/null +++ b/src/utils/logger.js @@ -0,0 +1,45 @@ +const winston = require('winston'); +const path = require('path'); +const fs = require('fs'); + +const LOG_DIR = path.join(__dirname, '..', 'data', 'logs'); +if (!fs.existsSync(LOG_DIR)) { + fs.mkdirSync(LOG_DIR, { recursive: true }); +} + +const logger = winston.createLogger({ + level: 'info', + format: winston.format.combine( + winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + winston.format.errors({ stack: true }), + winston.format.json() + ), + defaultMeta: { service: 'file-transfer-automation' }, + transports: [ + // 所有日志 + new winston.transports.File({ + filename: path.join(LOG_DIR, 'combined.log'), + maxsize: 10 * 1024 * 1024, // 10MB + maxFiles: 10, + }), + // 错误日志单独一份 + new winston.transports.File({ + filename: path.join(LOG_DIR, 'error.log'), + level: 'error', + maxsize: 10 * 1024 * 1024, + maxFiles: 5, + }), + ], +}); + +// 开发环境输出到控制台 +if (process.env.NODE_ENV !== 'production') { + logger.add(new winston.transports.Console({ + format: winston.format.combine( + winston.format.colorize(), + winston.format.simple() + ), + })); +} + +module.exports = logger; diff --git a/src/utils/pinyin.js b/src/utils/pinyin.js new file mode 100644 index 0000000..be6af76 --- /dev/null +++ b/src/utils/pinyin.js @@ -0,0 +1,15 @@ +const { pinyin } = require('pinyin-pro'); + +/** + * 将中文姓名转为拼音(小写、无声调、无空格) + * 例: "张三" -> "zhangsan" + */ +function nameToPinyin(name) { + if (!name) return ''; + return pinyin(name, { toneType: 'none', type: 'array' }) + .join('') + .toLowerCase() + .replace(/[^a-z]/g, ''); +} + +module.exports = { nameToPinyin }; diff --git a/src/workflows/file-in.js b/src/workflows/file-in.js new file mode 100644 index 0000000..2b7e3fb --- /dev/null +++ b/src/workflows/file-in.js @@ -0,0 +1,47 @@ +const synology = require('../services/synology'); +const feishu = require('../services/feishu'); +const { nameToPinyin } = require('../utils/pinyin'); +const { validatePath } = require('../middleware/security'); +const logger = require('../utils/logger'); + +/** + * 文件传入工作流 + * + * 流程: + * 1. 从审批中提取:申请人名称、文件名称 + * 2. 在群晖 /wingsemi/{申请人拼音}/ 下找到文件 + * 3. 复制到 /IN_R/{申请人拼音}/ + * 4. 在飞书审批中提交意见"已传入" + */ +async function handleFileIn(approvalData, logId) { + const { applicantName, fileName } = approvalData; + const pinyin = nameToPinyin(applicantName); + + logger.info(`[文件传入] 开始处理: 申请人=${applicantName}(${pinyin}), 文件=${fileName}`); + + // 1. 验证路径安全 + const srcDir = `/wingsemi/${pinyin}`; + const destDir = `/IN_R/${pinyin}`; + validatePath(srcDir); + validatePath(destDir); + + // 2. 在群晖中查找文件 + const file = await synology.findFile(srcDir, fileName); + if (!file) { + throw new Error(`在 ${srcDir} 中未找到文件: ${fileName}`); + } + logger.info(`[文件传入] 找到文件: ${file.path}`); + + // 3. 复制到 /IN_R/{pinyin}/ + await synology.copyFile(file.path, destDir); + logger.info(`[文件传入] 文件已复制: ${file.path} -> ${destDir}`); + + // 4. 提交审批意见 + return { + success: true, + message: '已传入', + detail: `文件 ${fileName} 已从 ${srcDir} 复制到 ${destDir}`, + }; +} + +module.exports = { handleFileIn }; diff --git a/src/workflows/file-out.js b/src/workflows/file-out.js new file mode 100644 index 0000000..daafe98 --- /dev/null +++ b/src/workflows/file-out.js @@ -0,0 +1,118 @@ +const jumpserver = require('../services/jumpserver'); +const synology = require('../services/synology'); +const nextcloud = require('../services/nextcloud'); +const { nameToPinyin } = require('../utils/pinyin'); +const { getTodayStr } = require('../utils/date'); +const { validatePath, validateCommand } = require('../middleware/security'); +const logger = require('../utils/logger'); + +/** + * 文件传出工作流 + * + * 内部使用: + * 1. 提取:传出目的、申请人名称、文件路径 + * 2. JumpServer 连接 ic1,执行 cp "文件路径" /OUT/{日期}/(无则创建) + * 3. 群晖:/OUT_R/{日期}/{文件名} 复制到 /wingsemi/{申请人拼音}/ + * 4. 审批意见:"已传出至/wingsemi/{申请人拼音}" + * + * 客户发布: + * 1. 额外提取:客户名缩写、文件过期日期 + * 2. JumpServer 连接 ic1,执行复制到 /OUT/{日期}/ + * 3. Nextcloud:/OUT-RED/{日期}/{文件名} 复制到 /WCPS-Files/{客户缩写}/{日期}/ + * 4. 设置共享(联系人=客户缩写,过期日期) + * 5. 审批意见:"已传出至客户发布目录" + */ +async function handleFileOut(approvalData, logId) { + const { applicantName, filePath, destination, customerAbbr, expireDate } = approvalData; + const pinyin = nameToPinyin(applicantName); + const today = getTodayStr(); + const fileName = filePath.split('/').pop(); + + logger.info(`[文件传出] 开始处理: 申请人=${applicantName}, 目的=${destination}, 文件=${filePath}`); + + if (destination === '内部使用') { + return await handleInternalUse(applicantName, pinyin, filePath, fileName, today); + } else if (destination === '客户发布') { + return await handleCustomerRelease(applicantName, pinyin, filePath, fileName, today, customerAbbr, expireDate); + } else { + throw new Error(`未知的传出目的: ${destination}`); + } +} + +/** + * 内部使用流程 + */ +async function handleInternalUse(applicantName, pinyin, filePath, fileName, today) { + // 1. 验证路径安全 + validatePath(filePath); + validatePath(`/OUT/${today}`); + + // 2. JumpServer: 在 ic1 上创建目录并复制 + const mkdirCmd = `mkdir -p /OUT/${today}`; + const cpCmd = `cp "${filePath}" /OUT/${today}/`; + + // 安全校验命令 + validateCommand(mkdirCmd); + validateCommand(cpCmd); + + await jumpserver.executeCommands([mkdirCmd, cpCmd]); + logger.info(`[文件传出-内部] ic1 复制完成: ${filePath} -> /OUT/${today}/`); + + // 3. 群晖: 从 /OUT_R/{日期}/{文件名} 复制到 /wingsemi/{拼音}/ + const nasSrcPath = `/OUT_R/${today}/${fileName}`; + const nasDestDir = `/wingsemi/${pinyin}`; + validatePath(nasSrcPath); + validatePath(nasDestDir); + + await synology.copyFile(nasSrcPath, nasDestDir); + logger.info(`[文件传出-内部] NAS复制完成: ${nasSrcPath} -> ${nasDestDir}`); + + return { + success: true, + message: `已传出至/wingsemi/${pinyin}`, + detail: `文件 ${fileName} 已传出至 ${nasDestDir}`, + }; +} + +/** + * 客户发布流程 + */ +async function handleCustomerRelease(applicantName, pinyin, filePath, fileName, today, customerAbbr, expireDate) { + if (!customerAbbr) throw new Error('客户发布缺少客户名缩写'); + if (!expireDate) throw new Error('客户发布缺少文件过期日期'); + + // 1. 验证路径安全 + validatePath(filePath); + validatePath(`/OUT/${today}`); + validatePath(`/WCPS-Files/${customerAbbr}`); + + // 2. JumpServer: 在 ic1 上创建目录并复制 + const mkdirCmd = `mkdir -p /OUT/${today}`; + const cpCmd = `cp "${filePath}" /OUT/${today}/`; + validateCommand(mkdirCmd); + validateCommand(cpCmd); + + await jumpserver.executeCommands([mkdirCmd, cpCmd]); + logger.info(`[文件传出-客户] ic1 复制完成: ${filePath} -> /OUT/${today}/`); + + // 3. Nextcloud: 复制到客户目录并设置共享 + const ncSrcPath = `/OUT-RED/${today}/${fileName}`; + const ncDestDir = `/WCPS-Files/${customerAbbr}/${today}`; + + const result = await nextcloud.copyAndShare( + ncSrcPath, + ncDestDir, + fileName, + customerAbbr, + expireDate // 格式 YYYY-MM-DD + ); + logger.info(`[文件传出-客户] Nextcloud复制+共享完成: ${ncSrcPath} -> ${ncDestDir}`); + + return { + success: true, + message: `已传出至客户发布目录 /WCPS-Files/${customerAbbr}/${today}`, + detail: `文件 ${fileName} 已发布至 ${ncDestDir},共享给 ${customerAbbr},过期日期 ${expireDate}`, + }; +} + +module.exports = { handleFileOut }; diff --git a/src/workflows/group-permission.js b/src/workflows/group-permission.js new file mode 100644 index 0000000..cd7f763 --- /dev/null +++ b/src/workflows/group-permission.js @@ -0,0 +1,103 @@ +const jumpserver = require('../services/jumpserver'); +const { nameToPinyin } = require('../utils/pinyin'); +const { validateCommand } = require('../middleware/security'); +const logger = require('../utils/logger'); + +/** + * 红区组权限工作流 + * + * 添加至已有 group: + * 1. 提取:待添加人员、group名称 + * 2. JumpServer ic1: usermod -aG "{group}" "{人员拼音}" + * 3. JumpServer ic1: make -C /var/yp + * + * 新建 group: + * 1. 提取:待添加人员、group名称 + * 2. JumpServer ic1: 查询最大 group id,+1 作为新 id + * 3. JumpServer ic1: groupadd -g "{new_id}" "{group}" + * 4. JumpServer ic1: usermod -aG "{group}" "{人员拼音}" + * 5. JumpServer ic1: make -C /var/yp + */ +async function handleGroupPermission(approvalData, logId) { + const { personnelName, requestType, groupName } = approvalData; + const userPinyin = nameToPinyin(personnelName); + + logger.info(`[红区组权限] 开始处理: 人员=${personnelName}(${userPinyin}), 类型=${requestType}, group=${groupName}`); + + // 安全校验 group 名称(只允许字母、数字、下划线、连字符) + if (!/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(groupName)) { + throw new Error(`非法的 group 名称: ${groupName}`); + } + if (!/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(userPinyin)) { + throw new Error(`非法的用户名: ${userPinyin}`); + } + + if (requestType === '添加至已有group') { + return await addToExistingGroup(userPinyin, groupName); + } else if (requestType === '新建group') { + return await createNewGroup(userPinyin, groupName); + } else { + throw new Error(`未知的申请类型: ${requestType}`); + } +} + +/** + * 添加用户到已有 group + */ +async function addToExistingGroup(userPinyin, groupName) { + const usermodCmd = `usermod -aG "${groupName}" "${userPinyin}"`; + const makeCmd = 'make -C /var/yp'; + + validateCommand(usermodCmd); + validateCommand(makeCmd); + + await jumpserver.executeCommands([usermodCmd, makeCmd]); + logger.info(`[红区组权限] 用户 ${userPinyin} 已添加到 group ${groupName}`); + + return { + success: true, + message: `已将 ${userPinyin} 添加至 ${groupName}`, + detail: `执行: ${usermodCmd} && ${makeCmd}`, + }; +} + +/** + * 新建 group 并添加用户 + */ +async function createNewGroup(userPinyin, groupName) { + // 1. 查询当前最大 group id + const getMaxGidCmd = "getent group | awk -F: '{print $3}' | sort -n | tail -1"; + validateCommand(getMaxGidCmd); + + const gidResult = await jumpserver.executeCommand(getMaxGidCmd); + if (!gidResult.success) { + throw new Error(`获取最大 GID 失败: ${gidResult.output}`); + } + + const maxGid = parseInt(gidResult.output.trim()); + if (isNaN(maxGid)) { + throw new Error(`无法解析最大 GID: ${gidResult.output}`); + } + const newGid = maxGid + 1; + logger.info(`[红区组权限] 当前最大GID=${maxGid}, 新GID=${newGid}`); + + // 2. 创建 group + const groupaddCmd = `groupadd -g ${newGid} "${groupName}"`; + const usermodCmd = `usermod -aG "${groupName}" "${userPinyin}"`; + const makeCmd = 'make -C /var/yp'; + + validateCommand(groupaddCmd); + validateCommand(usermodCmd); + validateCommand(makeCmd); + + await jumpserver.executeCommands([groupaddCmd, usermodCmd, makeCmd]); + logger.info(`[红区组权限] 新 group ${groupName}(gid=${newGid}) 已创建,用户 ${userPinyin} 已添加`); + + return { + success: true, + message: `已创建 ${groupName}(GID:${newGid}) 并添加 ${userPinyin}`, + detail: `执行: ${groupaddCmd} && ${usermodCmd} && ${makeCmd}`, + }; +} + +module.exports = { handleGroupPermission };