添加本地docker部署方式

This commit is contained in:
shuaiplus committed 2025-06-02 20:11:58 +08:00
1 parent 6b87b63bd0
commit f8f9a52e78
6 files changed
+832 -2

No files matched your search

+139
View File
@@ -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;'"]
+9 -1
View File
@@ -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。
+9 -1
View File
@@ -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.
+76
View File
@@ -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
}
}
}
}
}
+13
View File
@@ -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"
}
}
+586
View File
@@ -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);