diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index d59caf0..867fbc3 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -251,9 +251,21 @@ proxy and a natural fit — it's both an **OIDC client** of the SSO Manager *and **Automatic snapshots** — when run as part of the unified `theta-env` stack, `setup.sh` snapshots LDAP + Redis + `./config/` to `./backups//` before every rebuild and keeps the last `BACKUP_KEEP` (default 5). Standalone -deployments don't get this; use the manual steps below. +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 LDAP (`slapcat`, auto-detecting your base DN from +`./config/sso-secrets.js`), 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 # LDAP — full directory export (works while slapd is running) @@ -267,8 +279,8 @@ docker compose cp sso-manager:/data/dump.rdb sso-redis-$(date +%F).rdb # Secrets — copy the config dir (holds LDAP_ADMIN_PASS, JWT secret, etc.) cp -a ./config config-backup-$(date +%F) && chmod 700 config-backup-$(date +%F) ``` -Store the `.ldif`, `.rdb`, and config copy **off the host** — they contain -secrets and the whole user directory. +Store the backup **off the host** — it contains secrets and the whole user +directory. **Restore — full (disaster recovery)** diff --git a/Dockerfile.openldap b/Dockerfile.openldap index 2eb780f..5671889 100644 --- a/Dockerfile.openldap +++ b/Dockerfile.openldap @@ -84,6 +84,7 @@ COPY nodejs/controller ./controller COPY nodejs/middleware ./middleware COPY nodejs/models ./models COPY nodejs/routes ./routes +COPY nodejs/services ./services COPY nodejs/utils ./utils COPY nodejs/views ./views COPY nodejs/public ./public diff --git a/docs/ldap.md b/docs/ldap.md index 0b56ef7..9ff199b 100644 --- a/docs/ldap.md +++ b/docs/ldap.md @@ -212,6 +212,11 @@ active/inactive toggle depends on). ## Backups and restore +`ops/backup.sh` automates this (LDAP + Redis + `./config/`, with retention) +for standalone deployments — see the *Backups and restore* section of +`DEPLOYMENT.md`. The manual LDAP-only steps below are what it does under the +hood, useful if you want just the directory without Redis/config. + **Backup** (while slapd is running): ```bash diff --git a/nodejs/app.js b/nodejs/app.js index 518886d..dbce83e 100755 --- a/nodejs/app.js +++ b/nodejs/app.js @@ -23,6 +23,9 @@ const { router: oauthRouter, authRouter: oauthApiRouter, discovery } = require(' // Grab the projects PubSub app.contoller = require('./controller'); +// Background services (self-initializing on require). +require('./services/update_check'); + // Push pubsub over the socket and back. app.onListen.push(function(){ app.io.use(middleware.authIO); @@ -76,6 +79,7 @@ app.use('/api/token', middleware.auth, require('./routes/token')); app.use('/api/group', middleware.auth, require('./routes/group')); app.use('/api/service-account', middleware.auth, require('./routes/service_account')); app.use('/api/notification', middleware.auth, require('./routes/notification')); +app.use('/api/update-check', middleware.auth, require('./routes/update_check')); // Self-service API tokens (PATs) — owner-scoped, no admin group required. app.use('/api/api-token', middleware.auth, require('./routes/api_token')); diff --git a/nodejs/conf/base.js b/nodejs/conf/base.js index 244c303..2644cd8 100644 --- a/nodejs/conf/base.js +++ b/nodejs/conf/base.js @@ -52,4 +52,11 @@ module.exports = { pass: '__in secrets file__', from: 'SSO Manager ', }, + service: { + updateCheck: { + enabled: true, + initial: 30000, // first check 30s after start + interval: 86400000, // then every 24h + }, + }, }; \ 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..de68c45 --- /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/sso-manager-node'; +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-sso-manager-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 c5e62f1..f3cd9f6 100755 --- a/nodejs/views/top.ejs +++ b/nodejs/views/top.ejs @@ -85,7 +85,25 @@ + + +