Add standalone backup script and admin update-check banner

ops/backup.sh snapshots LDAP (slapcat), Redis (BGSAVE, dynamic RDB path
lookup), and ./config for standalone deployments, with retention. A
background service polls GitHub releases every 24h and surfaces an
admin-only banner in the UI when a newer version is published.
This commit is contained in:
2026-07-15 22:33:55 -04:00
parent b6766edd08
commit 4b0a9e9038
10 changed files with 280 additions and 4 deletions
+16 -4
View File
@@ -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/<timestamp>/`
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/<timestamp>/`, 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)**
+1
View File
@@ -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
+5
View File
@@ -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
+4
View File
@@ -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'));
+7
View File
@@ -52,4 +52,11 @@ module.exports = {
pass: '__in secrets file__',
from: 'SSO Manager <noreply@example.com>',
},
service: {
updateCheck: {
enabled: true,
initial: 30000, // first check 30s after start
interval: 86400000, // then every 24h
},
},
};
+18
View File
@@ -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;
+26
View File
@@ -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 = {};
+66
View File
@@ -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 };
+30
View File
@@ -85,7 +85,25 @@
</div>
</div>
</nav>
<div id="update-banner" class="alert alert-info alert-dismissible mb-0 rounded-0 text-center" style="display:none; position:fixed; left:0; right:0; z-index:1029;">
<span id="update-banner-text"></span>
<button type="button" class="btn-close" onclick="dismissUpdateBanner()"></button>
</div>
<script type="text/javascript">
function showUpdateBanner(){
let $nav = $('nav.fixed-top');
let $banner = $('#update-banner');
$banner.css('top', $nav.outerHeight() + 'px').show();
$('#spa-shell').css('margin-top', ($nav.outerHeight() + $banner.outerHeight()) + 'px');
}
function dismissUpdateBanner(){
$('#update-banner').hide();
$('#spa-shell').css('margin-top', '');
sessionStorage.setItem('update-banner-dismissed', '1');
}
$(document).ready(async function(){
// Set the correct link to active in the top nav bar
@@ -106,6 +124,18 @@
$('#cl-username-text').text(me.uid);
$('#cl-username').css('display', '');
}
if(await app.auth.memberOf('app_sso_admin', me) && !sessionStorage.getItem('update-banner-dismissed')){
app.api.get('update-check', function(error, info){
if(error || !info || !info.updateAvailable) return;
$('#update-banner-text').html(
'A newer version of SSO Manager is available: <b>v' + info.latestVersion + '</b> ' +
'(running v' + info.currentVersion + ') — ' +
'<a href="' + info.releaseUrl + '" target="_blank" class="alert-link">see what changed</a>.'
);
showUpdateBanner();
});
}
}else{
$('#cl-login-button').show();
}
Executable
+107
View File
@@ -0,0 +1,107 @@
#!/bin/bash
# Standalone backup for a Docker-deployed SSO Manager (container name
# "sso-manager").
#
# Snapshots the LDAP directory (slapcat), Redis (OAuth clients, tokens,
# sessions), and ./config (secrets) to ./backups/<timestamp>/, then prunes
# old backups beyond BACKUP_KEEP.
#
# If you run this as part of the unified theta-env stack, use theta-env's
# own setup.sh instead -- it already does this (and more, for both
# containers at once) before every rebuild. This script is for a standalone
# `docker compose up` deployment.
#
# Usage: ./ops/backup.sh [BACKUP_KEEP]
# BACKUP_KEEP how many timestamped backups to retain (default 5; env var
# of the same name also works, matching theta-env's setup.sh)
set -euo pipefail
CONTAINER="${CONTAINER:-sso-manager}"
BACKUP_ROOT="${BACKUP_ROOT:-./backups}"
BACKUP_KEEP="${1:-${BACKUP_KEEP:-5}}"
info() { printf '\033[1;34m[backup]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[backup]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31m[backup]\033[0m %s\n' "$*" >&2; exit 1; }
command -v docker >/dev/null || die "docker not found"
docker inspect "$CONTAINER" >/dev/null 2>&1 || die "container '$CONTAINER' not found -- is the stack running? (docker compose up -d)"
ts="$(date -u +%Y%m%dT%H%M%SZ)"
dest="${BACKUP_ROOT}/${ts}"
mkdir -p "$dest"
chmod 700 "$dest"
# LDAP -- slapcat the live directory while slapd is running. Read the base DN
# from the host-side config first (works whether or not the running
# container has /config mounted), then fall back to reading it inside the
# container, matching theta-env's setup.sh.
info "Snapshotting LDAP..."
basedn=""
if [ -f ./config/sso-secrets.js ] && command -v node >/dev/null 2>&1; then
basedn="$(timeout 5 node -e 'console.log((require(process.cwd()+"/config/sso-secrets.js").stack||{}).ldapBaseDn||"")' 2>/dev/null || true)"
fi
if [ -z "$basedn" ]; then
basedn="$(docker exec "$CONTAINER" node -e 'console.log((require("/config/sso-secrets.js").stack||{}).ldapBaseDn||"")' 2>/dev/null || true)"
fi
if [ -n "$basedn" ] && timeout 20 docker exec "$CONTAINER" slapcat -f /etc/openldap/slapd.conf -b "$basedn" > "${dest}/ldap.ldif" 2>/dev/null; then
info " -> ${dest}/ldap.ldif (${basedn})"
else
rm -f "${dest}/ldap.ldif"
warn " could not determine base DN or slapcat failed -- LDAP not snapshotted"
fi
info "Snapshotting Redis (BGSAVE)..."
# Capture LASTSAVE *before* issuing BGSAVE: a small dataset finishes in well
# under a second, so capturing it afterward races the save and the poll below
# can miss the update entirely (looks like "BGSAVE never finished" when it
# actually finished immediately).
before="$(docker exec "$CONTAINER" redis-cli LASTSAVE 2>/dev/null | tr -dc '0-9' || echo 0)"
docker exec "$CONTAINER" redis-cli BGSAVE >/dev/null 2>&1 || true
ok=0
for _ in $(seq 1 10); do
after="$(docker exec "$CONTAINER" redis-cli LASTSAVE 2>/dev/null | tr -dc '0-9' || echo 0)"
if [ "${after:-0}" -gt "${before:-0}" ]; then ok=1; break; fi
sleep 1
done
if [ "$ok" != "1" ]; then
info "BGSAVE didn't complete in time -- falling back to a synchronous SAVE."
[ "$(docker exec "$CONTAINER" redis-cli SAVE 2>/dev/null | tr -d '\r\n')" = "OK" ] && ok=1
fi
if [ "$ok" = "1" ]; then
# Ask Redis where it actually wrote the RDB rather than assuming a fixed
# path -- the all-in-one image keeps it at /app/dump.rdb, not /data.
rdir="$(docker exec "$CONTAINER" redis-cli CONFIG GET dir 2>/dev/null | sed -n '2p' | tr -d '\r\n')"
rfile="$(docker exec "$CONTAINER" redis-cli CONFIG GET dbfilename 2>/dev/null | sed -n '2p' | tr -d '\r\n')"
rpath="${rdir:+$rdir/}${rfile:-dump.rdb}"
if docker cp "${CONTAINER}:${rpath}" "${dest}/sso-manager.rdb" >/dev/null 2>&1; then
info " -> ${dest}/sso-manager.rdb"
else
warn " could not copy ${rpath} from the container -- Redis not snapshotted"
fi
else
warn " Redis snapshot failed -- not included in this backup"
fi
if [ -d ./config ]; then
info "Copying ./config..."
cp -a ./config "${dest}/config"
info " -> ${dest}/config"
else
warn "No ./config directory found here -- skipping (secrets aren't managed from this path?)."
fi
if [ -n "${BACKUP_KEEP}" ] && [ "${BACKUP_KEEP}" -gt 0 ] 2>/dev/null; then
info "Pruning old backups, keeping the newest ${BACKUP_KEEP}..."
# shellcheck disable=SC2012
ls -1dt "${BACKUP_ROOT}"/*/ 2>/dev/null | tail -n "+$((BACKUP_KEEP + 1))" | while read -r old; do
[ -L "${old%/}" ] && continue
info " removing ${old}"
rm -rf "${old}"
done
fi
info "Done: ${dest}"
info "Store this off-host -- it contains the whole user directory and every secret (LDAP admin pass, JWT secret, SMTP)."