commit ab329d497b7f32b1633fb1ab28f54fca0226ec8f Author: jtricerolph Date: Wed Jul 1 12:09:54 2026 +0000 Initial commit: management diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..68fa6d8 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +WEBHOOK_SECRET=CHANGE_ME_match_forgejo_webhook_secret +PG_SUPERPASS=CHANGE_ME_postgres_superuser_password +BACKUP_DATABASES=auth_db noticeboard_db kitchen_db cashup_db hk_db +BACKUP_REMOTE=user@yourserver.com:/backups/hnf-proxmox +KUMA_PUSH_URL=http://localhost:3001/api/push/KUMA_TOKEN diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a4ce5d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Dependencies +node_modules/ +.pnp/ +.pnp.js + +# Build output +dist/ +build/ +.next/ +out/ + +# Environment / secrets +.env +.env.local +.env.*.local +!.env.example + +# Editor +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* + +# Docker volumes (if any are mounted locally) +postgres-data/ + +# Python +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ +*.egg-info/ + +# Temp +*.tar.gz +*.tmp diff --git a/backup/backup.sh b/backup/backup.sh new file mode 100644 index 0000000..e1c0b56 --- /dev/null +++ b/backup/backup.sh @@ -0,0 +1,46 @@ +#!/bin/sh +set -e + +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +BACKUP_DIR=/backups +PG_HOST=10.10.10.100 + +mkdir -p "$BACKUP_DIR/postgres/daily" "$BACKUP_DIR/postgres/weekly" "$BACKUP_DIR/volumes" + +log() { echo "[$(date '+%H:%M:%S')] $1"; } + +# ── PostgreSQL dumps ────────────────────────────────────────────────────────── +log "Starting PostgreSQL backups..." +for DB in $BACKUP_DATABASES; do + OUTFILE="$BACKUP_DIR/postgres/daily/${DB}_${TIMESTAMP}.sql.gz" + PGPASSWORD="$PG_SUPERPASS" pg_dump -h "$PG_HOST" -U postgres "$DB" | gzip > "$OUTFILE" + log " $DB → $(du -sh "$OUTFILE" | cut -f1)" +done + +# Weekly full dump on Sundays +if [ "$(date +%u)" = "7" ]; then + log "Weekly full dump..." + PGPASSWORD="$PG_SUPERPASS" pg_dumpall -h "$PG_HOST" -U postgres \ + | gzip > "$BACKUP_DIR/postgres/weekly/full_${TIMESTAMP}.sql.gz" +fi + +# ── Retention cleanup ───────────────────────────────────────────────────────── +find "$BACKUP_DIR/postgres/daily" -name "*.gz" -mtime +7 -delete +find "$BACKUP_DIR/postgres/weekly" -name "*.gz" -mtime +28 -delete +log "Retention cleanup done" + +# ── Rsync to remote ─────────────────────────────────────────────────────────── +if [ -n "$BACKUP_REMOTE" ]; then + log "Syncing to $BACKUP_REMOTE..." + rsync -az --delete \ + -e "ssh -i /root/.ssh/id_ed25519 -o StrictHostKeyChecking=no" \ + "$BACKUP_DIR/" "$BACKUP_REMOTE" + log "Sync complete" +fi + +# ── Uptime Kuma heartbeat ───────────────────────────────────────────────────── +if [ -n "$KUMA_PUSH_URL" ]; then + wget -qO- "$KUMA_PUSH_URL?status=up&msg=Backup+OK&ping=" > /dev/null 2>&1 || true +fi + +log "Backup complete" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3565b01 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +services: + uptime-kuma: + image: louislam/uptime-kuma:1 + volumes: + - kuma_data:/app/data + ports: + - "3002:3001" + restart: unless-stopped + + updater: + build: ./updater + environment: + - WEBHOOK_SECRET=${WEBHOOK_SECRET} + - SSH_KEY_PATH=/root/.ssh/id_ed25519 + volumes: + - /root/.ssh:/root/.ssh:ro + ports: + - "9000:9000" + restart: unless-stopped + + backup: + image: postgres:16-alpine + entrypoint: ["/bin/sh", "-c", "crond -f -l 8"] + environment: + - BACKUP_DATABASES=${BACKUP_DATABASES} + - PG_SUPERPASS=${PG_SUPERPASS} + - BACKUP_REMOTE=${BACKUP_REMOTE} + - KUMA_PUSH_URL=${KUMA_PUSH_URL} + volumes: + - backup_data:/backups + - ./backup/backup.sh:/etc/periodic/daily/backup:ro + - /root/.ssh:/root/.ssh:ro + restart: unless-stopped + +volumes: + kuma_data: + backup_data: diff --git a/updater/Dockerfile b/updater/Dockerfile new file mode 100644 index 0000000..0c3fa06 --- /dev/null +++ b/updater/Dockerfile @@ -0,0 +1,15 @@ +FROM node:20-alpine + +RUN apk add --no-cache openssh-client + +WORKDIR /app + +COPY package.json ./ +RUN npm install --omit=dev + +COPY src/ ./src/ +COPY deploy-map.js ./ + +EXPOSE 9000 + +CMD ["node", "src/index.js"] diff --git a/updater/deploy-map.js b/updater/deploy-map.js new file mode 100644 index 0000000..d8fbd62 --- /dev/null +++ b/updater/deploy-map.js @@ -0,0 +1,14 @@ +// Maps Forgejo repo names → target LXC SSH details. +// Keys are the plain repo names Forgejo sends in the webhook (repository.name). +// The updater only pulls + rebuilds existing LXCs — provisioning is done on the +// Proxmox host with proxmox-helpers/add-app.sh, which appends new apps here. +export const deployMap = { + 'noticeboard': { host: '10.10.10.112', path: '/opt/noticeboard' }, + 'kitchen': { host: '10.10.10.110', path: '/opt/kitchen' }, + 'cashup': { host: '10.10.10.111', path: '/opt/cashup' }, + 'housekeeping': { host: '10.10.10.114', path: '/opt/housekeeping' }, + 'forecasting': { host: '10.10.10.113', path: '/opt/forecasting' }, + 'rates': { host: '10.10.10.115', path: '/opt/rates' }, + 'portal': { host: '10.10.10.102', path: '/opt/portal' }, + 'auth': { host: '10.10.10.101', path: '/opt/auth' }, +} diff --git a/updater/package.json b/updater/package.json new file mode 100644 index 0000000..6f2528f --- /dev/null +++ b/updater/package.json @@ -0,0 +1,9 @@ +{ + "name": "hnf-updater", + "version": "1.0.0", + "type": "module", + "scripts": { "start": "node src/index.js" }, + "dependencies": { + "fastify": "^4.28.1" + } +} diff --git a/updater/src/index.js b/updater/src/index.js new file mode 100644 index 0000000..baf4e2f --- /dev/null +++ b/updater/src/index.js @@ -0,0 +1,72 @@ +import Fastify from 'fastify' +import crypto from 'crypto' +import { execSync } from 'child_process' +import { deployMap } from '../deploy-map.js' + +const app = Fastify({ logger: true }) +const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || '' +const SSH_KEY = process.env.SSH_KEY_PATH || '/root/.ssh/id_ed25519' +const deployLog = [] + +function verifySignature(body, signature) { + if (!WEBHOOK_SECRET) return true // disabled in dev + const expected = `sha256=${crypto.createHmac('sha256', WEBHOOK_SECRET).update(body).digest('hex')}` + return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature || '')) +} + +async function deploy(target, repoName) { + const { host, path } = target + const cmd = `ssh -i ${SSH_KEY} -o StrictHostKeyChecking=no root@${host} "cd ${path} && git pull && docker compose up -d --build"` + + const entry = { repo: repoName, 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`) + } catch (err) { + entry.status = 'failed' + entry.error = err.message.slice(-500) + app.log.error(`Deploy ${repoName} → failed: ${err.message}`) + } + entry.finished = new Date().toISOString() +} + +app.post('/webhook', { + config: { rawBody: true } +}, async (request, reply) => { + const sig = request.headers['x-hub-signature-256'] + 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' }) + } + + const target = deployMap[repoName] + if (!target) { + app.log.warn(`No deploy target for repo: ${repoName}`) + return reply.status(200).send({ ok: true, skipped: 'no deploy target' }) + } + + // Kick off deploy async — don't block the webhook response + deploy(target, repoName).catch(() => {}) + return reply.status(202).send({ ok: true, deploying: repoName }) +}) + +app.get('/health', async () => ({ status: 'healthy' })) + +app.get('/deploys', async () => deployLog) + +await app.listen({ port: 9000, host: '0.0.0.0' })