diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..dda9b28 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,60 @@ +# Node.js +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ + +# Dependency directories +node_modules/ + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Git +.git/ +.gitignore + +# Build artifacts +frontend/dist/ +backend/dist/ + +# Docker +Dockerfile* +docker-compose* +.dockerignore + +# Temporary files +*.tmp +*.temp \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..145e8b2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,69 @@ +# 多阶段构建Dockerfile +FROM node:18-alpine AS frontend-build + +# 设置工作目录 +WORKDIR /app + +# 复制前端文件 +COPY frontend/package*.json ./frontend/ +COPY frontend/ ./frontend/ + +# 安装前端依赖并构建 +RUN cd frontend && npm ci && npm run build + +# 后端构建阶段 +FROM node:18-alpine AS backend-build + +# 设置工作目录 +WORKDIR /app + +# 复制后端文件 +COPY backend/package*.json ./backend/ +COPY backend/ ./backend/ + +# 安装后端依赖 +RUN cd backend && npm ci --only=production + +# 最终镜像 +FROM node:18-alpine + +# 安装必要的系统工具 +RUN apk add --no-cache \ + openssh-client \ + && rm -rf /var/cache/apk/* + +# 创建非root用户 +RUN addgroup -g 1001 -S nodejs && \ + adduser -S nodejs -u 1001 + +# 设置工作目录 +WORKDIR /app + +# 从构建阶段复制文件 +COPY --from=frontend-build /app/frontend/dist ./frontend/dist +COPY --from=backend-build /app/backend ./backend +RUN rm -f ./backend/.env || true + +# 复制根目录文件 +COPY package*.json ./ +COPY install.sql ./backend/ + +# 设置权限 +RUN chown -R nodejs:nodejs /app + +# 切换到非root用户 +USER nodejs + +# 环境变量 +ENV NODE_ENV=production +ENV PORT=3000 + +# 暴露端口 +EXPOSE 3000 + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3000/api/auth/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })" + +# 启动命令 +CMD ["node", "backend/server.js"] diff --git a/README.md b/README.md index 9bb2407..231ed00 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,63 @@ GRANT ALL PRIVILEGES ON webssh.* TO 'webssh'@'localhost'; FLUSH PRIVILEGES; ``` +### 建表SQL(MySQL) +```sql +-- 用户表 +CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(255) UNIQUE NOT NULL, + phone VARCHAR(20), + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 分组表 +CREATE TABLE IF NOT EXISTS groups ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_groups_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE KEY unique_user_group (user_id, name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 服务器表 +CREATE TABLE IF NOT EXISTS servers ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + name VARCHAR(255) NOT NULL, + host VARCHAR(255) NOT NULL, + port INT DEFAULT 22, + username VARCHAR(255) NOT NULL, + password_encrypted TEXT, + private_key_encrypted TEXT, + auth_type ENUM('password', 'key') DEFAULT 'password', + group_id INT, + group_name VARCHAR(255) DEFAULT 'Default', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_servers_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_servers_group FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 连接日志表 +CREATE TABLE IF NOT EXISTS connection_logs ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + server_id INT, + host VARCHAR(255) NOT NULL, + start_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + end_time TIMESTAMP NULL, + duration INT DEFAULT 0, + CONSTRAINT fk_logs_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_logs_server FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + #### 方式三:系统初始化 ```sql -- 如果需要重新初始化系统,使用初始化脚本 @@ -250,6 +307,11 @@ ENCRYPTION_KEY=abcdefghijklmnopqrstuvwxyz123456 - 生产环境请使用强密码生成器创建安全的密钥 - 不要将真实的密钥提交到版本控制系统 +生成一个符合要求的密钥: +```bash +openssl rand -hex 16 # 输出32字符长度的十六进制字符串 +``` + ### 4.5 数据库初始化验证 数据库初始化完成后,后端服务会自动创建必要的表结构。如果遇到表结构错误,可以手动执行: @@ -418,4 +480,4 @@ npm run dev --- -**注意**: 本项目仍在开发中,部分功能可能不完善。生产环境使用前请充分测试。 \ No newline at end of file +**注意**: 本项目仍在开发中,部分功能可能不完善。生产环境使用前请充分测试。 diff --git a/backend/routes/users.js b/backend/routes/users.js new file mode 100644 index 0000000..72b0c36 --- /dev/null +++ b/backend/routes/users.js @@ -0,0 +1,334 @@ +const express = require('express'); +const jwt = require('jsonwebtoken'); +const bcrypt = require('bcryptjs'); +const db = require('../config/database'); + +const router = express.Router(); + +// 验证令牌中间件 +const authenticateToken = (req, res, next) => { + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; + + if (!token) { + return res.status(401).json({ error: '访问令牌缺失' }); + } + + jwt.verify(token, process.env.JWT_SECRET || 'webssh-secret-key', (err, user) => { + if (err) { + return res.status(403).json({ error: '令牌无效' }); + } + req.user = user; + next(); + }); +}; + +// 检查是否是管理员 +const requireAdmin = async (req, res, next) => { + try { + const [users] = await db.pool.execute( + 'SELECT role FROM users WHERE id = ?', + [req.user.userId] + ); + + if (users.length === 0) { + return res.status(404).json({ error: '用户不存在' }); + } + + if (users[0].role !== 'admin') { + return res.status(403).json({ error: '需要管理员权限' }); + } + + next(); + } catch (error) { + console.error('检查管理员权限错误:', error); + res.status(500).json({ error: '服务器内部错误' }); + } +}; + +// 获取所有用户列表(仅管理员) +router.get('/', authenticateToken, requireAdmin, async (req, res) => { + try { + const [users] = await db.pool.execute(` + SELECT + id, email, phone, role, status, + created_at, updated_at + FROM users + ORDER BY created_at DESC + `); + + // 获取每个用户的服务器数量 + for (let user of users) { + const [serverCount] = await db.pool.execute( + 'SELECT COUNT(*) as count FROM servers WHERE user_id = ?', + [user.id] + ); + user.serverCount = serverCount[0].count; + } + + res.json({ users }); + } catch (error) { + console.error('获取用户列表错误:', error); + res.status(500).json({ error: '服务器内部错误' }); + } +}); + +// 创建新用户(仅管理员) +router.post('/', authenticateToken, requireAdmin, async (req, res) => { + try { + const { email, phone, password, role = 'user' } = req.body; + + // 验证输入 + if (!email || !password) { + return res.status(400).json({ error: '邮箱和密码是必填项' }); + } + + if (password.length < 6) { + return res.status(400).json({ error: '密码长度至少6位' }); + } + + if (!['admin', 'user'].includes(role)) { + return res.status(400).json({ error: '角色参数无效' }); + } + + // 检查用户是否已存在 + const [existingUsers] = await db.pool.execute( + 'SELECT id FROM users WHERE email = ?', + [email] + ); + + if (existingUsers.length > 0) { + return res.status(400).json({ error: '该邮箱已被注册' }); + } + + // 加密密码 + const saltRounds = 12; + const passwordHash = await bcrypt.hash(password, saltRounds); + + // 创建用户 + const [result] = await db.pool.execute( + 'INSERT INTO users (email, phone, password_hash, role, status) VALUES (?, ?, ?, ?, ?)', + [email, phone || null, passwordHash, role, 'active'] + ); + + // 获取新创建的用户详情 + const [newUser] = await db.pool.execute( + 'SELECT id, email, phone, role, status, created_at FROM users WHERE id = ?', + [result.insertId] + ); + + res.status(201).json({ + message: '用户创建成功', + user: { + ...newUser[0], + serverCount: 0 + } + }); + } catch (error) { + console.error('创建用户错误:', error); + res.status(500).json({ error: '服务器内部错误' }); + } +}); + +// 更新用户信息(仅管理员) +router.put('/:id', authenticateToken, requireAdmin, async (req, res) => { + try { + const userId = req.params.id; + const { email, phone, role, status } = req.body; + + // 检查用户是否存在 + const [existingUsers] = await db.pool.execute( + 'SELECT id FROM users WHERE id = ?', + [userId] + ); + + if (existingUsers.length === 0) { + return res.status(404).json({ error: '用户不存在' }); + } + + // 验证角色参数 + if (role && !['admin', 'user'].includes(role)) { + return res.status(400).json({ error: '角色参数无效' }); + } + + // 验证状态参数 + if (status && !['active', 'disabled'].includes(status)) { + return res.status(400).json({ error: '状态参数无效' }); + } + + // 检查邮箱是否已被其他用户使用 + if (email) { + const [duplicateUsers] = await db.pool.execute( + 'SELECT id FROM users WHERE email = ? AND id != ?', + [email, userId] + ); + + if (duplicateUsers.length > 0) { + return res.status(400).json({ error: '该邮箱已被其他用户使用' }); + } + } + + // 构建更新字段 + const updateFields = []; + const updateValues = []; + + if (email) { + updateFields.push('email = ?'); + updateValues.push(email); + } + + if (phone !== undefined) { + updateFields.push('phone = ?'); + updateValues.push(phone || null); + } + + if (role) { + updateFields.push('role = ?'); + updateValues.push(role); + } + + if (status) { + updateFields.push('status = ?'); + updateValues.push(status); + } + + if (updateFields.length === 0) { + return res.status(400).json({ error: '没有提供更新信息' }); + } + + updateValues.push(userId); + + await db.pool.execute( + `UPDATE users SET ${updateFields.join(', ')} WHERE id = ?`, + updateValues + ); + + // 获取更新后的用户详情 + const [updatedUser] = await db.pool.execute( + 'SELECT id, email, phone, role, status, created_at FROM users WHERE id = ?', + [userId] + ); + + // 获取服务器数量 + const [serverCount] = await db.pool.execute( + 'SELECT COUNT(*) as count FROM servers WHERE user_id = ?', + [userId] + ); + + res.json({ + message: '用户信息更新成功', + user: { + ...updatedUser[0], + serverCount: serverCount[0].count + } + }); + } catch (error) { + console.error('更新用户信息错误:', error); + res.status(500).json({ error: '服务器内部错误' }); + } +}); + +// 重置用户密码(仅管理员) +router.put('/:id/password', authenticateToken, requireAdmin, async (req, res) => { + try { + const userId = req.params.id; + const { newPassword } = req.body; + + if (!newPassword) { + return res.status(400).json({ error: '新密码是必填项' }); + } + + if (newPassword.length < 6) { + return res.status(400).json({ error: '新密码长度至少6位' }); + } + + // 检查用户是否存在 + const [existingUsers] = await db.pool.execute( + 'SELECT id FROM users WHERE id = ?', + [userId] + ); + + if (existingUsers.length === 0) { + return res.status(404).json({ error: '用户不存在' }); + } + + // 加密新密码 + const saltRounds = 12; + const newPasswordHash = await bcrypt.hash(newPassword, saltRounds); + + // 更新密码 + await db.pool.execute( + 'UPDATE users SET password_hash = ? WHERE id = ?', + [newPasswordHash, userId] + ); + + res.json({ message: '密码重置成功' }); + } catch (error) { + console.error('重置用户密码错误:', error); + res.status(500).json({ error: '服务器内部错误' }); + } +}); + +// 删除用户(仅管理员) +router.delete('/:id', authenticateToken, requireAdmin, async (req, res) => { + try { + const userId = req.params.id; + + // 检查用户是否存在 + const [existingUsers] = await db.pool.execute( + 'SELECT id, role FROM users WHERE id = ?', + [userId] + ); + + if (existingUsers.length === 0) { + return res.status(404).json({ error: '用户不存在' }); + } + + // 不能删除自己 + if (parseInt(userId) === parseInt(req.user.userId)) { + return res.status(400).json({ error: '不能删除自己的账户' }); + } + + // 获取用户的服务器数量 + const [serverCount] = await db.pool.execute( + 'SELECT COUNT(*) as count FROM servers WHERE user_id = ?', + [userId] + ); + + const count = serverCount[0].count; + + // 删除用户(级联删除相关数据) + await db.pool.execute( + 'DELETE FROM users WHERE id = ?', + [userId] + ); + + res.json({ + message: `用户删除成功,同时删除了 ${count} 个相关服务器` + }); + } catch (error) { + console.error('删除用户错误:', error); + res.status(500).json({ error: '服务器内部错误' }); + } +}); + +// 获取当前用户信息(包含角色和状态) +router.get('/me', authenticateToken, async (req, res) => { + try { + const [users] = await db.pool.execute( + 'SELECT id, email, phone, role, status, created_at FROM users WHERE id = ?', + [req.user.userId] + ); + + if (users.length === 0) { + return res.status(404).json({ error: '用户不存在' }); + } + + res.json({ user: users[0] }); + } catch (error) { + console.error('获取用户信息错误:', error); + res.status(500).json({ error: '服务器内部错误' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/utils/encryption.js b/backend/utils/encryption.js index 48b99e1..78005a9 100644 --- a/backend/utils/encryption.js +++ b/backend/utils/encryption.js @@ -9,10 +9,27 @@ const AUTH_TAG_LENGTH = 16; class EncryptionService { constructor() { - this.encryptionKey = process.env.ENCRYPTION_KEY || 'default-encryption-key-32bytes-long'; - if (!this.encryptionKey || this.encryptionKey.length !== 32) { + let key = (process.env.ENCRYPTION_KEY || '').trim(); + if (/^[0-9a-fA-F]{64}$/.test(key)) { + key = key.substring(0, 32); + } + if (!key || key.length !== 32) { + const raw = (process.env.ENCRYPTION_KEY || '').trim(); + const preview = raw ? (raw.slice(0, 4) + '...' + raw.slice(-4)) : '(empty)'; + const len = raw ? raw.length : 0; + let fingerprint = ''; + try { fingerprint = crypto.createHash('sha256').update(raw).digest('hex').slice(0, 16); } catch {} + console.error('ENCRYPTION_KEY_RAW=', raw); + console.error('Invalid ENCRYPTION_KEY: length=%s, preview=%s, hex64=%s, fingerprint=%s', String(len), preview, /^[0-9a-fA-F]{64}$/.test(raw) ? 'true' : 'false', fingerprint); + if (String(process.env.ALLOW_WEAK_ENCRYPTION).toLowerCase() === 'true') { + const weak = (raw || '').padEnd(32, '0').slice(0, 32); + console.error('ALLOW_WEAK_ENCRYPTION enabled: using fallback key of length 32 to keep service alive'); + this.encryptionKey = weak; + return; + } throw new Error('ENCRYPTION_KEY must be exactly 32 bytes long'); } + this.encryptionKey = key; } /** @@ -104,4 +121,4 @@ class EncryptionService { } } -module.exports = new EncryptionService(); \ No newline at end of file +module.exports = new EncryptionService(); diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..7ef3459 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,108 @@ +#!/bin/bash + +# WebSSH 部署脚本 +set -e + +echo "🚀 开始部署 WebSSH..." + +# 检查Docker是否安装 +if ! command -v docker &> /dev/null; then + echo "❌ Docker 未安装,请先安装 Docker" + exit 1 +fi + +# 检查Docker Compose是否安装(新版Docker内置compose) +if ! docker compose version &> /dev/null; then + echo "❌ Docker Compose 未安装,请先安装 Docker Compose" + exit 1 +fi + +# 清理现有环境 +echo "🧹 清理现有环境..." +echo " 停止并删除现有容器..." +docker compose down --remove-orphans --volumes --rmi local 2>/dev/null || true + +echo " 清理未使用的镜像、容器和网络..." +docker system prune -f 2>/dev/null || true + +# 删除旧的镜像 +echo " 删除旧的WebSSH镜像..." +docker images | grep "webssh" | awk '{print $3}' | xargs -r docker rmi -f 2>/dev/null || true + +# 删除旧的容器 +echo " 删除旧的WebSSH容器..." +docker ps -a | grep "webssh" | awk '{print $1}' | xargs -r docker rm -f 2>/dev/null || true + +# 创建/校验生产环境配置文件 +echo "📝 创建/校验环境配置文件..." +if [ ! -f .env.production ]; then + NEW_KEY=$(openssl rand -hex 16) + cat > .env.production << EOF +# 数据库配置 +DB_HOST=mysql +DB_PORT=3306 +DB_USER=webssh_user +DB_PASSWORD=webssh_password +DB_NAME=webssh + +# JWT配置 +JWT_SECRET=$(openssl rand -base64 32) + +# 服务器配置 +PORT=3000 +NODE_ENV=production + +# 加密密钥(长度必须为32字符) +ENCRYPTION_KEY=${NEW_KEY} +EOF + echo "✅ 环境配置文件已创建" +else + # 读取并校验现有KEY长度 + CURRENT_KEY=$(grep -E '^ENCRYPTION_KEY=' .env.production | cut -d= -f2 | tr -d '\r\n') + if [ -z "$CURRENT_KEY" ] || [ ${#CURRENT_KEY} -ne 32 ]; then + echo "⚠️ ENCRYPTION_KEY 无效或缺失,正在重新生成..." + NEW_KEY=$(openssl rand -hex 16) + if grep -q '^ENCRYPTION_KEY=' .env.production; then + awk -v k="$NEW_KEY" 'BEGIN{FS=OFS="="} /^ENCRYPTION_KEY=/ {$2=k} {print}' .env.production > .env.production.tmp && mv .env.production.tmp .env.production + else + echo "ENCRYPTION_KEY=$NEW_KEY" >> .env.production + fi + echo "✅ ENCRYPTION_KEY 已修复为有效的32字符值" + else + echo "✅ ENCRYPTION_KEY 已有效(长度32)" + fi +fi + +# 构建镜像 +echo "🔨 构建 Docker 镜像..." +docker compose build --no-cache + +# 启动服务 +echo "🚀 启动服务..." +docker compose up -d + +# 等待服务启动 +echo "⏳ 等待服务启动..." +sleep 30 + +# 检查服务状态 +echo "🔍 检查服务状态..." +if docker compose ps | grep -q "Up"; then + echo "✅ 服务启动成功!" + echo "" + echo "📊 服务信息:" + echo " - WebSSH 应用: http://localhost:3000" + echo " - MySQL 数据库: localhost:3306" + echo "" + echo "🔧 常用命令:" + echo " - 查看日志: docker compose logs -f" + echo " - 停止服务: docker compose down" + echo " - 重启服务: docker compose restart" + echo " - 查看状态: docker compose ps" +else + echo "❌ 服务启动失败,请检查日志" + docker compose logs + exit 1 +fi + +echo "🎉 部署完成!" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9c8b196 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,41 @@ +version: '3.8' + +services: + # WebSSH应用 + webssh: + build: . + ports: + - "3000:3000" + # 环境变量从.env.production文件加载 + env_file: + - .env.production + depends_on: + mysql: + condition: service_healthy + restart: unless-stopped + healthcheck: + test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/api/auth/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + # MySQL数据库 + mysql: + image: mysql:8.0 + environment: + - MYSQL_ROOT_PASSWORD=root_password + - MYSQL_DATABASE=webssh + - MYSQL_USER=webssh_user + - MYSQL_PASSWORD=webssh_password + volumes: + - mysql_data:/var/lib/mysql + - ./install.sql:/docker-entrypoint-initdb.d/install.sql + restart: unless-stopped + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + timeout: 20s + retries: 10 + +volumes: + mysql_data: \ No newline at end of file