diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index f32fbf5..034ca04 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -147,10 +147,21 @@ required for HTTP-01 challenges (mapped in the compose). **Automatic snapshots** — when run as part of the unified `theta-env` stack, `setup.sh` snapshots Redis + `./config/` to `./backups//` before every -rebuild and keeps the last `BACKUP_KEEP` (default 5). Standalone deployments use -the manual steps below. +rebuild and keeps the last `BACKUP_KEEP` (default 5). Standalone deployments +should run `ops/backup.sh` the same way (on a cron/systemd timer, or by hand +before an upgrade): -**Manual backup** +```bash +./ops/backup.sh # keeps the last 5 by default +./ops/backup.sh 10 # or override retention +BACKUP_KEEP=10 ./ops/backup.sh +``` + +It snapshots Redis (`BGSAVE`, falling back to a synchronous `SAVE` if that +doesn't complete quickly) and `./config/` to `./backups//`, +pruning older backups beyond the retention count — the same approach +`theta-env`'s `setup.sh` uses, just scoped to this one container. Equivalent +manual steps, if you'd rather not use the script: ```bash # Redis — hot snapshot: trigger a save, then copy the RDB out @@ -160,8 +171,8 @@ docker compose cp proxy:/data/dump.rdb proxy-redis-$(date +%F).rdb # Secrets — copy the config dir (holds OIDC client secret, LDAP bind password) cp -a ./config config-backup-$(date +%F) && chmod 700 config-backup-$(date +%F) ``` -Store the `.rdb` and config copy **off the host** — they contain secrets and -the whole Host/permission/user dataset. +Store the backup **off the host** — it contains secrets and the whole +Host/permission/user dataset. **Restore — Redis (full proxy state + certs)** diff --git a/nodejs/app.js b/nodejs/app.js index bbffc5a..9bb69a8 100755 --- a/nodejs/app.js +++ b/nodejs/app.js @@ -34,6 +34,7 @@ app.contoller = require('./controller'); require('./services/host_lookup'); require('./services/host_scheduler'); require('./services/dynamic_dns'); +require('./services/update_check'); // Push pubsub over the socket and back. app.onListen.push(function(){ diff --git a/nodejs/conf/base.js b/nodejs/conf/base.js index 027cbe5..8e3df65 100644 --- a/nodejs/conf/base.js +++ b/nodejs/conf/base.js @@ -66,7 +66,12 @@ module.exports = { enabled: true, initial: 15000, // first refresh 15s after start interval: 14400000, // then every 4 hours - } + }, + updateCheck:{ + enabled: true, + initial: 30000, // first check 30s after start + interval: 86400000, // then every 24h + }, }, // Dynamic DNS: services queried (in order) to learn this box's public IP. diff --git a/nodejs/routes/api.js b/nodejs/routes/api.js index 9e3ea4d..e9c57e2 100644 --- a/nodejs/routes/api.js +++ b/nodejs/routes/api.js @@ -31,4 +31,6 @@ router.use('/group', middleware.auth, authz.requireAdmin, require('./group')); // Self-service API tokens (PATs) — owner-scoped, no admin gate required. router.use('/api-token', middleware.auth, require('./api_token')); +router.use('/update-check', middleware.auth, require('./update_check')); + module.exports = router; \ No newline at end of file diff --git a/nodejs/routes/update_check.js b/nodejs/routes/update_check.js new file mode 100644 index 0000000..33f2ee7 --- /dev/null +++ b/nodejs/routes/update_check.js @@ -0,0 +1,18 @@ +'use strict'; + +const router = require('express').Router(); +const updateCheck = require('../utils/update_check'); + +// Any authenticated user can read this (it's just "is a newer version +// published on GitHub", not sensitive) -- the UI only shows the banner to +// admins (see views/top.ejs), but the endpoint itself doesn't need to be +// admin-gated. +router.get('/', async function(req, res, next){ + try{ + return res.json(updateCheck.getState()); + }catch(error){ + next(error); + } +}); + +module.exports = router; diff --git a/nodejs/services/update_check.js b/nodejs/services/update_check.js new file mode 100644 index 0000000..4bc1bca --- /dev/null +++ b/nodejs/services/update_check.js @@ -0,0 +1,26 @@ +'use strict'; + +const conf = require('@simpleworkjs/conf'); +const updateCheck = require('../utils/update_check'); + +function updateCheckService(){ + /** + * Update Check Service + * + * Periodically asks GitHub for the latest published release of this repo + * and compares it to the running version (nodejs/package.json). Never + * auto-updates anything -- just makes the result available via + * GET /api/update-check (see routes/update_check.js) so the admin UI can + * show a banner when a newer release exists. + */ + + setTimeout(updateCheck.checkNow, conf.service.updateCheck.initial); + setInterval(updateCheck.checkNow, conf.service.updateCheck.interval); + + console.log('Update check service initialized'); + console.log(`- Checking ${updateCheck.REPO} releases: 30s after start, then every 24h`); +} + +if(conf.service.updateCheck.enabled !== false) updateCheckService(); + +module.exports = {}; diff --git a/nodejs/utils/update_check.js b/nodejs/utils/update_check.js new file mode 100644 index 0000000..190cea5 --- /dev/null +++ b/nodejs/utils/update_check.js @@ -0,0 +1,66 @@ +'use strict'; + +// Periodic "is a newer release available" check against GitHub releases. +// Nothing auto-updates -- this only surfaces a notice (an admin-only banner, +// see routes/update_check.js + views/top.ejs) so operators know to +// `git pull` + rebuild on their own schedule. State lives in memory only +// (single-process app); a restart just re-checks on the next interval. + +const { buildVersion } = require('./build_info'); + +const REPO = 'theta42/proxy'; +const API_URL = `https://api.github.com/repos/${REPO}/releases/latest`; + +let state = { + currentVersion: buildVersion, + latestVersion: null, + updateAvailable: false, + releaseUrl: null, + checkedAt: null, + error: null, +}; + +// Basic semver compare (major.minor.patch, ignoring any -prerelease/+build +// suffix) -- good enough for comparing release tags like "v1.2.0" against +// package.json's "1.1.0". Returns true if `a` is strictly newer than `b`. +function isNewer(a, b) { + const pa = a.replace(/^v/i, '').split('.').map(n => parseInt(n, 10) || 0); + const pb = b.replace(/^v/i, '').split('.').map(n => parseInt(n, 10) || 0); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const na = pa[i] || 0, nb = pb[i] || 0; + if (na !== nb) return na > nb; + } + return false; +} + +async function checkNow() { + try { + const res = await fetch(API_URL, { + headers: { 'Accept': 'application/vnd.github+json', 'User-Agent': 'theta42-proxy-update-check' }, + }); + if (!res.ok) throw new Error(`GitHub API returned ${res.status}`); + const data = await res.json(); + const latestVersion = String(data.tag_name || '').replace(/^v/i, ''); + + state = { + currentVersion: buildVersion, + latestVersion: latestVersion || null, + updateAvailable: latestVersion ? isNewer(latestVersion, buildVersion) : false, + releaseUrl: data.html_url || `https://github.com/${REPO}/releases/latest`, + checkedAt: Date.now(), + error: null, + }; + } catch (error) { + // Network hiccup, rate limit, no releases published yet, etc. -- keep + // the previous state and just note the failure; never throw, this + // runs unattended on a timer. + state = { ...state, checkedAt: Date.now(), error: error.message }; + } + return state; +} + +function getState() { + return state; +} + +module.exports = { checkNow, getState, isNewer, REPO }; diff --git a/nodejs/views/top.ejs b/nodejs/views/top.ejs index e81071c..68599d8 100755 --- a/nodejs/views/top.ejs +++ b/nodejs/views/top.ejs @@ -83,7 +83,31 @@ + + + +