Serv00上部署自己的Nodejs应用

简单介绍Serv00

Serv00.com 的主要特点:

完全免费: 不需要支付任何费用,就能享受到一定程度的服务器资源。 配置适中: 免费套餐提供了 3GB 存储空间、512MB 内存、不限流量,对于个人博客、小型网站来说足够使用。 功能丰富: 支持 PHP、MySQL 等常见的网站开发语言和数据库,提供免费二级域名、可自定义开放端口、支持 SSH 访问等。 易于使用: 提供用户友好的控制面板,方便用户管理自己的网站。 免费域名邮箱: 可以申请免费的域名邮箱,方便收发邮件。 Serv00.com 的适用人群:

个人博客爱好者: 搭建自己的个人博客,分享生活、技术等。 小型网站开发者: 测试和部署小型网站或应用程序。 学习编程的人: 用于练习和实验。 需要注意的事项:

资源有限: 毕竟是免费的,资源有限,不适合运行大型网站或高负载的应用程序。 需要保持活跃: Serv00 会要求用户定期登录后台或 SSH,以保持账号的活跃状态。 可能存在不稳定性: 免费服务可能存在不稳定性,影响网站的访问速度和稳定性。

在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举例。

serv00_port

3. PM2方式运行UptimeKuma

https://github.com/louislam/uptime-kuma

首先把项目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"

如下配置:

serv00_cron

如果你有其他的可以用来检测服务状态的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"
  }
]
Serv00上部署自己的Nodejs应用
本文作者
spark1e
发布于
2024-12-27
许可协议
转载或引用本文时请遵守许可协议,注明出处、不得用于商业用途!
评论区 - Powered by Giscus