Merge pull request #140 from theta42/backup-and-update-check
Add standalone backup script and admin update-check banner
This commit is contained in:
+16
-5
@@ -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,
|
**Automatic snapshots** — when run as part of the unified `theta-env` stack,
|
||||||
`setup.sh` snapshots Redis + `./config/` to `./backups/<timestamp>/` before every
|
`setup.sh` snapshots Redis + `./config/` to `./backups/<timestamp>/` before every
|
||||||
rebuild and keeps the last `BACKUP_KEEP` (default 5). Standalone deployments use
|
rebuild and keeps the last `BACKUP_KEEP` (default 5). Standalone deployments
|
||||||
the manual steps below.
|
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/<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
|
```bash
|
||||||
# Redis — hot snapshot: trigger a save, then copy the RDB out
|
# 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)
|
# 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)
|
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
|
Store the backup **off the host** — it contains secrets and the whole
|
||||||
the whole Host/permission/user dataset.
|
Host/permission/user dataset.
|
||||||
|
|
||||||
**Restore — Redis (full proxy state + certs)**
|
**Restore — Redis (full proxy state + certs)**
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ app.contoller = require('./controller');
|
|||||||
require('./services/host_lookup');
|
require('./services/host_lookup');
|
||||||
require('./services/host_scheduler');
|
require('./services/host_scheduler');
|
||||||
require('./services/dynamic_dns');
|
require('./services/dynamic_dns');
|
||||||
|
require('./services/update_check');
|
||||||
|
|
||||||
// Push pubsub over the socket and back.
|
// Push pubsub over the socket and back.
|
||||||
app.onListen.push(function(){
|
app.onListen.push(function(){
|
||||||
|
|||||||
+6
-1
@@ -66,7 +66,12 @@ module.exports = {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
initial: 15000, // first refresh 15s after start
|
initial: 15000, // first refresh 15s after start
|
||||||
interval: 14400000, // then every 4 hours
|
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.
|
// Dynamic DNS: services queried (in order) to learn this box's public IP.
|
||||||
|
|||||||
@@ -31,4 +31,6 @@ router.use('/group', middleware.auth, authz.requireAdmin, require('./group'));
|
|||||||
// Self-service API tokens (PATs) — owner-scoped, no admin gate required.
|
// Self-service API tokens (PATs) — owner-scoped, no admin gate required.
|
||||||
router.use('/api-token', middleware.auth, require('./api_token'));
|
router.use('/api-token', middleware.auth, require('./api_token'));
|
||||||
|
|
||||||
|
router.use('/update-check', middleware.auth, require('./update_check'));
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -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;
|
||||||
@@ -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 = {};
|
||||||
@@ -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 };
|
||||||
+39
-1
@@ -83,7 +83,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<!-- Admin-only "a newer release is available" notice (services/update_check.js).
|
||||||
|
Dismissal is per-browser-session only (sessionStorage), not persisted server-side.
|
||||||
|
Fixed-positioned below the fixed navbar (a plain in-flow div here would render
|
||||||
|
UNDER the nav, since fixed elements are taken out of document flow) -- shown/hidden
|
||||||
|
dynamically, so #spa-shell's margin-top is adjusted in JS to make room for it. -->
|
||||||
|
<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">
|
<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(function(){
|
$(document).ready(function(){
|
||||||
|
|
||||||
// Set the correct link to active in the top nav bar
|
// Set the correct link to active in the top nav bar
|
||||||
@@ -108,7 +132,21 @@
|
|||||||
$('#cl-login-button').show();
|
$('#cl-login-button').show();
|
||||||
}
|
}
|
||||||
|
|
||||||
if(data && data.isAdmin) $('.nav-admin').css('display', '');
|
if(data && data.isAdmin){
|
||||||
|
$('.nav-admin').css('display', '');
|
||||||
|
|
||||||
|
if(!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 the proxy 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
Executable
+89
@@ -0,0 +1,89 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Standalone backup for a Docker-deployed proxy (container name "proxy").
|
||||||
|
#
|
||||||
|
# Snapshots Redis (Host records, permissions, DNS creds, local users,
|
||||||
|
# lua-resty-auto-ssl certs) and ./config (secrets) to ./backups/<timestamp>/,
|
||||||
|
# then prunes old backups beyond BACKUP_KEEP.
|
||||||
|
#
|
||||||
|
# If you run proxy as part of the unified theta-env stack, use theta-env's
|
||||||
|
# own setup.sh instead -- it already does this (and more) 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:-proxy}"
|
||||||
|
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"
|
||||||
|
|
||||||
|
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
|
||||||
|
# BGSAVE didn't advance LASTSAVE in time (fork failure, or a save already
|
||||||
|
# in progress) -- fall back to a synchronous SAVE. Reply must be "OK".
|
||||||
|
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 /data --
|
||||||
|
# works regardless of the container's working directory.
|
||||||
|
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}/proxy.rdb" >/dev/null 2>&1; then
|
||||||
|
info " -> ${dest}/proxy.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}..."
|
||||||
|
# Newest-first, drop everything after the Nth.
|
||||||
|
# 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 OIDC client secret, LDAP bind password, and Let's Encrypt account state."
|
||||||
Reference in New Issue
Block a user