- fastify-raw-body was never registered, so HMAC ran over re-serialised JSON and every Forgejo delivery failed with 401 - accept X-Forgejo-Signature / X-Gitea-Signature (bare hex) as well as the GitHub-style sha256= prefix, and reject length mismatches instead of crashing timingSafeEqual with a 500 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
245 lines
9.2 KiB
JavaScript
245 lines
9.2 KiB
JavaScript
import Fastify from 'fastify'
|
|
import crypto from 'crypto'
|
|
import { exec, execSync } from 'child_process'
|
|
import { promisify } from 'util'
|
|
|
|
const execAsync = promisify(exec)
|
|
|
|
const app = Fastify({ logger: true })
|
|
|
|
// Capture the exact request bytes for webhook HMAC verification —
|
|
// re-serialising the parsed body does not round-trip Forgejo's payload.
|
|
app.addContentTypeParser('application/json', { parseAs: 'string' }, (req, body, done) => {
|
|
req.rawBody = body
|
|
try { done(null, JSON.parse(body)) } catch (err) { done(err) }
|
|
})
|
|
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || ''
|
|
const SSH_KEY = process.env.SSH_KEY_PATH || '/root/.ssh/id_ed25519'
|
|
const FORGEJO_URL = process.env.FORGEJO_URL || 'https://git.pterois.co.uk'
|
|
const FORGEJO_ORG = process.env.FORGEJO_ORG || 'hotel-manage-stack'
|
|
const FORGEJO_TOKEN = process.env.FORGEJO_TOKEN || ''
|
|
const AUTH_URL = process.env.AUTH_URL || 'http://10.10.10.101:3001'
|
|
const CENTRAL_AUTH_SECRET = process.env.CENTRAL_AUTH_SECRET || ''
|
|
|
|
// Platform services: fixed IPs, never in the user-facing apps table.
|
|
// 'management' excluded from deploy — can't redeploy the container managing deploys.
|
|
const INFRA_HEALTH = {
|
|
portal: { host: '10.10.10.102', port: 3000 },
|
|
auth: { host: '10.10.10.101', port: 3001 },
|
|
settings: { host: '10.10.10.116', port: 3080 },
|
|
}
|
|
const INFRA_DEPLOY = {
|
|
portal: { host: '10.10.10.102', path: '/opt/portal' },
|
|
auth: { host: '10.10.10.101', path: '/opt/auth' },
|
|
settings: { host: '10.10.10.116', path: '/opt/settings' },
|
|
}
|
|
|
|
// App registry: fetched dynamically from auth — slug → { host, port, path }
|
|
let appRegistry = {}
|
|
|
|
async function refreshRegistry() {
|
|
try {
|
|
const res = await fetch(`${AUTH_URL}/api/auth/internal/registry`, {
|
|
headers: { Authorization: `Bearer ${CENTRAL_AUTH_SECRET}` },
|
|
signal: AbortSignal.timeout(5000),
|
|
})
|
|
if (!res.ok) { app.log.warn(`Registry fetch ${res.status}`); return }
|
|
const apps = await res.json()
|
|
const next = {}
|
|
for (const a of apps) {
|
|
if (a.internal_host && a.internal_port) {
|
|
next[a.slug] = { host: a.internal_host, port: a.internal_port, path: `/opt/${a.slug}` }
|
|
}
|
|
}
|
|
appRegistry = next
|
|
app.log.info(`Registry refreshed: ${Object.keys(next).join(', ') || '(empty)'}`)
|
|
} catch (e) {
|
|
app.log.warn(`Registry refresh failed: ${e.message}`)
|
|
}
|
|
}
|
|
|
|
function getHealthMap() {
|
|
const map = { ...INFRA_HEALTH }
|
|
for (const [slug, { host, port }] of Object.entries(appRegistry)) map[slug] = { host, port }
|
|
return map
|
|
}
|
|
|
|
function getDeployTarget(repo) {
|
|
if (INFRA_DEPLOY[repo]) return INFRA_DEPLOY[repo]
|
|
const a = appRegistry[repo]
|
|
return a ? { host: a.host, path: a.path } : null
|
|
}
|
|
|
|
function allDeployRepos() {
|
|
return [...Object.keys(INFRA_DEPLOY), ...Object.keys(appRegistry)]
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
const deployLog = []
|
|
const statusCache = {}
|
|
const CACHE_TTL = 5 * 60 * 1000
|
|
|
|
function verifySignature(body, signature) {
|
|
if (!WEBHOOK_SECRET) return true
|
|
// Forgejo/Gitea send a bare hex digest; GitHub prefixes it with "sha256="
|
|
const provided = (signature || '').replace(/^sha256=/, '')
|
|
const expected = crypto.createHmac('sha256', WEBHOOK_SECRET).update(body).digest('hex')
|
|
if (provided.length !== expected.length) return false
|
|
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided))
|
|
}
|
|
|
|
async function sshGet(host, cmd) {
|
|
try {
|
|
const { stdout } = await execAsync(
|
|
`ssh -i ${SSH_KEY} -o StrictHostKeyChecking=no -o ConnectTimeout=2 root@${host} "${cmd}"`,
|
|
{ timeout: 8_000 }
|
|
)
|
|
return stdout.trim()
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
async function getForgejoCommit(repo) {
|
|
try {
|
|
const headers = { Accept: 'application/json' }
|
|
if (FORGEJO_TOKEN) headers['Authorization'] = `token ${FORGEJO_TOKEN}`
|
|
const res = await fetch(`${FORGEJO_URL}/api/v1/repos/${FORGEJO_ORG}/${repo}/commits?limit=1`, { headers })
|
|
if (!res.ok) return null
|
|
const data = await res.json()
|
|
const c = data?.[0]
|
|
if (!c) return null
|
|
return { sha: c.sha, timestamp: c.created || c.commit?.committer?.date || null }
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
async function refreshStatus(repo) {
|
|
const target = getDeployTarget(repo)
|
|
if (!target) return null
|
|
|
|
const [currentRaw, latest] = await Promise.all([
|
|
sshGet(target.host, `git -C ${target.path} log -1 --format='%H|%cI' HEAD 2>/dev/null || echo not-deployed`),
|
|
getForgejoCommit(repo),
|
|
])
|
|
|
|
let currentSha = null, currentTimestamp = null
|
|
if (currentRaw && currentRaw.includes('|')) {
|
|
;[currentSha, currentTimestamp] = currentRaw.split('|')
|
|
}
|
|
const deployed = !!currentSha
|
|
const entry = {
|
|
repo,
|
|
host: target.host,
|
|
currentCommit: deployed ? currentSha.slice(0, 12) : (currentRaw || 'unreachable'),
|
|
currentCommitAt: currentTimestamp || null,
|
|
latestCommit: latest ? latest.sha.slice(0, 12) : 'unknown',
|
|
latestCommitAt: latest?.timestamp || null,
|
|
updateAvailable: !!(deployed && latest && currentSha && latest.sha !== currentSha),
|
|
deployed,
|
|
checkedAt: new Date().toISOString(),
|
|
}
|
|
statusCache[repo] = entry
|
|
return entry
|
|
}
|
|
|
|
async function deploy(target, repoName) {
|
|
const cmd = `ssh -i ${SSH_KEY} -o StrictHostKeyChecking=no root@${target.host} "cd ${target.path} && git pull && docker compose up -d --build"`
|
|
const entry = { repo: repoName, host: target.host, started: new Date().toISOString(), status: 'running' }
|
|
deployLog.unshift(entry)
|
|
if (deployLog.length > 50) deployLog.pop()
|
|
|
|
try {
|
|
const out = execSync(cmd, { timeout: 300_000, encoding: 'utf8' })
|
|
entry.status = 'success'
|
|
entry.output = out.slice(-500)
|
|
app.log.info(`Deploy ${repoName} → success`)
|
|
delete statusCache[repoName]
|
|
} catch (err) {
|
|
entry.status = 'failed'
|
|
entry.error = err.message.slice(-500)
|
|
app.log.error(`Deploy ${repoName} → failed`)
|
|
}
|
|
entry.finished = new Date().toISOString()
|
|
}
|
|
|
|
// ── Routes ────────────────────────────────────────────────────────────────────
|
|
|
|
app.get('/health', async () => ({ status: 'healthy' }))
|
|
|
|
app.get('/health-status', async () => {
|
|
const healthMap = getHealthMap()
|
|
return Promise.all(
|
|
Object.entries(healthMap).map(async ([name, { host, port }]) => {
|
|
const t0 = Date.now()
|
|
try {
|
|
await fetch(`http://${host}:${port}/health`, { signal: AbortSignal.timeout(3000) })
|
|
return { name, up: true, ms: Date.now() - t0 }
|
|
} catch {
|
|
return { name, up: false, ms: null }
|
|
}
|
|
})
|
|
)
|
|
})
|
|
|
|
app.get('/deploys', async () => deployLog)
|
|
|
|
app.get('/status', async (request) => {
|
|
const single = request.query?.repo
|
|
const force = request.query?.force === 'true'
|
|
const repos = single ? [single] : allDeployRepos()
|
|
const now = Date.now()
|
|
const results = await Promise.all(repos.map(repo => {
|
|
const cached = statusCache[repo]
|
|
if (!force && cached && (now - new Date(cached.checkedAt).getTime()) < CACHE_TTL) return cached
|
|
return refreshStatus(repo)
|
|
}))
|
|
return results.filter(Boolean)
|
|
})
|
|
|
|
app.post('/deploy/:repo', async (request, reply) => {
|
|
const { repo } = request.params
|
|
const target = getDeployTarget(repo)
|
|
if (!target) return reply.status(404).send({ error: 'Unknown repo' })
|
|
|
|
const running = deployLog.find(d => d.repo === repo && d.status === 'running')
|
|
if (running) return reply.status(409).send({ error: 'Deploy already in progress' })
|
|
|
|
deploy(target, repo).catch(() => {})
|
|
return reply.status(202).send({ ok: true, deploying: repo })
|
|
})
|
|
|
|
app.post('/webhook', { config: { rawBody: true } }, async (request, reply) => {
|
|
const sig = request.headers['x-hub-signature-256']
|
|
|| request.headers['x-forgejo-signature']
|
|
|| request.headers['x-gitea-signature']
|
|
const rawBody = request.rawBody || JSON.stringify(request.body)
|
|
if (!verifySignature(rawBody, sig)) return reply.status(401).send({ error: 'Invalid signature' })
|
|
|
|
const event = request.headers['x-gitea-event'] || request.headers['x-github-event']
|
|
if (event !== 'push') return reply.status(200).send({ ok: true, skipped: 'not a push event' })
|
|
|
|
const repoName = request.body?.repository?.name
|
|
const ref = request.body?.ref || ''
|
|
if (!ref.endsWith('/main') && !ref.endsWith('/master')) {
|
|
return reply.status(200).send({ ok: true, skipped: 'not main branch' })
|
|
}
|
|
|
|
// Refresh registry on push so a newly registered app deploys immediately
|
|
await refreshRegistry()
|
|
|
|
const target = getDeployTarget(repoName)
|
|
if (!target) return reply.status(200).send({ ok: true, skipped: 'no deploy target' })
|
|
|
|
deploy(target, repoName).catch(() => {})
|
|
return reply.status(202).send({ ok: true, deploying: repoName })
|
|
})
|
|
|
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
|
|
await refreshRegistry()
|
|
setInterval(refreshRegistry, 5 * 60 * 1000) // keep registry fresh
|
|
|
|
await app.listen({ port: 9000, host: '0.0.0.0' })
|