Serv00上部署自己的Nodejs应用
“Serv00.com 是一个提供免费虚拟主机服务的平台。简单来说,它就像是一个免费的“云端小房子”,你可以把你的个人网站或者一些小型的应用程序放在里面,让别人通过网络访问。” —— Gemini
简单介绍Serv00
Serv00.com 的主要特点:
完全免费: 不需要支付任何费用,就能享受到一定程度的服务器资源。 配置适中: 免费套餐提供了 3GB 存储空间、512MB 内存、不限流量,对于个人博客、小型网站来说足够使用。 功能丰富: 支持 PHP、MySQL 等常见的网站开发语言和数据库,提供免费二级域名、可自定义开放端口、支持 SSH 访问等。 易于使用: 提供用户友好的控制面板,方便用户管理自己的网站。 免费域名邮箱: 可以申请免费的域名邮箱,方便收发邮件。 Serv00.com 的适用人群:
个人博客爱好者: 搭建自己的个人博客,分享生活、技术等。 小型网站开发者: 测试和部署小型网站或应用程序。 学习编程的人: 用于练习和实验。 需要注意的事项:
资源有限: 毕竟是免费的,资源有限,不适合运行大型网站或高负载的应用程序。 需要保持活跃: Serv00 会要求用户定期登录后台或 SSH,以保持账号的活跃状态。 可能存在不稳定性: 免费服务可能存在不稳定性,影响网站的访问速度和稳定性。
请注意,Serv00最近修改了TOS,使用🚫 Proxy software🚫 VPN🚫 Tunneling software🚫 Alist🚫 Nezha🚫 Hikka=🚫 Randomly named binaries🚫 Binary names that don’t match the software等会导致封号,如果账号长时间无使用也会被删号,现在不推荐。
在Serv00上运行一个Nodejs程序 —— 以UptimeKuma为例
0. 先在后台允许运行自己的应用
首先根据你注册成功后收到的邮件登录到后台 panelX.serv00.com,
左边栏依次找到 “Additional services” -> “Run your own applications” -> “Enable”
如果当前正在SSH,需要重新连接。
1. SSH到虚拟主机并配置PM2
PM2 是一个功能强大、易于使用的 Node.js 进程管理器。它可以帮助开发者更轻松地管理和监控 Node.js 应用程序,提高应用程序的稳定性和性能。
这里主要是使用PM2管理Nodejs应用。由于虚拟主机并不给到Root权限,因此需要配置一下npm。
# 在当前目录创建npm全局包的安装位置
mkdir -p ~/.npm-global
npm config set prefix '~/.npm-global'
# 配置环境变量以全局使用pm2命令
echo "export PATH=~/.npm-global/bin:$PATH" >> ~/.profile
# 安装PM2
npm install -g pm2
# 安装完成后使环境变量生效
source ~/.profile
当上述步骤完成后,可以运行 pm2 status 验证是否安装成功。
如果返回 bin/pm2 Permission denied,说明第0步没有设置成功,请检查。
2. Serv00配置一个公开端口
一般的Nodejs构建的Web应用需要暴露一个端口供客户端访问,因此需要在Serv00上配置一个端口。
在panelX.serv00.com的后台左边栏找到:“Port reservation” -> “Add port” ,根据需要添加暴露的端口,下文的UptimeKuma只需要一个暴露端口,这里我以22010举例。

