Merge pull request #275 from qinfendebingshuo/copilot/fix-vpn-deployment-issues-again
fix: 铸渊专线订阅服务 "Connection closed before full header" 修复
This commit is contained in:
commit
cc5ed414dc
|
|
@ -100,6 +100,9 @@ server {
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
proxy_read_timeout 30s;
|
||||||
|
proxy_send_timeout 30s;
|
||||||
add_header X-Content-Type-Options nosniff always;
|
add_header X-Content-Type-Options nosniff always;
|
||||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,14 @@
|
||||||
|
|
||||||
location /api/proxy-sub/ {
|
location /api/proxy-sub/ {
|
||||||
proxy_pass http://127.0.0.1:3802/;
|
proxy_pass http://127.0.0.1:3802/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
proxy_read_timeout 30s;
|
||||||
|
proxy_send_timeout 30s;
|
||||||
|
|
||||||
# 订阅服务安全头
|
# 订阅服务安全头
|
||||||
add_header X-Content-Type-Options nosniff always;
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
|
|
||||||
|
|
@ -224,24 +224,40 @@ deploy_services() {
|
||||||
|
|
||||||
# ── 配置Nginx ─────────────────────────────────
|
# ── 配置Nginx ─────────────────────────────────
|
||||||
configure_nginx() {
|
configure_nginx() {
|
||||||
# 检查主Nginx配置是否已有proxy-sub
|
# 查找正确的Nginx配置文件 (zhuyuan.conf 优先于 default)
|
||||||
NGINX_CONF="/etc/nginx/sites-enabled/default"
|
NGINX_CONF=""
|
||||||
|
for candidate in /etc/nginx/sites-enabled/zhuyuan.conf /etc/nginx/sites-enabled/default; do
|
||||||
|
if [ -f "$candidate" ]; then
|
||||||
|
NGINX_CONF="$candidate"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
if [ -f "$NGINX_CONF" ] && ! grep -q "proxy-sub" "$NGINX_CONF" 2>/dev/null; then
|
if [ -z "$NGINX_CONF" ]; then
|
||||||
|
echo " ⚠️ 未找到Nginx站点配置文件"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " 使用Nginx配置: $NGINX_CONF"
|
||||||
|
|
||||||
|
if ! grep -q "proxy-sub" "$NGINX_CONF" 2>/dev/null; then
|
||||||
echo " 添加Nginx代理订阅反向代理配置..."
|
echo " 添加Nginx代理订阅反向代理配置..."
|
||||||
# 在第一个 location = /health 之前插入 proxy-sub location
|
# 在第一个 location = /health 之前插入 proxy-sub location
|
||||||
sed -i '/# ─── 健康探针 ───/{
|
sed -i '/# ─── 健康探针 ───/{
|
||||||
# 只在第一次匹配时插入
|
# 只在第一次匹配时插入
|
||||||
i\ # ─── 铸渊专线订阅服务 (端口 3802) ───\n location /api/proxy-sub/ {\n proxy_pass http://127.0.0.1:3802/;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n add_header X-Content-Type-Options nosniff always;\n add_header Cache-Control "no-store, no-cache, must-revalidate" always;\n }\n
|
i\ # ─── 铸渊专线订阅服务 (端口 3802) ───\n location /api/proxy-sub/ {\n proxy_pass http://127.0.0.1:3802/;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_connect_timeout 10s;\n proxy_read_timeout 30s;\n proxy_send_timeout 30s;\n add_header X-Content-Type-Options nosniff always;\n add_header Cache-Control "no-store, no-cache, must-revalidate" always;\n }\n
|
||||||
}' "$NGINX_CONF" || true
|
}' "$NGINX_CONF" || true
|
||||||
echo " ✅ Nginx proxy-sub配置已注入"
|
echo " ✅ Nginx proxy-sub配置已注入"
|
||||||
else
|
else
|
||||||
echo " Nginx代理配置已存在 (或主配置不存在)"
|
echo " Nginx proxy-sub配置已存在"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if nginx -t 2>/dev/null; then
|
if nginx -t 2>/dev/null; then
|
||||||
nginx -s reload || true
|
nginx -s reload || true
|
||||||
echo " ✅ Nginx配置验证通过并已重载"
|
echo " ✅ Nginx配置验证通过并已重载"
|
||||||
|
else
|
||||||
|
echo " ⚠️ Nginx配置验证失败:"
|
||||||
|
nginx -t 2>&1 || true
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -291,11 +307,18 @@ health_check() {
|
||||||
echo " ❌ 端口443: 未监听"
|
echo " ❌ 端口443: 未监听"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 订阅服务
|
# 订阅服务 (直接访问)
|
||||||
if curl -sf http://127.0.0.1:3802/health >/dev/null 2>&1; then
|
if curl -sf http://127.0.0.1:3802/health >/dev/null 2>&1; then
|
||||||
echo " ✅ 订阅服务: 正常"
|
echo " ✅ 订阅服务: 正常 (直连3802)"
|
||||||
else
|
else
|
||||||
echo " ⏳ 订阅服务: 启动中..."
|
echo " ❌ 订阅服务: 端口3802无响应"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 订阅服务 (通过Nginx反代)
|
||||||
|
if curl -sf http://127.0.0.1/api/proxy-sub/health >/dev/null 2>&1; then
|
||||||
|
echo " ✅ Nginx反代: 正常 (/api/proxy-sub/ → 3802)"
|
||||||
|
else
|
||||||
|
echo " ⚠️ Nginx反代: /api/proxy-sub/ 未响应 (Nginx配置可能缺失)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# PM2
|
# PM2
|
||||||
|
|
@ -308,6 +331,7 @@ update() {
|
||||||
deploy_services
|
deploy_services
|
||||||
save_server_host
|
save_server_host
|
||||||
configure_xray
|
configure_xray
|
||||||
|
configure_nginx
|
||||||
|
|
||||||
ensure_xray_root_user
|
ensure_xray_root_user
|
||||||
ensure_log_permissions
|
ensure_log_permissions
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ module.exports = {
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
script: '/opt/zhuyuan/proxy/service/subscription-server.js',
|
script: '/opt/zhuyuan/proxy/service/subscription-server.js',
|
||||||
instances: 1,
|
instances: 1,
|
||||||
|
exec_mode: 'fork',
|
||||||
env: {
|
env: {
|
||||||
NODE_ENV: 'production',
|
NODE_ENV: 'production',
|
||||||
ZY_PROXY_SUB_PORT: 3802,
|
ZY_PROXY_SUB_PORT: 3802,
|
||||||
|
|
@ -26,6 +27,7 @@ module.exports = {
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
script: '/opt/zhuyuan/proxy/service/traffic-monitor.js',
|
script: '/opt/zhuyuan/proxy/service/traffic-monitor.js',
|
||||||
instances: 1,
|
instances: 1,
|
||||||
|
exec_mode: 'fork',
|
||||||
env: {
|
env: {
|
||||||
NODE_ENV: 'production',
|
NODE_ENV: 'production',
|
||||||
ZY_PROXY_DATA_DIR: '/opt/zhuyuan/proxy/data',
|
ZY_PROXY_DATA_DIR: '/opt/zhuyuan/proxy/data',
|
||||||
|
|
@ -41,6 +43,7 @@ module.exports = {
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
script: '/opt/zhuyuan/proxy/service/proxy-guardian.js',
|
script: '/opt/zhuyuan/proxy/service/proxy-guardian.js',
|
||||||
instances: 1,
|
instances: 1,
|
||||||
|
exec_mode: 'fork',
|
||||||
env: {
|
env: {
|
||||||
NODE_ENV: 'production',
|
NODE_ENV: 'production',
|
||||||
ZY_PROXY_DATA_DIR: '/opt/zhuyuan/proxy/data',
|
ZY_PROXY_DATA_DIR: '/opt/zhuyuan/proxy/data',
|
||||||
|
|
|
||||||
|
|
@ -482,82 +482,110 @@ function detectClientType(userAgent) {
|
||||||
|
|
||||||
// ── HTTP服务器 ───────────────────────────────
|
// ── HTTP服务器 ───────────────────────────────
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
const parsedUrl = url.parse(req.url, true);
|
try {
|
||||||
const pathname = parsedUrl.pathname;
|
const parsedUrl = url.parse(req.url, true);
|
||||||
|
const pathname = parsedUrl.pathname;
|
||||||
|
|
||||||
// 健康检查
|
// 健康检查
|
||||||
if (pathname === '/health') {
|
if (pathname === '/health') {
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ status: 'ok', service: 'zy-proxy-subscription' }));
|
res.end(JSON.stringify({ status: 'ok', service: 'zy-proxy-subscription' }));
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 订阅端点: /sub/{token}
|
|
||||||
const subMatch = pathname.match(/^\/sub\/([a-f0-9]+)$/);
|
|
||||||
if (subMatch) {
|
|
||||||
const token = subMatch[1];
|
|
||||||
const keys = loadKeys();
|
|
||||||
|
|
||||||
// 验证Token
|
|
||||||
if (token !== keys.ZY_PROXY_SUB_TOKEN) {
|
|
||||||
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
||||||
res.end('Forbidden');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverHost = getServerHost();
|
// 订阅端点: /sub/{token}
|
||||||
const quota = getQuotaInfo();
|
const subMatch = pathname.match(/^\/sub\/([a-f0-9]+)$/);
|
||||||
const clientType = detectClientType(req.headers['user-agent']);
|
if (subMatch) {
|
||||||
const userInfoHeader = generateUserInfoHeader(quota);
|
const token = subMatch[1];
|
||||||
|
const keys = loadKeys();
|
||||||
|
|
||||||
if (clientType === 'clash') {
|
// 验证Token
|
||||||
// Clash YAML格式
|
if (token !== keys.ZY_PROXY_SUB_TOKEN) {
|
||||||
const yaml = generateClashYaml(keys, serverHost);
|
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
||||||
res.writeHead(200, {
|
res.end('Forbidden');
|
||||||
'Content-Type': 'text/yaml; charset=utf-8',
|
return;
|
||||||
'Content-Disposition': 'attachment; filename="zy-proxy.yaml"',
|
}
|
||||||
'subscription-userinfo': userInfoHeader,
|
|
||||||
'profile-update-interval': '6',
|
const serverHost = getServerHost();
|
||||||
'profile-title': 'base64:6ZO45ria5LiT57q/', // "铸渊专线" in base64
|
const quota = getQuotaInfo();
|
||||||
});
|
const clientType = detectClientType(req.headers['user-agent']);
|
||||||
res.end(yaml);
|
const userInfoHeader = generateUserInfoHeader(quota);
|
||||||
} else {
|
|
||||||
// Base64 URI格式 (Shadowrocket)
|
if (clientType === 'clash') {
|
||||||
const vlessUri = generateVlessUri(keys, serverHost);
|
// Clash YAML格式
|
||||||
const encoded = Buffer.from(vlessUri).toString('base64');
|
const yaml = generateClashYaml(keys, serverHost);
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': 'text/plain; charset=utf-8',
|
'Content-Type': 'text/yaml; charset=utf-8',
|
||||||
'subscription-userinfo': userInfoHeader,
|
'Content-Disposition': 'attachment; filename="zy-proxy.yaml"',
|
||||||
'profile-update-interval': '6',
|
'subscription-userinfo': userInfoHeader,
|
||||||
});
|
'profile-update-interval': '6',
|
||||||
res.end(encoded);
|
'profile-title': 'base64:6ZO45ria5LiT57q/', // "铸渊专线" in base64
|
||||||
|
});
|
||||||
|
res.end(yaml);
|
||||||
|
} else {
|
||||||
|
// Base64 URI格式 (Shadowrocket)
|
||||||
|
const vlessUri = generateVlessUri(keys, serverHost);
|
||||||
|
const encoded = Buffer.from(vlessUri).toString('base64');
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/plain; charset=utf-8',
|
||||||
|
'subscription-userinfo': userInfoHeader,
|
||||||
|
'profile-update-interval': '6',
|
||||||
|
});
|
||||||
|
res.end(encoded);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配额查询端点: /quota (公开安全 - 仅数字)
|
||||||
|
if (pathname === '/quota') {
|
||||||
|
const quota = getQuotaInfo();
|
||||||
|
const totalGB = (quota.total_bytes / (1024 ** 3)).toFixed(1);
|
||||||
|
const usedGB = ((quota.upload_bytes + quota.download_bytes) / (1024 ** 3)).toFixed(1);
|
||||||
|
const remainGB = (totalGB - usedGB).toFixed(1);
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
total_gb: parseFloat(totalGB),
|
||||||
|
used_gb: parseFloat(usedGB),
|
||||||
|
remaining_gb: parseFloat(remainGB),
|
||||||
|
percentage_used: parseFloat(((usedGB / totalGB) * 100).toFixed(1)),
|
||||||
|
period: quota.period,
|
||||||
|
reset_day: quota.reset_day,
|
||||||
|
updated_at: quota.updated_at || new Date().toISOString()
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 404
|
||||||
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('Not Found');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ 请求处理错误 [%s %s]:', req.method, req.url, err.message || err);
|
||||||
|
try {
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||||
|
}
|
||||||
|
res.end('Internal Server Error');
|
||||||
|
} catch (writeErr) {
|
||||||
|
console.error(' ⚠️ 响应写入失败:', writeErr.message);
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 配额查询端点: /quota (公开安全 - 仅数字)
|
// 处理连接级别错误 (防止未捕获的socket错误导致进程崩溃)
|
||||||
if (pathname === '/quota') {
|
server.on('error', (err) => {
|
||||||
const quota = getQuotaInfo();
|
console.error('❌ 服务器错误:', err.message);
|
||||||
const totalGB = (quota.total_bytes / (1024 ** 3)).toFixed(1);
|
if (err.code === 'EADDRINUSE') {
|
||||||
const usedGB = ((quota.upload_bytes + quota.download_bytes) / (1024 ** 3)).toFixed(1);
|
console.error(' 端口 %d 已被占用,进程退出等待PM2重启...', PORT);
|
||||||
const remainGB = (totalGB - usedGB).toFixed(1);
|
process.exit(1);
|
||||||
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({
|
|
||||||
total_gb: parseFloat(totalGB),
|
|
||||||
used_gb: parseFloat(usedGB),
|
|
||||||
remaining_gb: parseFloat(remainGB),
|
|
||||||
percentage_used: parseFloat(((usedGB / totalGB) * 100).toFixed(1)),
|
|
||||||
period: quota.period,
|
|
||||||
reset_day: quota.reset_day,
|
|
||||||
updated_at: quota.updated_at || new Date().toISOString()
|
|
||||||
}));
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 404
|
server.on('clientError', (err, socket) => {
|
||||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
console.error('⚠️ 客户端连接错误:', err.code || err.message);
|
||||||
res.end('Not Found');
|
if (socket.writable) {
|
||||||
|
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
server.listen(PORT, '127.0.0.1', () => {
|
server.listen(PORT, '127.0.0.1', () => {
|
||||||
|
|
@ -579,3 +607,13 @@ function gracefulShutdown(signal) {
|
||||||
}
|
}
|
||||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||||
|
|
||||||
|
// 进程级错误保护 (记录后优雅退出,由PM2负责重启)
|
||||||
|
process.on('uncaughtException', (err) => {
|
||||||
|
console.error('❌ 未捕获的异常:', err.message);
|
||||||
|
console.error(err.stack);
|
||||||
|
gracefulShutdown('uncaughtException');
|
||||||
|
});
|
||||||
|
process.on('unhandledRejection', (reason) => {
|
||||||
|
console.error('❌ 未处理的Promise拒绝:', reason);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue