diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f74180b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,139 @@ +# Use a Node.js 18 Alpine base image for the backend builder +# 使用 Node.js 18 Alpine 基础镜像作为后端构建器 +FROM node:18-alpine AS backend-builder + +WORKDIR /app + +# Copy package.json first to leverage Docker's caching +# 首先复制 package.json 以利用 Docker 的缓存 +COPY server/package.json ./server/ +# Install production dependencies +# 安装生产依赖 +RUN cd server && npm install --production --no-package-lock --no-audit + +# Copy only the server.js file +# 仅复制 server.js 文件 +COPY server/server.js ./server/ + +# Frontend build stage +# 前端构建阶段 +FROM node:18-alpine AS frontend-builder + +WORKDIR /app + +# Copy frontend dependencies and configuration files +# 复制前端依赖和配置文件 +COPY package.json package-lock.json* ./ +# Install dependencies +# 安装依赖 +RUN npm ci --no-audit || npm install --no-audit + +# Copy source code and configuration files +# 复制源代码和配置文件 +COPY vite.config.js ./ +COPY client/ ./client/ + +# Build the frontend +# 构建前端 +RUN npm run build:docker + +# Second stage: Minimal image +# 第二阶段:极小镜像 +FROM alpine:3.16 + +# Install minimal Node.js and Nginx +# 安装最小化版本的 Node.js 和 Nginx +RUN apk add --no-cache nodejs nginx && \ + mkdir -p /app/server /app/client /run/nginx && \ + # Clean up apk cache + # 清理 apk 缓存 + rm -rf /var/cache/apk/* + +# Copy server files and static files +# 复制服务器文件和静态文件 +COPY --from=backend-builder /app/server/node_modules /app/server/node_modules +COPY --from=backend-builder /app/server/*.js /app/server/ +# Copy built frontend files from the frontend build stage +# 从前端构建阶段复制构建好的文件,而不是复制 dist 目录 +COPY --from=frontend-builder /app/dist/ /app/client/ + +# Optimized Nginx configuration +# 优化的 Nginx 配置 +RUN cat > /etc/nginx/nginx.conf <<'EOF' +worker_processes 1; +worker_rlimit_nofile 512; +events { + worker_connections 128; + multi_accept off; +} +http { + include mime.types; + default_type application/octet-stream; + + # Optimization settings + # 优化设置 + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 15; + types_hash_max_size 1024; + client_max_body_size 1M; + client_body_buffer_size 128k; + + # Disable access logs to reduce I/O + # 禁用访问日志以减少 I/O + access_log off; + error_log /dev/null; + # Disable unnecessary features + # 禁用不需要的功能 + server_tokens off; + + # Map to handle WebSocket upgrade detection + # 映射处理 WebSocket 升级检测 + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + server { + listen 80; + server_name localhost; + + # Main location block - handles both HTTP and WebSocket + # 主位置块 - 处理 HTTP 和 WebSocket + location / { + # Check if this is a WebSocket upgrade request + # 检查是否为 WebSocket 升级请求 + if ($http_upgrade = "websocket") { + proxy_pass http://127.0.0.1:8088; + break; + } + + # For WebSocket requests, proxy to Node.js backend + # 对于 WebSocket 请求,代理到 Node.js 后端 + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # For regular HTTP requests, serve static files + # 对于常规 HTTP 请求,提供静态文件 + root /app/client; + index index.html; + try_files $uri $uri/ /index.html; + } + } +} +EOF + +EXPOSE 80 + +# Set low memory environment variables and remove unsupported options +# 设置低内存环境变量,去除不支持的选项 + +# Run in the foreground and combine commands to reduce the number of processes +# 使用前台运行并合并命令减少进程数 +CMD ["sh", "-c", "node --expose-gc --unhandled-rejections=strict /app/server/server.js & nginx -g 'daemon off;'"] \ No newline at end of file diff --git a/README.md b/README.md index b79fa6c..305d19b 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,15 @@ 2. 打开 Cloudflare Workers 控制台,选择“从 GitHub 导入”,并选择你 fork 的仓库进行部署。 > 本项目已内置自动同步 workflow,fork 后无需任何操作,主仓库的更新会自动同步到你的 fork 仓库,Cloudflare 也会自动重新部署,无需手动维护。 -### 方法三:本地开发部署 +### 方法三:Docker 一键部署(推荐自托管) + +```bash +docker run -d --name nodecrypt -p 80:80 shua1/nodecrypt +``` + +访问 http://localhost:80 + +### 方法四:本地开发部署 克隆项目并安装依赖后,使用 `npm run dev` 启动开发服务器。 使用 `npm run deploy` 部署到 Cloudflare Workers。 diff --git a/README_EN.md b/README_EN.md index 2c72e47..9307dcf 100644 --- a/README_EN.md +++ b/README_EN.md @@ -15,7 +15,15 @@ Click the button below for one-click deployment to Cloudflare Workers: 2. Open the Cloudflare Workers console, select "Import from GitHub," and choose your forked repository for deployment. > This project has built-in auto-sync workflow. After forking, no action is required. Updates from the main repository will automatically sync to your fork, and Cloudflare will automatically redeploy without manual maintenance. -### Method 3: Local Development Deployment +### Method 3: Docker One-Click Deployment (Recommended for Self-hosting) + +```bash +docker run -d --name nodecrypt -p 80:80 shua1/nodecrypt +``` + +Access http://localhost:80 + +### Method 4: Local Development Deployment After cloning the project and installing dependencies, use `npm run dev` to start the development server. Use `npm run deploy` to deploy to Cloudflare Workers. diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..b0530a5 --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,76 @@ +{ + "name": "nodecrypt-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nodecrypt-server", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2", + "ws": "^7.3.1" + } + }, + "node_modules/bufferutil": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.9.tgz", + "integrity": "sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, + "node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..32f6380 --- /dev/null +++ b/server/package.json @@ -0,0 +1,13 @@ +{ + "name": "nodecrypt-server", + "version": "1.0.0", + "description": "NodeCrypt Server", + "main": "server.js", + "author": "Zoltan Hajdu", + "license": "ISC", + "dependencies": { + "ws": "^7.3.1", + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + } +} diff --git a/server/server.js b/server/server.js new file mode 100644 index 0000000..90a981e --- /dev/null +++ b/server/server.js @@ -0,0 +1,586 @@ +#!/usr/bin/env nodejs + +'use strict'; + +const crypto = require('crypto'); +const ws = require('ws'); + +// Generate a new RSA key pair +// 生成一个新的 RSA 密钥对 +const generateRSAKeyPair = () => { + try { + console.log('Generating new RSA keypair...'); + const { + publicKey, + privateKey + } = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { + type: 'spki', + format: 'der' + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'der' + } + }); + + return { + rsaPublic: Buffer.from(publicKey).toString('base64'), + rsaPrivate: crypto.createPrivateKey({ + key: privateKey, + format: 'der', + type: 'pkcs8' + }) + }; + } catch (error) { + console.error('Error generating RSA key pair:', error); + process.exit(1); + } +}; + +const keyPair = generateRSAKeyPair(); +console.log('RSA key pair generated successfully'); + +const config = { + rsaPrivate: keyPair.rsaPrivate, + rsaPublic: keyPair.rsaPublic, + wsHost: '127.0.0.1', + wsPort: 8088, + seenTimeout: 60000, + debug: false +}; + + +const wss = new ws.Server({ + host: config.wsHost, + port: config.wsPort, + perMessageDeflate: false +}); + +console.log('server started', config.wsHost, config.wsPort); + + +var clients = {}; +var channels = {}; + +// WebSocket server connection event handler +// WebSocket 服务器连接事件处理程序 +wss.on('connection', (connection) => { + + if ( + !connection + ) { + return; + } + + + const seenThreshold = (getTime() - config.seenTimeout); + + for ( + const clientId in clients + ) { + if ( + clients[clientId].seen < seenThreshold + ) { + try { + logEvent('connection-seen', clientId, 'debug'); + clients[clientId].connection.terminate(); + } catch (error) { + logEvent('connection-seen', error, 'error'); + } + } + } + + + const clientId = generateClientId(); + + if ( + !clientId || + clients[clientId] + ) { + closeConnection(connection); + return; + } + + logEvent('connection', clientId, 'debug'); + + + clients[clientId] = { + connection: connection, + seen: getTime(), + key: null, + channel: null + }; + + try { + logEvent('sending-public-key', clientId, 'debug'); + sendMessage(connection, JSON.stringify({ + type: 'server-key', + key: config.rsaPublic + })); + } catch (error) { + logEvent('sending-public-key', error, 'error'); + } + + + + connection.on('message', (message) => { + + if ( + !isString(message) || + !clients[clientId] + ) { + return; + } + + clients[clientId].seen = getTime(); + + if ( + message === 'ping' + ) { + sendMessage(connection, 'pong'); + return; + } + + logEvent('message', [clientId, message], 'debug'); + + if ( + !clients[clientId].shared && + message.length < 2048 + ) { + + + try { + + const keys = crypto.createECDH('secp384r1'); + + keys.generateKeys(); + + const publicKey = keys.getPublicKey(); + const signature = crypto.sign('sha256', publicKey, { + key: config.rsaPrivate, + padding: crypto.constants.RSA_PKCS1_PADDING, + dsaEncoding: 'ieee-p1363' + }); + + clients[clientId].shared = keys.computeSecret(message, 'hex', null).slice(8, 40); + + sendMessage(connection, publicKey.toString('hex') + '|' + signature.toString('base64')); + + } catch (error) { + logEvent('message-key', [clientId, error], 'error'); + closeConnection(connection); + } + + return; + + } + + if ( + clients[clientId].shared && + message.length <= (8 * 1024 * 1024) + ) { + + processEncryptedMessage(clientId, message); + + } + + }); + + + + connection.on('close', (event) => { + + logEvent('close', [clientId, event], 'debug'); + + + const channel = clients[clientId].channel; + + if ( + channel && + channels[channel] + ) { + + channels[channel].splice(channels[channel].indexOf(clientId), 1); + + if ( + channels[channel].length === 0 + ) { + + + delete(channels[channel]); + + } else { + + + try { + + const members = channels[channel]; + + for ( + const member of members + ) { + + const client = clients[member]; + + if ( + isClientInChannel(client, channel) + ) { + sendMessage(client.connection, encryptMessage({ + a: 'l', + p: members.filter((value) => { + return ( + value !== member ? + true : + false + ); + }) + }, client.shared)); + } + + } + + } catch (error) { + logEvent('close-list', [clientId, error], 'error'); + } + + } + + } + + + if ( + clients[clientId] + ) { + delete(clients[clientId]); + } + + }); + +}); + +// Process encrypted messages +// 处理加密消息 +const processEncryptedMessage = (clientId, message) => { + let decrypted = null; + + try { + decrypted = decryptMessage(message, clients[clientId].shared); + + logEvent('message-decrypted', [clientId, decrypted], 'debug'); + + if ( + !isObject(decrypted) || + !isString(decrypted.a) + ) { + return; + } + + const action = decrypted.a; + + if (action === 'j') { + handleJoinChannel(clientId, decrypted); + } else if (action === 'c') { + handleClientMessage(clientId, decrypted); + } else if (action === 'w') { + handleChannelMessage(clientId, decrypted); + } + + } catch (error) { + logEvent('process-encrypted-message', [clientId, error], 'error'); + } finally { + decrypted = null; + } +}; + +// Handle channel join requests +// 处理加入频道请求 +const handleJoinChannel = (clientId, decrypted) => { + if ( + !isString(decrypted.p) || + clients[clientId].channel + ) { + return; + } + + try { + const channel = decrypted.p; + + clients[clientId].channel = channel; + + if (!channels[channel]) { + channels[channel] = [clientId]; + } else { + channels[channel].push(clientId); + } + + broadcastMemberList(channel); + + } catch (error) { + logEvent('message-join', [clientId, error], 'error'); + } +}; + +// Handle client messages +// 处理客户端消息 +const handleClientMessage = (clientId, decrypted) => { + if ( + !isString(decrypted.p) || + !isString(decrypted.c) || + !clients[clientId].channel + ) { + return; + } + + try { + const channel = clients[clientId].channel; + const targetClient = clients[decrypted.c]; + + if (isClientInChannel(targetClient, channel)) { + const messageObj = { + a: 'c', + p: decrypted.p, + c: clientId + }; + + const encrypted = encryptMessage(messageObj, targetClient.shared); + sendMessage(targetClient.connection, encrypted); + + messageObj.p = null; + } + + } catch (error) { + logEvent('message-client', [clientId, error], 'error'); + } +}; + +// Handle channel messages +// 处理频道消息 +const handleChannelMessage = (clientId, decrypted) => { + if ( + !isObject(decrypted.p) || + !clients[clientId].channel + ) { + return; + } + + try { + const channel = clients[clientId].channel; + + for (const member in decrypted.p) { + const targetClient = clients[member]; + + if ( + isString(decrypted.p[member]) && + isClientInChannel(targetClient, channel) + ) { + const messageObj = { + a: 'c', + p: decrypted.p[member], + c: clientId + }; + + const encrypted = encryptMessage(messageObj, targetClient.shared); + sendMessage(targetClient.connection, encrypted); + + messageObj.p = null; + } + } + + } catch (error) { + logEvent('message-channel', [clientId, error], 'error'); + } +}; + +// Broadcast member list to channel +// 向频道广播成员列表 +const broadcastMemberList = (channel) => { + try { + const members = channels[channel]; + + for (const member of members) { + const client = clients[member]; + + if (isClientInChannel(client, channel)) { + const filteredMembers = members.filter(value => value !== member); + + const listObj = { + a: 'l', + p: filteredMembers + }; + + const encrypted = encryptMessage(listObj, client.shared); + sendMessage(client.connection, encrypted); + + listObj.p = null; + } + } + } catch (error) { + logEvent('broadcast-member-list', error, 'error'); + } +}; + + + +// Log events with timestamps and levels +// 记录带时间戳和级别的事件 +const logEvent = (source, message, level) => { + if ( + level !== 'debug' || + config.debug + ) { + + const date = new Date(), + dateString = date.getFullYear() + '-' + + ('0' + (date.getMonth() + 1)).slice(-2) + '-' + + ('0' + date.getDate()).slice(-2) + ' ' + + ('0' + date.getHours()).slice(-2) + ':' + + ('0' + date.getMinutes()).slice(-2) + ':' + + ('0' + date.getSeconds()).slice(-2); + + console.log('[' + dateString + ']', (level ? level.toUpperCase() : 'INFO'), source + (message ? ':' : ''), (message ? message : '')); + + } +}; + + +const generateClientId = () => { + try { + return (crypto.randomBytes(8).toString('hex')); + } catch (error) { + logEvent('generateClientId', error, 'error'); + return (null); + } +}; + + +const closeConnection = (connection) => { + try { + connection.close(); + } catch (error) { + logEvent('closeConnection', error, 'error'); + } +}; + + +const isClientInChannel = (client, channel) => { + return ( + client && + client.connection && + client.shared && + client.channel && + client.channel === channel ? + true : + false + ); +}; + + +const sendMessage = (connection, message) => { + try { + if ( + connection.readyState && + connection.readyState === ws.OPEN + ) { + connection.send(message); + } + } catch (error) { + logEvent('sendMessage', error, 'error'); + } +}; + + +const encryptMessage = (message, key) => { + + let encrypted = ''; + + try { + + const messageBuffer = Buffer.from(JSON.stringify(message), 'utf8'); + + const paddedBuffer = (messageBuffer.length % 16) !== 0 ? + Buffer.concat([messageBuffer, Buffer.alloc(16 - (messageBuffer.length % 16))]) : + messageBuffer; + + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); + cipher.setAutoPadding(false); + + encrypted = iv.toString('base64') + '|' + cipher.update(paddedBuffer, '', 'base64') + cipher.final('base64'); + + } catch (error) { + logEvent('encryptMessage', error, 'error'); + } + + return (encrypted); + +}; + + +const decryptMessage = (message, key) => { + + let decrypted = {}; + + try { + + const parts = message.split('|'); + const decipher = crypto.createDecipheriv( + 'aes-256-cbc', + key, + Buffer.from(parts[0], 'base64') + ); + + decipher.setAutoPadding(false); + + const decryptedText = decipher.update(parts[1], 'base64', 'utf8') + decipher.final('utf8'); + decrypted = JSON.parse(decryptedText.replace(/\0+$/, '')); + + } catch (error) { + logEvent('decryptMessage', error, 'error'); + } + + return (decrypted); + +}; + + +const getTime = () => { + return (new Date().getTime()); +}; + + +const isString = (value) => { + return ( + value && + Object.prototype.toString.call(value) === '[object String]' ? + true : + false + ); +}; + + +const isArray = (value) => { + return ( + value && + Object.prototype.toString.call(value) === '[object Array]' ? + true : + false + ); +}; + + +const isObject = (value) => { + return ( + value && + Object.prototype.toString.call(value) === '[object Object]' ? + true : + false + ); +}; + +setInterval(() => { + if (global.gc) { + global.gc(); + } +}, 30000); \ No newline at end of file