前言
在Github一些免费的云资源上看到有一款叫Lade的云平台,支持Go、Node.js、PHP、Python、Ruby语言,实测非常好用,不会休眠掉线,免费额度也比较慷慨,free计划能部署3个应用,配置1C128MB,免费100GB出站流量,足够部署大部分常用应用。官网有提供文档,但是较为简略还没有网页面板,只能用lade CLI
,因此记录部署nodejs项目的过程方便大家参考。
测速记录
Lade使用的是Vultr的服务器,直连速度一般,搭配Cloudflare优选后国内响应速度不错,可以选择接入自定义域名来提高网站访问速度,可选地区有荷兰阿姆斯特丹、 印度班加罗尔、美国芝加哥、英国伦敦、美国洛杉矶、 美国纽约、 新加坡、澳大利亚悉尼、日本东京。
日本区域自带域名ladeapp.com
访问速度

日本区域,通过CF优选的speedtest测速

注册账号
- 打开lade,点击Get started for free开始注册,注意最好选择和你GitHub绑定一致的邮箱
- 进入控制台后,点击
GitHub gist
创建,在其中输入你的Github邮箱,点击Create secret gist
,再点击Raw
,将包含邮箱的GitHub gist
链接发送给老板 - 等待验证Github后,老板会回复你
Your Github account has been verified, thanks!
,就可以开始使用了。
- PS:不建议你注册多个账号,可能会被取消你所有账号的访问权限,如果真的想注册多个账号,尽量使用活跃的Github账号并切换IP
安装Lade CLI平台
官方教程:https://www.lade.io/docs/platform/cli
windows配置环境变量:点击查看图片
在Linux amd64上使用:
curl -L https://github.com/lade-io/lade/releases/latest/download/lade-linux-amd64.tar.gz | tar xz
sudo mv lade /usr/local/bin
常用命令:
lade apps create myapp #创建项目myapp
lade deploy --app myapp #部署myapp
lade apps remove myapp #删除myapp
lade logs -a myapp -f #查看myapp实时日志
搭建node实例
登录Lade账号
bashlade login #登陆命令 Enter your Lade credentials: ? Username or email: myuser@gmail.com #lade的注册邮箱 ? Password: ************ #输入lade注册密码 Logged in as myuser #登陆成功
使用
lade apps create node-test
创建应用程序,可选地区有8个,推荐选择日本或新加坡,一个账号可以创建3个实例用你习惯的linux使用方法,创建文件夹
lade-test
并进入文件夹,创建文件index.js和package.json,下方提供的示例代码只有哪吒
和CF隧道
的部分,有那啥功能的完整代码在nodejs-argo,直连版本在lade-ws
index.js:
jsconst express = require("express"); const app = express(); const axios = require("axios"); const os = require('os'); const fs = require("fs"); const path = require("path"); const { promisify } = require('util'); const exec = promisify(require('child_process').exec); // --- Configuration Variables --- const FILE_PATH = process.env.FILE_PATH || './tmp'; // 运行目录 const PORT = process.env.SERVER_PORT || process.env.PORT || 3000; // http服务端口 (基本没用,但保持express运行) const UUID = process.env.UUID || '9afd1229-b893-40c1-84dd-51e7ce204913'; // 使用哪吒v1,在不同的平台运行需修改UUID,否则会覆盖 const NEZHA_SERVER = process.env.NEZHA_SERVER || ''; // 哪吒v1填写形式: nz.abc.com:8008 哪吒v0填写形式:nz.abc.com const NEZHA_PORT = process.env.NEZHA_PORT || ''; // 使用哪吒v1请留空,哪吒v0需填写 const NEZHA_KEY = process.env.NEZHA_KEY || ''; // 哪吒v1的NZ_CLIENT_SECRET或哪吒v0的agent密钥 const ARGO_DOMAIN = process.env.ARGO_DOMAIN || ''; // 固定隧道域名,留空即启用临时隧道 const ARGO_AUTH = process.env.ARGO_AUTH || ''; // 固定隧道密钥json或token,留空即启用临时隧道,json获取地址:https://fscarmen.cloudflare.now.cc // ARGO_PORT is kept conceptually for tunnel config, but no local service will listen on it in this version. const ARGO_PORT = process.env.ARGO_PORT || 8001; // 隧道指向的本地端口 (现在没有服务监听) // --- File Paths --- let npmPath = path.join(FILE_PATH, 'npm'); let phpPath = path.join(FILE_PATH, 'php'); let botPath = path.join(FILE_PATH, 'bot'); let bootLogPath = path.join(FILE_PATH, 'boot.log'); let tunnelJsonPath = path.join(FILE_PATH, 'tunnel.json'); let tunnelYmlPath = path.join(FILE_PATH, 'tunnel.yml'); let nezhaConfigYamlPath = path.join(FILE_PATH, 'config.yaml'); //创建运行文件夹 if (!fs.existsSync(FILE_PATH)) { fs.mkdirSync(FILE_PATH); console.log(`${FILE_PATH} is created`); } else { console.log(`${FILE_PATH} already exists`); } //清理历史文件 (移除 web, sub.txt, list.txt, config.json) function cleanupOldFiles() { const pathsToDelete = ['npm', 'php', 'bot', 'boot.log', 'tunnel.json', 'tunnel.yml', 'config.yaml']; pathsToDelete.forEach(file => { const filePath = path.join(FILE_PATH, file); // Use rm -rf for directories and files, ignore errors if not found exec(`rm -rf ${filePath}`).catch(() => {}); }); console.log('Cleaned up potential old files.'); } // 根路由 app.get("/", function(req, res) { res.send("Nezha Agent and Cloudflare Tunnel script is running!"); }); // 判断系统架构 function getSystemArchitecture() { const arch = os.arch(); if (arch === 'arm' || arch === 'arm64' || arch === 'aarch64') { return 'arm'; } else { return 'amd'; } } // 下载对应系统架构的依赖文件 function downloadFile(fileName, fileUrl, callback) { const filePath = path.join(FILE_PATH, fileName); // Check if file exists, skip download if it does if (fs.existsSync(filePath)) { console.log(`${fileName} already exists, skipping download.`); // Still need to call callback for Promise.all // Use setTimeout to avoid potential race conditions with file access later setTimeout(() => callback(null, fileName), 0); return; } const writer = fs.createWriteStream(filePath); console.log(`Attempting to download ${fileName} from ${fileUrl}`); axios({ method: 'get', url: fileUrl, responseType: 'stream', timeout: 30000 // 30 second timeout for download }) .then(response => { response.data.pipe(writer); writer.on('finish', () => { writer.close(); console.log(`Download ${fileName} successfully`); callback(null, fileName); }); writer.on('error', err => { fs.unlink(filePath, () => { }); // Clean up incomplete file const errorMessage = `Download ${fileName} failed (writer error): ${err.message}`; console.error(errorMessage); callback(errorMessage); }); }) .catch(err => { // Ensure the file path exists before trying to unlink on catch if(fs.existsSync(filePath)) { fs.unlink(filePath, () => { }); // Clean up potentially incomplete file } const errorMessage = `Download ${fileName} failed (axios error): ${err.message}`; console.error(errorMessage); callback(errorMessage); }); } // 下载并运行依赖文件 async function downloadFilesAndRun() { const architecture = getSystemArchitecture(); const filesToDownload = getFilesForArchitecture(architecture); if (filesToDownload.length === 0) { console.log(`No necessary files identified for the current architecture/configuration.`); return; } console.log(`Identified files to download: ${filesToDownload.map(f => f.fileName).join(', ')}`); const downloadPromises = filesToDownload.map(fileInfo => { return new Promise((resolve, reject) => { downloadFile(fileInfo.fileName, fileInfo.fileUrl, (err, fileName) => { if (err) { // Don't reject immediately, log error and resolve with null // This allows other downloads to proceed console.error(`Failed to download ${fileInfo.fileName}: ${err}`); resolve(null); // reject(err); // Original behavior: stop all if one fails } else { resolve(fileName); } }); }); }); const downloadedFiles = await Promise.all(downloadPromises); const successfullyDownloaded = downloadedFiles.filter(name => name !== null); // Check if essential files were downloaded if (!successfullyDownloaded.includes('bot')) { console.error("Essential file 'bot' (cloudflared) failed to download. Cannot proceed with tunnel."); // Depending on requirements, you might want to exit here // process.exit(1); } if (NEZHA_SERVER && NEZHA_KEY) { const nezhaAgentFile = NEZHA_PORT ? 'npm' : 'php'; if (!successfullyDownloaded.includes(nezhaAgentFile)) { console.error(`Essential file '${nezhaAgentFile}' (Nezha agent) failed to download. Cannot proceed with Nezha monitoring.`); // process.exit(1); } } console.log('File download process finished.'); // 授权和运行 function authorizeFiles(filePaths) { const newPermissions = 0o775; // rwxrwxr-x filePaths.forEach(relativeFilePath => { const absoluteFilePath = path.join(FILE_PATH, relativeFilePath); if (fs.existsSync(absoluteFilePath)) { try { fs.chmodSync(absoluteFilePath, newPermissions); console.log(`Empowerment success for ${absoluteFilePath}: ${newPermissions.toString(8)}`); } catch (err) { console.error(`Empowerment failed for ${absoluteFilePath}: ${err}`); } } else { console.warn(`Cannot authorize ${absoluteFilePath}: File does not exist.`); } }); } // Determine files needing authorization based on config const filesToAuthorize = ['bot']; if (NEZHA_SERVER && NEZHA_KEY) { filesToAuthorize.push(NEZHA_PORT ? 'npm' : 'php'); } authorizeFiles(filesToAuthorize); //运行ne-zha if (NEZHA_SERVER && NEZHA_KEY && fs.existsSync(path.join(FILE_PATH, NEZHA_PORT ? 'npm' : 'php'))) { if (!NEZHA_PORT) { // Using Nezha v1 (php file) // Generate config.yaml for Nezha v1 const configYaml = ` client_secret: ${NEZHA_KEY} debug: false disable_auto_update: true disable_command_execute: false disable_force_update: true disable_nat: false disable_send_query: false gpu: false insecure_tls: false ip_report_period: 1800 report_delay: 1 server: ${NEZHA_SERVER} skip_connection_count: false skip_procs_count: false temperature: false tls: false use_gitee_to_upgrade: false use_ipv6_country_code: false uuid: ${UUID}`; try { fs.writeFileSync(nezhaConfigYamlPath, configYaml); console.log('Generated Nezha v1 config.yaml'); // Run php (Nezha v1 agent) const command = `nohup ${phpPath} -c "${nezhaConfigYamlPath}" >/dev/null 2>&1 &`; await exec(command); console.log('Nezha v1 agent (php) is starting...'); await new Promise((resolve) => setTimeout(resolve, 1000)); // Short delay } catch (error) { console.error(`Nezha v1 agent (php) startup error: ${error}`); } } else { // Using Nezha v0 (npm file) let NEZHA_TLS = ''; const tlsPorts = ['443', '8443', '2096', '2087', '2083', '2053']; if (tlsPorts.includes(NEZHA_PORT)) { NEZHA_TLS = '--tls'; } const command = `nohup ${npmPath} -s ${NEZHA_SERVER}:${NEZHA_PORT} -p ${NEZHA_KEY} ${NEZHA_TLS} >/dev/null 2>&1 &`; try { await exec(command); console.log('Nezha v0 agent (npm) is starting...'); await new Promise((resolve) => setTimeout(resolve, 1000)); // Short delay } catch (error) { console.error(`Nezha v0 agent (npm) startup error: ${error}`); } } } else if (NEZHA_SERVER && NEZHA_KEY) { console.warn('Nezha variables are set, but the agent executable is missing. Skipping Nezha agent start.'); } else { console.log('Nezha variables not fully set, skipping Nezha agent start.'); } // 运行cloudflared tunnel (bot) if (fs.existsSync(botPath)) { let args; if (ARGO_AUTH && ARGO_DOMAIN) { // User specified domain and auth (Token or JSON) if (ARGO_AUTH.match(/^[A-Z0-9a-z=]{120,250}$/)) { // Token based args = `tunnel --edge-ip-version auto --no-autoupdate --protocol http2 run --token ${ARGO_AUTH}`; console.log('Starting Cloudflare Tunnel with Token.'); } else if (ARGO_AUTH.includes('TunnelSecret') && fs.existsSync(tunnelYmlPath)) { // JSON based (tunnel.yml should have been created by argoType) args = `tunnel --edge-ip-version auto --config ${tunnelYmlPath} run`; console.log('Starting Cloudflare Tunnel with JSON credentials file.'); } else { console.warn('ARGO_AUTH specified but format not recognized as Token or JSON secret. Attempting temporary tunnel.'); // Fallback to temporary tunnel if auth format is weird but domain is set // Point temporary tunnel to a dummy service since 'web' is removed args = `tunnel --edge-ip-version auto --no-autoupdate --protocol http2 --logfile ${bootLogPath} --loglevel info hello-world`; console.log('Starting Temporary Cloudflare Tunnel (fallback).'); } } else { // Temporary tunnel (no domain/auth specified or only one specified) // Point temporary tunnel to a dummy service args = `tunnel --edge-ip-version auto --no-autoupdate --protocol http2 --logfile ${bootLogPath} --loglevel info hello-world`; console.log('Starting Temporary Cloudflare Tunnel.'); } try { await exec(`nohup ${botPath} ${args} >/dev/null 2>&1 &`); console.log('Cloudflare Tunnel (bot) is starting...'); await new Promise((resolve) => setTimeout(resolve, 4000)); // Increased delay for tunnel to potentially write log } catch (error) { console.error(`Error starting Cloudflare Tunnel (bot): ${error}`); } } else { console.warn("Cloudflare Tunnel executable 'bot' not found. Skipping tunnel start."); } // No need to wait for proxy startup anymore // await new Promise((resolve) => setTimeout(resolve, 5000)); // Original delay removed } //根据系统架构返回对应的url (Removed 'web') function getFilesForArchitecture(architecture) { let baseFiles = []; console.log(`Determining files for architecture: ${architecture}`); // Cloudflared (bot) is always needed const botUrl = architecture === 'arm' ? "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64" : "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64"; baseFiles.push({ fileName: "bot", fileUrl: botUrl }); // Nezha agent only if configured if (NEZHA_SERVER && NEZHA_KEY) { if (NEZHA_PORT) { // Nezha v0 (npm) const npmUrl = architecture === 'arm' ? "https://github.com/nezhahq/agent/releases/latest/download/nezha-agent_linux_arm64.zip" // Need to unzip later? Or find direct binary if available : "https://github.com/nezhahq/agent/releases/latest/download/nezha-agent_linux_amd64.zip"; // Need to unzip later? // Assuming the original URLs pointed to direct binaries, find similar ones or adjust download/extract logic // Using placeholder URLs from original script for now, assuming they provide direct binaries: const npmUrlDirect = architecture === 'arm' ? "https://arm64.ssss.nyc.mn/agent" // Placeholder from original : "https://amd64.ssss.nyc.mn/agent"; // Placeholder from original baseFiles.push({ fileName: "npm", // fileUrl: npmUrl // Official but zipped fileUrl: npmUrlDirect // Using original script's direct links }); } else { // Nezha v1 (php) const phpUrl = architecture === 'arm' ? "https://github.com/naiba/nezha/releases/latest/download/dashboard-linux-arm64" // This is dashboard, need agent binary? : "https://github.com/naiba/nezha/releases/latest/download/dashboard-linux-amd64"; // Assuming the original URLs pointed to direct binaries named 'php'/'v1' // Using placeholder URLs from original script for now: const phpUrlDirect = architecture === 'arm' ? "https://arm64.ssss.nyc.mn/v1" // Placeholder from original : "https://amd64.ssss.nyc.mn/v1"; // Placeholder from original baseFiles.push({ fileName: "php", // Should match the executable name expected later // fileUrl: phpUrl // Official but might be wrong binary/name fileUrl: phpUrlDirect // Using original script's direct links }); } } else { console.log("Nezha agent download skipped (not configured).") } return baseFiles; } // 获取固定隧道json/yml (Modified to point service to ARGO_PORT even if nothing listens) function argoType() { if (!ARGO_AUTH || !ARGO_DOMAIN) { console.log("ARGO_DOMAIN or ARGO_AUTH variable is empty/missing, will use temporary tunnel if needed."); return; } if (ARGO_AUTH.includes('TunnelSecret')) { console.log("ARGO_AUTH appears to be JSON secret. Generating tunnel.json and tunnel.yml"); try { // Attempt to parse the JSON part to get the Tunnel ID let tunnelID = "unknown-tunnel-id"; // Default fallback try { const parsedAuth = JSON.parse(ARGO_AUTH); tunnelID = parsedAuth.TunnelID || tunnelID; } catch (parseError) { console.warn("Could not parse ARGO_AUTH as JSON to extract TunnelID, using default in tunnel.yml"); // Extract using regex as a less reliable fallback if JSON parsing fails const match = ARGO_AUTH.match(/"TunnelID":"([^"]+)"/); if (match && match[1]) { tunnelID = match[1]; } } fs.writeFileSync(tunnelJsonPath, ARGO_AUTH); // Point ingress to the ARGO_PORT. Cloudflared won't complain if nothing listens there, // but requests to the domain will fail unless something is started on that port later. // The tunnel connection itself will still establish. const tunnelYaml = ` tunnel: ${tunnelID} credentials-file: ${tunnelJsonPath} protocol: http2 ingress: - hostname: ${ARGO_DOMAIN} service: http://localhost:${ARGO_PORT} originRequest: noTLSVerify: true # Keep this if the local service (if any) uses self-signed certs - service: http_status:404 # Default fallback for requests not matching the hostname `; fs.writeFileSync(tunnelYmlPath, tunnelYaml); console.log(`Generated tunnel.yml for domain ${ARGO_DOMAIN} pointing to http://localhost:${ARGO_PORT}`); } catch (error) { console.error(`Error writing tunnel configuration files: ${error}`); // Clean up potentially corrupt files fs.unlink(tunnelJsonPath, () => {}); fs.unlink(tunnelYmlPath, () => {}); } } else if (ARGO_AUTH.match(/^[A-Z0-9a-z=]{120,250}$/)) { console.log("ARGO_AUTH appears to be a Tunnel Token. Tunnel will be run directly with the token."); // tunnel.yml is not needed for token auth run command // Clean up any old yml/json files if switching auth type fs.unlink(tunnelJsonPath, () => {}); fs.unlink(tunnelYmlPath, () => {}); } else { console.warn("ARGO_AUTH format is unrecognized. Tunnel setup might rely on temporary tunnel logic."); // Clean up any old yml/json files if auth format is bad fs.unlink(tunnelJsonPath, () => {}); fs.unlink(tunnelYmlPath, () => {}); } } // 获取隧道domain (Removed proxy link generation) async function extractDomains() { // Call argoType first to generate tunnel.yml if needed for JSON auth argoType(); await new Promise(resolve => setTimeout(resolve, 100)); // Small delay to ensure file writing completes let argoDomain; if (ARGO_DOMAIN) { // If user provides ARGO_DOMAIN, use it directly (fixed tunnel) // Whether it's token or JSON based, ARGO_DOMAIN should be the target argoDomain = ARGO_DOMAIN; console.log(`Using specified fixed tunnel domain: ${argoDomain}`); // No need to generate proxy links anymore } else if (fs.existsSync(botPath)) { // Try to get domain from temporary tunnel logs console.log('Attempting to extract temporary tunnel domain from boot log...'); await new Promise(resolve => setTimeout(resolve, 5000)); // Wait longer for log file to appear/populate try { if (!fs.existsSync(bootLogPath)) { console.log('boot.log not found. Cannot extract temporary domain. Tunnel might still be starting.'); // Maybe retry after another delay? Or just report failure. return; // Exit the function if log doesn't exist yet } const fileContent = fs.readFileSync(bootLogPath, 'utf-8'); const lines = fileContent.split('\n'); const argoDomains = []; // Updated regex to potentially catch different log formats const domainRegex = /https:\/\/([a-zA-Z0-9-]+-[a-zA-Z0-9-]+\.trycloudflare\.com)/; lines.forEach((line) => { const domainMatch = line.match(domainRegex); if (domainMatch && domainMatch[1]) { const domain = domainMatch[1]; // Add only if unique if (!argoDomains.includes(domain)) { argoDomains.push(domain); } } }); if (argoDomains.length > 0) { argoDomain = argoDomains[argoDomains.length - 1]; // Get the latest one found console.log(`Extracted temporary tunnel domain: ${argoDomain}`); // No need to generate proxy links } else { console.log('Temporary tunnel domain not found in boot.log.'); // Maybe the tunnel failed to start, or log format changed. // Could attempt to restart 'bot' here, but it might loop if there's a persistent issue. // For simplicity, just log the failure. } } catch (error) { console.error('Error reading boot.log:', error); } } else { console.log("Cloudflared (bot) not found, cannot determine tunnel domain."); } // No need to generate list or sub info anymore } // 90s后删除相关文件 (Removed web, config, sub, list; Adjusted conditionals) function cleanFiles() { setTimeout(() => { console.log('Starting scheduled cleanup of executables and logs...'); const filesToDelete = [bootLogPath, tunnelJsonPath, tunnelYmlPath, nezhaConfigYamlPath, botPath]; // Always try to clean these // Conditionally add nezha agent files if (NEZHA_SERVER && NEZHA_KEY) { if (NEZHA_PORT) { filesToDelete.push(npmPath); } else { filesToDelete.push(phpPath); } } // Use rm -rf to handle files and potentially directories if paths change exec(`rm -rf ${filesToDelete.join(' ')}`, (error) => { if (error) { console.warn(`Cleanup command encountered an error: ${error.message}. Some files might remain.`); } else { console.log('Scheduled cleanup finished.'); } // console.clear(); // Maybe don't clear console to preserve logs console.log('-----------------------------------------------------'); console.log('Nezha Agent / Cloudflare Tunnel script setup complete.'); console.log('Services should be running in the background.'); console.log('-----------------------------------------------------'); }); }, 90000); // 90 seconds } // Main execution flow (Removed proxy related steps) async function startserver() { console.log("Starting script initialization..."); cleanupOldFiles(); // Clean up before downloading await downloadFilesAndRun(); // Download and run Nezha/Tunnel await extractDomains(); // Determine and log the tunnel domain (fixed or temp) cleanFiles(); // Schedule cleanup for later console.log("Initialization sequence complete. Background services started."); } // Start the server process startserver().catch(err => { console.error("Critical error during startup sequence:", err); // process.exit(1); // Optional: exit if startup fails critically }); // Keep the basic express server running (might be useful for health checks in some platforms) app.listen(PORT, () => console.log(`Basic HTTP server listening on port: ${PORT} (used for keeping process alive)`));
package.json:
{
"name": "node-test",
"version": "1.0.0",
"description": "node",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"ws": "latest",
"axios": "latest"
},
"engines": {
"node": ">=14"
}
}
- 配置说明
UUID 可去uuid-generator随机生成一个
使用哪吒v1要开启tls,需在代码中搜索
tls: false
,改为tls: true
建议使用固定隧道,打开cf-tunnels,点击
创建隧道
,选择Cloudflared
,填写隧道名称
,复制cloudflared.exe service install eyJhxxxxxxx
这条命令,在process.env.ARGO_AUTH
中填入你的token:eyJhxxxxxxx
,同时,要在公共主机名中添加一个域名,并监听https://localhost:8001
部署 Lade 应用程序:
lade deploy --app lade-test
查看日志:lade logs -a lade-test -f
访问https://node-test-xxxxx.ladeapp.com (xxx为你的用户名),看到
Hello world!
就说明你部署成功了,如果你用的是eooce的代码,在网站后面加上你设置的SUB_PATH
,就是订阅地址安全起见,推荐把要实际部署的代码混淆一下,命令如下:
npm install -g javascript-obfuscator #安装混淆命令javascript-obfuscator
javascript-obfuscator index.js --output index.ob.js --compact true --control-flow-flattening true --control-flow-flattening-threshold 0.75 --dead-code-injection true --dead-code-injection-threshold 0.2 --identifier-names-generator mangled --rename-globals false --string-array true --string-array-encoding base64 --string-array-threshold 0.75 --split-strings true --split-strings-chunk-length 5 --transform-object-keys true --unicode-escape-sequence true --self-defending true
将混淆生成的index.ob.js
中的内容覆盖index.js
即可
参考文档
- 本文链接:https://blog.kafuchino.top/posts/2025-04-23
- 版权声明:本博客所有文章除特别声明外,均默认采用 CC BY-NC-SA 许可协议。