#!/usr/bin/env node import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { spawnSync } from 'node:child_process' import { fileURLToPath } from 'node:url' import { setGodaddyARecord } from './godaddy-dns.js' import { renderVisualGameHttpConfig, renderVisualGameHttpsConfig } from './visual-game-nginx.js' const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') const config = readDeploymentConfig(process.argv) const pat = readGodaddyPat(config.dns.credential_file) for (const domain of config.domains) { const result = await setGodaddyARecord({ pat, zone: config.dns.zone, name: domain.record_name, address: config.public_ip, ttl: config.dns.ttl, }) console.log(`DNS ${result.action}: ${result.hostname} -> ${result.address}`) } prepareRemoteDirectories(config) installNginxConfig(config, renderVisualGameHttpConfig(config)) issueCertificate(config) installNginxConfig(config, renderVisualGameHttpsConfig(config)) await verifyDeployment(config) console.log('域名部署完成') for (const domain of config.domains) console.log(`${domain.role}: https://${domain.hostname}`) function readDeploymentConfig(argv) { const configPath = path.resolve(repositoryRoot, argv[2]) return JSON.parse(fs.readFileSync(configPath, 'utf8')) } function readGodaddyPat(credentialFile) { const filePath = path.resolve(repositoryRoot, credentialFile) const content = fs.readFileSync(filePath, 'utf8').trim() return content.slice('GODADDY_PAT='.length) } function prepareRemoteDirectories(deployment) { runRemote(deployment, 'sudo', ['mkdir', '-p', deployment.nginx.acme_webroot]) runRemote(deployment, 'sudo', ['mkdir', '-p', deployment.nginx.certificate_directory]) if (deployment.nginx.layout === 'sites') { runRemote(deployment, 'sudo', ['mkdir', '-p', '/etc/nginx/sites-available']) runRemote(deployment, 'sudo', ['mkdir', '-p', '/etc/nginx/sites-enabled']) } } function installNginxConfig(deployment, content) { const upload = `/tmp/${deployment.nginx.site_name}` runRemote(deployment, 'tee', [upload], content) installNginxSite(deployment, upload) runRemote(deployment, 'sudo', ['nginx', '-t']) runRemote(deployment, 'sudo', ['systemctl', 'reload', 'nginx']) runRemote(deployment, 'rm', [upload]) } function installNginxSite(deployment, upload) { switch (deployment.nginx.layout) { case 'sites': { const available = `/etc/nginx/sites-available/${deployment.nginx.site_name}` const enabled = `/etc/nginx/sites-enabled/${deployment.nginx.site_name}` runRemote(deployment, 'sudo', ['install', '-m', '644', upload, available]) runRemote(deployment, 'sudo', ['ln', '-sfn', available, enabled]) return } case 'conf.d': runRemote(deployment, 'sudo', [ 'install', '-m', '644', upload, `/etc/nginx/conf.d/${deployment.nginx.site_name}.conf`, ]) return default: throw new Error(`不支持的 nginx layout:${deployment.nginx.layout}`) } } function issueCertificate(deployment) { const domains = [] for (const domain of deployment.domains) domains.push('-d', domain.hostname) runRemote(deployment, 'sudo', [ '/root/.acme.sh/acme.sh', '--issue', '--server', 'letsencrypt', '--webroot', deployment.nginx.acme_webroot, ...domains, ]) runRemote(deployment, 'sudo', [ '/root/.acme.sh/acme.sh', '--install-cert', '-d', deployment.domains[0].hostname, '--key-file', `${deployment.nginx.certificate_directory}/key.pem`, '--fullchain-file', `${deployment.nginx.certificate_directory}/fullchain.pem`, '--reloadcmd', 'systemctl reload nginx', ]) } async function verifyDeployment(deployment) { for (const domain of deployment.domains) { const response = await fetch(`https://${domain.hostname}/`, { redirect: 'manual' }) if (response.status !== 200) throw new Error(`${domain.hostname} 首页返回 ${response.status}`) console.log(`HTTPS 200: ${domain.hostname}`) } const health = await fetch(`https://${deployment.domains[0].hostname}/healthz`) if (health.status !== 200) throw new Error(`${deployment.domains[0].hostname}/healthz 返回 ${health.status}`) console.log(`API 200: ${deployment.domains[0].hostname}/healthz`) } function runRemote(deployment, command, args, input) { const identityFile = expandHome(deployment.ssh.identity_file) const destination = `${deployment.ssh.user}@${deployment.ssh.host}` const remoteCommand = [command, ...args].map(quoteRemoteArgument).join(' ') const result = spawnSync('ssh', [ '-i', identityFile, '-o', 'IdentitiesOnly=yes', '-o', 'StrictHostKeyChecking=yes', destination, remoteCommand, ], { cwd: repositoryRoot, encoding: 'utf8', input, maxBuffer: 10 * 1024 * 1024, }) if (result.status !== 0) { throw new Error(`远程命令失败: ${command} ${args.join(' ')}\n${result.stdout}\n${result.stderr}`) } return result.stdout.trim() } function quoteRemoteArgument(value) { return `'${value.replaceAll("'", "'\\''")}'` } function expandHome(filePath) { if (!filePath.startsWith('~/')) return filePath return path.join(os.homedir(), filePath.slice(2)) }