3. PM2方式运行UptimeKuma
首先把项目clone到domain目录下
cd ~/domains
# 克隆指定Tag的Release
git clone -b 1.23.16 https://github.com/louislam/uptime-kuma.git
克隆完成后,根目录下有一个 ecosystem.config.js ,可以作为PM2的配置文件。
编辑它,增加端口配置以及关闭Playwright特性:
module.exports = {
apps: [{
name: "uptime-kuma",
script: "./server/server.js",
args: "--port=22010",
env: {
"PLAYWRIGHT_BROWSERS_PATH": "/nonexistent"
}
}]
};
保存,然后执行 pm2 start ./ecosystem.config.js 测试是否运行正常。
运行成功后记得立即访问
<your-account>.serv00.net:22010配置管理员账户!
4. 配置服务保活
由于Serv00的环境经常不定时重启,会导致PM2相关服务被关闭,因此我们需要配置一个保活脚本来让PM2的相关任务被kill时自动重新拉起。
#!/bin/bash
USERNAME='<your-account>'
WEBSITE='http://<your-account>.serv00.net:22010/'
check_health() {
local CODE=$(curl -o /dev/null -s -w "%{http_code}\n" --connect-timeout 10 --max-time 30 ${WEBSITE})
if [ "$CODE" = "000" ]; then
return 1
fi
return 0
}
check_health
EXIT_CODE=$?
if [ ${EXIT_CODE} -eq 1 ]; then
echo 'Trying to restart pm2...'
/home/${USERNAME}/.npm-global/bin/pm2 resurrect
/home/${USERNAME}/.npm-global/bin/pm2 restart all
elif [ ${EXIT_CODE} -eq 0 ]; then
echo 'Server is up!'
fi
这个脚本通过检测UptimeKuma的Web页面是否还能够正常返回状态码从而判断主机的PM2是否正常,因此Website可以换成你的配置,其中Code为000是指服务器无响应(即对应端口并没有服务处理请求)。
保存为restart-pm2.sh,放在我们的用户目录。用主机的Crontab也是有可能被还原的,因此我们需要在Serv00的后台配置一个定时任务。
依然是panelX.serv00.com,左边栏依次:“Cron jobs” -> “Add cron job”
如下配置:

如果你有其他的可以用来检测服务状态的Web服务也可以换成对应的
5. 配置Cloudflare定时访问后台账号保活
我是用的是这个帖子的旧版本脚本,可以参考更新的。由于我不需要TG通知,相关的函数都进行了注释。
参考:@Xiang https://linux.do/t/topic/181957
addEventListener('fetch', event => {
event.respondWith(handleRequest(event.request))
})
addEventListener('scheduled', event => {
event.waitUntil(handleScheduled(event.scheduledTime))
})
// @ts-ignore
async function handleRequest(request) {
return new Response('Worker is running')
}
// @ts-ignore
async function handleScheduled(scheduledTime) {
// @ts-ignore
const accounts = JSON.parse(ACCOUNTS_JSON)
const results = await loginAccounts(accounts)
await sendSummary(results)
}
async function loginAccounts(accounts) {
const results = []
for (const account of accounts) {
const result = await loginAccount(account)
results.push({ ...account, ...result })
await delay(Math.floor(Math.random() * 8000) + 1000)
}
return results
}
function generateRandomUserAgent() {
const browsers = ['Chrome', 'Firefox', 'Safari', 'Edge', 'Opera'];
const browser = browsers[Math.floor(Math.random() * browsers.length)];
const version = Math.floor(Math.random() * 100) + 1;
const os = ['Windows NT 10.0', 'Macintosh', 'X11'];
const selectedOS = os[Math.floor(Math.random() * os.length)];
const osVersion = selectedOS === 'X11' ? 'Linux x86_64' : selectedOS === 'Macintosh' ? 'Intel Mac OS X 10_15_7' : 'Win64; x64';
return `Mozilla/5.0 (${selectedOS}; ${osVersion}) AppleWebKit/537.36 (KHTML, like Gecko) ${browser}/${version}.0.0.0 Safari/537.36`;
}
async function loginAccount(account) {
const { username, password, panelnum, type } = account
let url = type === 'ct8'
? 'https://panel.ct8.pl/login/?next=/'
: `https://panel${panelnum}.serv00.com/login/?next=/`
const userAgent = generateRandomUserAgent();
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'User-Agent': userAgent,
},
})
const pageContent = await response.text()
const csrfMatch = pageContent.match(/name="csrfmiddlewaretoken" value="([^"]*)"/)
const csrfToken = csrfMatch ? csrfMatch[1] : null
if (!csrfToken) {
throw new Error('CSRF token not found')
}
const initialCookies = response.headers.get('set-cookie') || ''
const formData = new URLSearchParams({
'username': username,
'password': password,
'csrfmiddlewaretoken': csrfToken,
'next': '/'
})
const loginResponse = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Referer': url,
'User-Agent': userAgent,
'Cookie': initialCookies,
},
body: formData.toString(),
redirect: 'manual'
})
console.log(`Login response status: ${loginResponse.status}`)
console.log(`Login response headers: ${JSON.stringify(Object.fromEntries(loginResponse.headers))}`)
const loginResponseBody = await loginResponse.text()
console.log(`Login response body: ${loginResponseBody.substring(0, 200)}...`)
if (loginResponse.status === 302 && loginResponse.headers.get('location') === '/') {
const loginCookies = loginResponse.headers.get('set-cookie') || ''
const allCookies = combineCookies(initialCookies, loginCookies)
const dashboardResponse = await fetch(url.replace('/login/', '/'), {
headers: {
'Cookie': allCookies,
'User-Agent': userAgent,
}
})
const dashboardContent = await dashboardResponse.text()
console.log(`Dashboard content: ${dashboardContent.substring(0, 200)}...`)
if (dashboardContent.includes('href="/logout/"') || dashboardContent.includes('href="/wyloguj/"')) {
const nowUtc = formatToISO(new Date())
const nowBeijing = formatToISO(new Date(Date.now() + 8 * 60 * 60 * 1000))
const message = `账号 ${username} (${type}) 于北京时间 ${nowBeijing}(UTC时间 ${nowUtc})登录成功!`
console.log(message)
// await sendTelegramMessage(message)
return { success: true, message }
} else {
const message = `账号 ${username} (${type}) 登录后未找到登出链接,可能登录失败。`
console.error(message)
// await sendTelegramMessage(message)
return { success: false, message }
}
} else if (loginResponseBody.includes('Nieprawidłowy login lub hasło')) {
const message = `账号 ${username} (${type}) 登录失败:用户名或密码错误。`
console.error(message)
// await sendTelegramMessage(message)
return { success: false, message }
} else {
const message = `账号 ${username} (${type}) 登录失败,未知原因。请检查账号和密码是否正确。`
console.error(message)
// await sendTelegramMessage(message)
return { success: false, message }
}
} catch (error) {
const message = `账号 ${username} (${type}) 登录时出现错误: ${error.message}`
console.error(message)
// await sendTelegramMessage(message)
return { success: false, message }
}
}
function combineCookies(cookies1, cookies2) {
const cookieMap = new Map()
const parseCookies = (cookieString) => {
cookieString.split(',').forEach(cookie => {
const [fullCookie] = cookie.trim().split(';')
const [name, value] = fullCookie.split('=')
if (name && value) {
cookieMap.set(name.trim(), value.trim())
}
})
}
parseCookies(cookies1)
parseCookies(cookies2)
return Array.from(cookieMap.entries()).map(([name, value]) => `${name}=${value}`).join('; ')
}
async function sendSummary(results) {
const successfulLogins = results.filter(r => r.success)
const failedLogins = results.filter(r => !r.success)
let summaryMessage = '登录结果统计:\n'
summaryMessage += `成功登录的账号:${successfulLogins.length}\n`
summaryMessage += `登录失败的账号:${failedLogins.length}\n`
if (failedLogins.length > 0) {
summaryMessage += '\n登录失败的账号列表:\n'
failedLogins.forEach(({ username, type, message }) => {
summaryMessage += `- ${username} (${type}): ${message}\n`
})
}
console.log(summaryMessage)
// try {
// // @ts-ignore
// let data = JSON.parse(TELEGRAM_JSON);
// if(!data.telegramBotToken || !data.telegramBotUserId) return;
// await sendTelegramMessage(summaryMessage)
// } catch(e) {
// console.log("Skip telegram notify... ("+e.message+")")
// }
}
// async function sendTelegramMessage(message) {
// // @ts-ignore
// const telegramConfig = JSON.parse(TELEGRAM_JSON)
// const { telegramBotToken, telegramBotUserId } = telegramConfig
// const url = `https://api.telegram.org/bot${telegramBotToken}/sendMessage`
// try {
// await fetch(url, {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({
// chat_id: telegramBotUserId,
// text: message
// })
// })
// } catch (error) {
// console.error('Error sending Telegram message:', error)
// }
// }
function formatToISO(date) {
return date.toISOString().replace('T', ' ').replace('Z', '').replace(/\.\d{3}Z/, '')
}
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
其中ACCOUNTS_JSON的是存储在Worker的机密里,文本或者机密格式:
[
{
"username": "user1",
"password": "pass1",
"panelnum": "1",
"type": "ct8"
},
{
"username": "user2",
"password": "pass2",
"panelnum": "13",
"type": "serv00"
}
]