openvibers/openvibe.host Commits
Sign-in canary: every public site's /auth/health every 15 minutes (#206)
Changes
@@@ -15,7 +15,7 @@ jobs:test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'contracts-src: lib serverdocs-status: true-- extra: node --check bin/ovhost && for f in scripts/*.sh; do bash -n "$f"; done++ extra: node --check bin/ovhost && for f in scripts/*.sh deploy/scripts/*.sh; do bash -n "$f"; done# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).security:uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@aa570e4815063dc65624a6a486038d83ff6f6ec0@@@ -293,7 +293,7 @@ Two things deploy from this repository:`cd /usr/local/lib/openvibe-host && sudo git pull --ff-only && sudo npm ci --omit=dev --no-audit --no-fund`,then `ovhost --version` and `sudo ovhost capabilities` (ovhost does not deploy itself; an update isundone by checking out the previous commit there). Its timers (`openvibe-backup`, `openvibe-alerts`, `openvibe-browsercheck`,-- `openvibe-devpath`, `openvibe-toolsjob`) are unit files in [deploy/systemd/](deploy/systemd/).++ `openvibe-authhealth`, `openvibe-devpath`, `openvibe-toolsjob`) are unit files in [deploy/systemd/](deploy/systemd/).- **Boot order.** Three drop-ins in [deploy/systemd/](deploy/systemd/) order every `openvibe-*` and `openre-*`service after PostgreSQL, PgBouncer and Valkey (systemd prefix drop-ins, `openvibe-.service.d/`), and PgBouncerafter PostgreSQL. Without them a reboot starts services against a PgBouncer that caches PostgreSQL's refused@@@ -95,6 +95,16 @@ groups:labels: { severity: ticket }annotations:summary: "{{ $labels.service }}: no browser check has run to the end for more than 26 hours"++ # The sign-in canary (deploy/scripts/auth-health.sh, openvibe-authhealth.timer; docs/auth-health.md):++ # a public site answers GET /auth/health 200 while the Network accepts its OAuth client and 503 when++ # it refuses it. A site not on the SDK's sign-in yet writes no line, so it never fires this. A ticket,++ # not a page: sign-in is broken, the site still serves.++ - alert: OpenVibeSignInBroken++ expr: openvibe_auth_health == 0++ for: 30m++ labels: { severity: ticket }++ annotations:++ summary: "Sign-in is broken on {{ $labels.site }}: the Network refuses its OAuth client (check OV_OAUTH_CLIENT_SECRET in its env)"# The relay itself (openvibe-alerts.timer; WS-H task 11). It cannot page while it is broken; it pages# this as soon as a run gets through again, so an outage of the pager is never silent afterwards.- alert: OpenVibeAlertRelayFailingnnew file mode 100755@@@ -0,0 +1,149 @@++#!/usr/bin/env bash++# Sign-in canary for every OpenVibe site (docs/auth-health.md, openvibe-authhealth.timer).++#++# deploy/scripts/auth-health.sh every public origin in Network's registry++# deploy/scripts/auth-health.sh --sites live,cam only these (a post-deploy run)++# AUTH_HEALTH_REGISTRY=<url|file> where the list comes from (default Network's registry)++# TEXTFILE_DIR=<dir> node_exporter's textfile collector (tests)++#++# For each site: GET <origin>/auth/health with a 10 s timeout (openvibe-sdk ≥ 0.73.0, src/sso/index.js++# handlers.health). One textfile line per site, written to <TEXTFILE_DIR>/openvibe_auth_health.prom:++#++# openvibe_auth_health{site="<id>"} 1 200: the Network accepts the site's OAuth client++# openvibe_auth_health{site="<id>"} 0 503 (the Network refuses it), any other answer (a 500, a 502,++# a redirect) or no answer at all++# (no line) 404: the site is not on the SDK's sign-in yet; 401: its own++# sign-in gate answered (openvibe.ai's console is staff-only).++# Neither is the SDK's health handler, so neither is judged.++#++# The file is written atomically (a temp file beside it, then rename), so Prometheus never reads half of++# it. Exit: 0 when every probed site is 1 or 404 · 1 when one is 0 · 2 when the registry could not be++# read or held no public origin (the previous file is kept: an unreadable list is not a green canary).++set -uo pipefail++++REGISTRY=${AUTH_HEALTH_REGISTRY:-https://openvibe.network/api/v1/registry/services}++DIR=${TEXTFILE_DIR:-/var/lib/prometheus/node-exporter}++OUT=$DIR/openvibe_auth_health.prom++SITES=++TRIES=${AUTH_HEALTH_REGISTRY_TRIES:-3}++WAIT=${AUTH_HEALTH_REGISTRY_WAIT:-10}++++usage() { sed -n '2,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; }++++while [ $# -gt 0 ]; do++ case "$1" in++ --sites) SITES=${2:-}; shift 2 ;;++ --sites=*) SITES=${1#*=}; shift ;;++ --registry) REGISTRY=${2:-}; shift 2 ;;++ -h|--help) usage; exit 0 ;;++ *) echo "auth-health: unknown argument $1" >&2; exit 2 ;;++ esac++done++++command -v curl >/dev/null || { echo "auth-health: curl is needed" >&2; exit 2; }++command -v node >/dev/null || { echo "auth-health: node is needed" >&2; exit 2; }++++TMP=$(mktemp -d) || exit 2++trap 'rm -rf "$TMP"' EXIT++++# The list of sites: Network's registry, asked again a couple of times because a release of Network++# restarts it (scripts/browser-check.js does the same). A file is read as is.++if [[ $REGISTRY =~ ^https?:// ]]; then++ attempt=1++ while :; do++ if curl -fsS --max-time 20 -H 'accept: application/json' -o "$TMP/registry.json" "$REGISTRY" 2>/dev/null; then break; fi++ if [ "$attempt" -ge "$TRIES" ]; then++ echo "auth-health: the registry did not answer after $TRIES tries ($REGISTRY); keeping $OUT" >&2++ exit 2++ fi++ echo "auth-health: the registry did not answer (Network restarting?); asking again in ${WAIT}s" >&2++ sleep "$WAIT"++ attempt=$((attempt + 1))++ done++elif ! cp -- "$REGISTRY" "$TMP/registry.json" 2>/dev/null; then++ echo "auth-health: cannot read the registry file $REGISTRY; keeping $OUT" >&2++ exit 2++fi++++# id<TAB>origin, one line per service with a public origin. Both shapes Network has served are read: an++# array, or { "services": [...] }, with publicOrigin or public_origin. A trailing slash goes, and an id++# that is not a service id is skipped (it must be a Prometheus label value).++if ! node - "$TMP/registry.json" > "$TMP/sites" <<'JS'++const fs = require('fs');++let body;++try { body = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); } catch { process.stderr.write('auth-health: the registry is not JSON\n'); process.exit(1); }++const list = Array.isArray(body) ? body : (body && Array.isArray(body.services) ? body.services : []);++const seen = new Set();++for (const s of list) {++ if (!s || typeof s !== 'object') continue;++ const id = String(s.id || s.name || '').trim();++ const origin = String(s.publicOrigin || s.public_origin || '').trim().replace(/\/+$/, '');++ if (!/^[a-z][a-z0-9-]{1,39}$/.test(id)) continue;++ if (!/^https?:\/\/[^/?#]+$/.test(origin)) continue;++ if (seen.has(id)) continue;++ seen.add(id);++ process.stdout.write(`${id}\t${origin}\n`);++}++JS++then++ echo "auth-health: the registry is not JSON; keeping $OUT" >&2++ exit 2++fi++++if [ -n "$SITES" ]; then++ : > "$TMP/wanted"++ for s in ${SITES//,/ }; do echo "$s" >> "$TMP/wanted"; done++ awk -F'\t' 'NR==FNR { want[$1]=1; next } ($1 in want)' "$TMP/wanted" "$TMP/sites" > "$TMP/sites.wanted"++ mv "$TMP/sites.wanted" "$TMP/sites"++fi++++if [ ! -s "$TMP/sites" ]; then++ echo "auth-health: the registry named no site with a public origin; keeping $OUT" >&2++ exit 2++fi++++LINES=()++FAILED=0++PROBED=0++while IFS=$'\t' read -r id origin; do++ [ -n "$id" ] || continue++ code=$(curl -sS -o /dev/null --max-time 10 -w '%{http_code}' "$origin/auth/health" 2>/dev/null) || code=000++ case "$code" in++ 200) value=1 ;;++ 401|404) value= ;; # not the SDK's health handler: a staff gate, or a site not on the sign-in yet++ *) value=0 ;;++ esac++ [ -n "${value-}" ] || continue++ PROBED=$((PROBED + 1))++ [ "$value" = 1 ] || FAILED=$((FAILED + 1))++ LINES+=("openvibe_auth_health{site=\"$id\"} $value")++done < "$TMP/sites"++++if [ "$PROBED" -eq 0 ]; then++ echo "auth-health: no site answered 200, 503 or an error (all 404: none on the SDK's sign-in yet); keeping $OUT" >&2++ exit 0++fi++++if [ -d "$DIR" ]; then++ TMPFILE=$OUT.tmp.$$++ if ! {++ echo '# HELP openvibe_auth_health 1 when GET /auth/health answered 200 (the Network accepts the site OAuth client), 0 when it answered 503 or did not answer.'++ echo '# TYPE openvibe_auth_health gauge'++ printf '%s\n' "${LINES[@]}"++ } > "$TMPFILE"; then++ rm -f "$TMPFILE"++ echo "auth-health: could not write $TMPFILE" >&2++ exit 2++ fi++ chmod 0644 "$TMPFILE" || { rm -f "$TMPFILE"; echo "auth-health: could not chmod $TMPFILE" >&2; exit 2; }++ if ! mv -f "$TMPFILE" "$OUT"; then++ rm -f "$TMPFILE"++ echo "auth-health: could not rename $TMPFILE to $OUT" >&2++ exit 2++ fi++fi++++printf '%s\n' "${LINES[@]}"++++[ "$FAILED" -eq 0 ] || exit 1++exit 0nnew file mode 100644@@@ -0,0 +1,17 @@++[Unit]++Description=OpenVibe sign-in canary: GET /auth/health on every public site (docs/auth-health.md)++Documentation=https://github.com/OpenVibers/OpenVibe.Host/blob/main/docs/auth-health.md++After=network-online.target++Wants=network-online.target++++[Service]++Type=oneshot++# Root writes node_exporter's textfile collector; nothing else is touched.++User=root++ExecStart=/usr/local/lib/openvibe-host/deploy/scripts/auth-health.sh++Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin++Environment=OVHOST_INVENTORY=/etc/openvibe/host.json++Nice=10++TimeoutStartSec=10min++PrivateTmp=true++ProtectSystem=fullnnew file mode 100644@@@ -0,0 +1,10 @@++[Unit]++Description=Check that every OpenVibe site's sign-in still works, every 15 minutes (docs/auth-health.md)++++[Timer]++OnBootSec=5min++OnUnitActiveSec=15min++AccuracySec=30s++++[Install]++WantedBy=timers.targetnnew file mode 100644@@@ -0,0 +1,71 @@++# Sign-in canary++++A canary for every OpenVibe site's sign-in, on the production host. On 2026-10-29 openvibe.cam's OAuth++client secret was the `.env.example` placeholder for two days: nobody could sign in, and nothing noticed.++This notices.++++Sites on openvibe-sdk 0.73.0 or later answer `GET /auth/health` (`src/sso/index.js`, `handlers.health`):++they make one token request with a code no sign-in ever has, and read which way the Network refuses it.++`invalid_grant` means the client is fine, `invalid_client` that its id or secret is wrong. The answer is++cached for ten minutes in the site's process.++++| Answer | Means | Metric |++|---|---|---|++| 200 | the Network accepts the site's OAuth client | `openvibe_auth_health{site="<id>"} 1` |++| 503 | the Network refuses it (a wrong `OV_OAUTH_CLIENT_SECRET` or `OV_OAUTH_CLIENT_ID`) | `openvibe_auth_health{site="<id>"} 0` |++| any other status (a 500, a 502, a redirect), or no answer at all | a broken site or a broken path, not a healthy one | `openvibe_auth_health{site="<id>"} 0` |++| 404 | the site is not on the SDK's sign-in yet: nothing to judge | no line |++| 401 | the site's own sign-in gate answered: openvibe.ai's operator console is staff-only, so `/auth/health` is never the SDK's handler there | no line |++++## The run++++`deploy/scripts/auth-health.sh` (openvibe-authhealth.timer, every 15 minutes) asks OpenVibe.Network's++registry (`/api/v1/registry/services`, the same list `scripts/browser-check.js` uses) for every service++with a public origin, and gets `<origin>/auth/health` with a 10 s timeout, one site at a time. It writes++node_exporter's textfile collector in one rename (`/var/lib/prometheus/node-exporter/openvibe_auth_health.prom`),++so Prometheus never reads half a file, and prints the lines it wrote.++++- **The registry does not answer**: it asks up to three times, ten seconds apart (a Network release++ restarts it), then exits 2 and leaves the previous file alone. An unreadable list is not a green canary.++- **No collector directory**: it writes nothing and exits 0, so it is harmless off the host.++- **Exit code**: 0 every probed site is 1 or 404 · 1 one site is 0 · 2 the list could not be read.++- **Secrets**: it reads no env file and prints nothing from one. The metric is a site id and 0 or 1.++++After a deploy, or when checking one site now:++++```bash++sudo systemctl start openvibe-authhealth.service # every site, now++sudo ovhost deploy <service> # a deploy is followed by the timer within 15 minutes++deploy/scripts/auth-health.sh --sites live,cam # these sites, printing the lines++```++++## The alert++++`OpenVibeSignInBroken` in [deploy/prometheus/openvibe-rules.yml](../deploy/prometheus/openvibe-rules.yml)++fires on `openvibe_auth_health == 0` for 30 minutes, severity `ticket`: the owner's bell, not a push,++because the site still serves; only its sign-in is broken. It travels the usual path++([operator-alerts.md](operator-alerts.md)):++++> Sign-in is broken on cam: the Network refuses its OAuth client (check OV_OAUTH_CLIENT_SECRET in its env)++++The fix is the site's env file, never a change made by this check.++++## Install (once)++++```bash++sudo cp /usr/local/lib/openvibe-host/deploy/systemd/openvibe-authhealth.{service,timer} /etc/systemd/system/++sudo systemctl daemon-reload && sudo systemctl enable --now openvibe-authhealth.timer++sudo cp /usr/local/lib/openvibe-host/deploy/prometheus/openvibe-rules.yml /etc/prometheus/rules/openvibe.yml++sudo promtool check rules /etc/prometheus/rules/openvibe.yml && sudo systemctl reload prometheus++```++++The script lives in the `ovhost` CLI's own checkout (`/usr/local/lib/openvibe-host`), which++`sudo ovhost self-update` brings up to `main`; the unit runs it from there.++++## What it cannot see++++- A site on openvibe-sdk before 0.73.0 answers 404, and is silently outside the canary until its pin moves.++ So is a site whose own sign-in gate answers first, as openvibe.ai's staff console does (401).++- `/auth/health` tells a broken client secret from a working one. It does not finish a sign-in, so a++ broken callback URL, a Network that is down, or a site whose sessions cannot be verified still look fine.++- The check is one request per site per 15 minutes, from the host. It never signs anybody in.@@@ -3,7 +3,8 @@Roadmap WS-H task 11. The host's Prometheus evaluates the rules in[deploy/prometheus/openvibe-rules.yml](../deploy/prometheus/openvibe-rules.yml): targets down, errorrates, disk and memory, backups, deploy drift, the developer path, the Tools job proof, the browser check--of each release and the relay itself. Until 2026-09-26 nothing delivered them.++of each release, the sign-in canary of every site ([auth-health.md](auth-health.md)) and the relay itself.++Until 2026-09-26 nothing delivered them.## The path@@@ -76,7 +77,8 @@ systemctl list-timers openvibe-alerts.timer```bashsudo cp /usr/local/lib/openvibe-host/deploy/systemd/openvibe-alerts.{service,timer} /etc/systemd/system/sudo cp /usr/local/lib/openvibe-host/deploy/systemd/openvibe-browsercheck.{service,timer} /etc/systemd/system/--sudo systemctl daemon-reload && sudo systemctl enable --now openvibe-alerts.timer openvibe-browsercheck.timer++sudo cp /usr/local/lib/openvibe-host/deploy/systemd/openvibe-authhealth.{service,timer} /etc/systemd/system/++sudo systemctl daemon-reload && sudo systemctl enable --now openvibe-alerts.timer openvibe-browsercheck.timer openvibe-authhealth.timer# Chrome and the check account (once):curl -fsSL https://dl.google.com/linux/linux_signing_key.pub | sudo gpg --dearmor -o /usr/share/keyrings/google-chrome.gpgecho "deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome.gpg] https://dl.google.com/linux/chrome/deb/ stable main" | sudo tee /etc/apt/sources.list.d/google-chrome.listnnew file mode 100644@@@ -0,0 +1,183 @@++'use strict';++/**++ * The sign-in canary (deploy/scripts/auth-health.sh, docs/auth-health.md): every public origin in Network's++ * registry gets one GET /auth/health — 1 for 200, 0 for 503, another answer or no answer at all, and no++ * line for 404 (a site not on the SDK's sign-in yet) — and the textfile collector's file is replaced in++ * one rename. A registry it cannot read leaves the previous file alone, so a broken list is never green.++ * The script runs for real here; the sites and the registry are local HTTP servers.++ */++const assert = require('assert');++const fs = require('fs');++const http = require('http');++const os = require('os');++const path = require('path');++const { execFile } = require('child_process');++const { test, runTests } = require('./helpers');++++const SCRIPT = path.join(__dirname, '..', 'deploy', 'scripts', 'auth-health.sh');++const PROM = 'openvibe_auth_health.prom';++++function site(status) {++ const s = http.createServer((req, res) => {++ s.hits.push(req.url);++ res.statusCode = status;++ res.setHeader('content-type', 'application/json');++ res.end(JSON.stringify({ ok: status === 200, client: status === 200 ? 'ok' : 'invalid' }));++ });++ s.hits = [];++ return new Promise((resolve) => s.listen(0, '127.0.0.1', () => resolve(s)));++}++++const originOf = (s) => `http://127.0.0.1:${s.address().port}`;++++/** A port nothing listens on: the canary is asked to meet a site that does not answer. */++function closedPort() {++ return new Promise((resolve) => {++ const s = http.createServer();++ s.listen(0, '127.0.0.1', () => { const port = s.address().port; s.close(() => resolve(port)); });++ });++}++++function dir() {++ return fs.mkdtempSync(path.join(os.tmpdir(), 'auth-health-'));++}++++function registryFile(dirPath, body) {++ const file = path.join(dirPath, 'registry.json');++ fs.writeFileSync(file, typeof body === 'string' ? body : JSON.stringify(body));++ return file;++}++++// execFile, not spawnSync: the sites and the registry are servers in this process, and a blocked event++// loop could not answer them (the canary would meet four timeouts instead).++function run(textfileDir, registry, args = []) {++ return new Promise((resolve) => {++ execFile('bash', [SCRIPT, '--registry', registry, ...args], {++ timeout: 120000,++ env: { ...process.env, TEXTFILE_DIR: textfileDir, AUTH_HEALTH_REGISTRY_TRIES: '1', AUTH_HEALTH_REGISTRY_WAIT: '0' },++ }, (err, stdout, stderr) => resolve({ status: err ? (err.code == null ? null : err.code) : 0, stdout, stderr }));++ });++}++++const prom = (textfileDir) => {++ try { return fs.readFileSync(path.join(textfileDir, PROM), 'utf8'); } catch { return null; }++};++++runTests([++ test('every public origin is probed once; 200 is 1, 503 and no answer are 0, 404 is no line', async () => {++ const ok = await site(200);++ const refused = await site(503);++ const missing = await site(404);++ const broken = await site(500);++ const gate = await site(401);++ const dead = await closedPort();++ const textfile = dir();++ const registry = registryFile(textfile, {++ services: [++ { id: 'live', publicOrigin: originOf(ok) },++ { id: 'cam', public_origin: `${originOf(refused)}/` },++ { id: 'blog', publicOrigin: originOf(missing) },++ { id: 'chat', publicOrigin: originOf(broken) },++ { id: 'tools', publicOrigin: `http://127.0.0.1:${dead}` },++ { id: 'ai', publicOrigin: originOf(gate) },++ { id: 'contracts' },++ { id: 'Bad Id', publicOrigin: 'https://openvibe.example' },++ { id: 'noscheme', publicOrigin: 'openvibe.example' },++ { id: 'live', publicOrigin: originOf(ok) },++ ],++ });++ const r = await run(textfile, registry);++ for (const s of [ok, refused, missing, broken, gate]) s.close();++ assert.strictEqual(r.status, 1, r.stderr);++ assert.deepStrictEqual(ok.hits, ['/auth/health'], 'exactly one probe per site');++ const file = prom(textfile);++ assert.match(file, /^# TYPE openvibe_auth_health gauge$/m);++ assert.match(file, /^openvibe_auth_health\{site="live"\} 1$/m);++ assert.match(file, /^openvibe_auth_health\{site="cam"\} 0$/m, '503: the Network refuses its client');++ assert.ok(!/site="blog"/.test(file), '404: not on the SDK sign-in, no line');++ assert.ok(!/site="ai"/.test(file), '401: the site\'s own sign-in gate answered, no line');++ assert.match(file, /^openvibe_auth_health\{site="chat"\} 0$/m, 'another status is not a pass');++ assert.match(file, /^openvibe_auth_health\{site="tools"\} 0$/m, 'no answer is 0');++ assert.ok(!/site="Bad Id"/.test(file) && !/site="noscheme"/.test(file) && !/site="contracts"/.test(file));++ assert.strictEqual(file.match(/site="live"/g).length, 1, 'a repeated id keeps one line');++ assert.deepStrictEqual(r.stdout.trim().split('\n'), [++ 'openvibe_auth_health{site="live"} 1',++ 'openvibe_auth_health{site="cam"} 0',++ 'openvibe_auth_health{site="chat"} 0',++ 'openvibe_auth_health{site="tools"} 0',++ ]);++ assert.deepStrictEqual(fs.readdirSync(textfile).filter((f) => f !== 'registry.json'), [PROM], 'no temp file is left behind');++ }),++ test('a bare array is read too, --sites narrows a run, and an all-good list exits 0', async () => {++ const ok = await site(200);++ const other = await site(200);++ const textfile = dir();++ const registry = registryFile(textfile, [{ id: 'live', publicOrigin: originOf(ok) }, { id: 'blog', publicOrigin: originOf(other) }]);++ let r = await run(textfile, registry);++ assert.strictEqual(r.status, 0, r.stderr);++ assert.match(prom(textfile), /^openvibe_auth_health\{site="live"\} 1$/m);++ assert.match(prom(textfile), /^openvibe_auth_health\{site="blog"\} 1$/m);++ r = await run(textfile, registry, ['--sites', 'live']);++ assert.strictEqual(r.status, 0, r.stderr);++ assert.deepStrictEqual([ok.hits.length, other.hits.length], [2, 1], 'the filtered run leaves the other site alone');++ assert.ok(!/site="blog"/.test(prom(textfile)));++ ok.close();++ other.close();++ }),++ test('the registry is taken from a URL, and one that does not answer keeps the previous file', async () => {++ const ok = await site(200);++ const registryServer = await site(200);++ const registryBody = JSON.stringify({ services: [{ id: 'live', publicOrigin: originOf(ok) }] });++ registryServer.removeAllListeners('request');++ registryServer.on('request', (req, res) => { res.statusCode = 200; res.end(registryBody); });++ const textfile = dir();++ let r = await run(textfile, originOf(registryServer));++ assert.strictEqual(r.status, 0, r.stderr);++ assert.match(prom(textfile), /^openvibe_auth_health\{site="live"\} 1$/m);++ const before = prom(textfile);++ r = await run(textfile, 'http://127.0.0.1:1/api/v1/registry/services');++ assert.strictEqual(r.status, 2);++ assert.match(r.stderr, /the registry did not answer/);++ assert.strictEqual(prom(textfile), before, 'a registry that cannot be read is not a green canary');++ const deadServer = http.createServer((req, res) => { res.statusCode = 500; res.end('no'); });++ await new Promise((resolve) => deadServer.listen(0, '127.0.0.1', resolve));++ r = await run(textfile, `http://127.0.0.1:${deadServer.address().port}/api/v1/registry/services`);++ assert.strictEqual(r.status, 2);++ assert.strictEqual(prom(textfile), before, 'a 500 from the registry changes nothing');++ ok.close();++ registryServer.close();++ deadServer.close();++ }),++ test('a registry that is not JSON or names no site keeps the previous file', async () => {++ const ok = await site(200);++ const textfile = dir();++ fs.writeFileSync(path.join(textfile, PROM), 'openvibe_auth_health{site="live"} 1\n');++ let r = await run(textfile, path.join(textfile, 'absent.json'));++ assert.strictEqual(r.status, 2);++ assert.match(r.stderr, /cannot read the registry file/);++ r = await run(textfile, registryFile(textfile, '<html>502</html>'));++ assert.strictEqual(r.status, 2);++ assert.match(r.stderr, /not JSON/);++ r = await run(textfile, registryFile(textfile, { services: [{ id: 'live' }] }));++ assert.strictEqual(r.status, 2);++ assert.match(r.stderr, /no site with a public origin/);++ assert.strictEqual(prom(textfile), 'openvibe_auth_health{site="live"} 1\n');++ ok.close();++ }),++ test('the file is replaced in one rename; without the collector directory nothing is written', async () => {++ const ok = await site(200);++ const textfile = dir();++ const registry = registryFile(textfile, [{ id: 'live', publicOrigin: originOf(ok) }]);++ assert.strictEqual((await run(textfile, registry)).status, 0);++ const first = fs.statSync(path.join(textfile, PROM)).ino;++ assert.strictEqual((await run(textfile, registry)).status, 0);++ const second = fs.statSync(path.join(textfile, PROM)).ino;++ assert.notStrictEqual(second, first, 'a rename, not a rewrite in place');++ assert.ok(!fs.readdirSync(textfile).some((f) => f.endsWith('.tmp.' + process.pid) || f.startsWith(PROM + '.tmp')), 'no temp file');++ const absent = path.join(textfile, 'no-collector-here');++ const r = await run(absent, registry);++ assert.strictEqual(r.status, 0);++ assert.ok(!fs.existsSync(path.join(absent, PROM)), 'nothing to write to, nothing written');++ ok.close();++ }),++]);
@@ -15,7 +15,7 @@ jobs:
@@ -15,7 +15,7 @@ jobs:
test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'
test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'
contracts-src: lib server
contracts-src: lib server
docs-status: true
docs-status: true
- extra: node --check bin/ovhost && for f in scripts/*.sh; do bash -n "$f"; done
+ extra: node --check bin/ovhost && for f in scripts/*.sh deploy/scripts/*.sh; do bash -n "$f"; done
# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
security:
security:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@aa570e4815063dc65624a6a486038d83ff6f6ec0
uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@aa570e4815063dc65624a6a486038d83ff6f6ec0
@@ -293,7 +293,7 @@ Two things deploy from this repository:
@@ -293,7 +293,7 @@ Two things deploy from this repository:
`cd /usr/local/lib/openvibe-host && sudo git pull --ff-only && sudo npm ci --omit=dev --no-audit --no-fund`,
`cd /usr/local/lib/openvibe-host && sudo git pull --ff-only && sudo npm ci --omit=dev --no-audit --no-fund`,
then `ovhost --version` and `sudo ovhost capabilities` (ovhost does not deploy itself; an update is
then `ovhost --version` and `sudo ovhost capabilities` (ovhost does not deploy itself; an update is
undone by checking out the previous commit there). Its timers (`openvibe-backup`, `openvibe-alerts`, `openvibe-browsercheck`,
undone by checking out the previous commit there). Its timers (`openvibe-backup`, `openvibe-alerts`, `openvibe-browsercheck`,
- `openvibe-devpath`, `openvibe-toolsjob`) are unit files in [deploy/systemd/](deploy/systemd/).
+ `openvibe-authhealth`, `openvibe-devpath`, `openvibe-toolsjob`) are unit files in [deploy/systemd/](deploy/systemd/).
- **Boot order.** Three drop-ins in [deploy/systemd/](deploy/systemd/) order every `openvibe-*` and `openre-*`
- **Boot order.** Three drop-ins in [deploy/systemd/](deploy/systemd/) order every `openvibe-*` and `openre-*`
service after PostgreSQL, PgBouncer and Valkey (systemd prefix drop-ins, `openvibe-.service.d/`), and PgBouncer
service after PostgreSQL, PgBouncer and Valkey (systemd prefix drop-ins, `openvibe-.service.d/`), and PgBouncer
after PostgreSQL. Without them a reboot starts services against a PgBouncer that caches PostgreSQL's refused
after PostgreSQL. Without them a reboot starts services against a PgBouncer that caches PostgreSQL's refused
@@ -95,6 +95,16 @@ groups:
@@ -95,6 +95,16 @@ groups:
labels: { severity: ticket }
labels: { severity: ticket }
annotations:
annotations:
summary: "{{ $labels.service }}: no browser check has run to the end for more than 26 hours"
summary: "{{ $labels.service }}: no browser check has run to the end for more than 26 hours"
+ # The sign-in canary (deploy/scripts/auth-health.sh, openvibe-authhealth.timer; docs/auth-health.md):
+ # a public site answers GET /auth/health 200 while the Network accepts its OAuth client and 503 when
+ # it refuses it. A site not on the SDK's sign-in yet writes no line, so it never fires this. A ticket,
+ # not a page: sign-in is broken, the site still serves.
+ - alert: OpenVibeSignInBroken
+ expr: openvibe_auth_health == 0
+ for: 30m
+ labels: { severity: ticket }
+ annotations:
+ summary: "Sign-in is broken on {{ $labels.site }}: the Network refuses its OAuth client (check OV_OAUTH_CLIENT_SECRET in its env)"
# The relay itself (openvibe-alerts.timer; WS-H task 11). It cannot page while it is broken; it pages
# The relay itself (openvibe-alerts.timer; WS-H task 11). It cannot page while it is broken; it pages
# this as soon as a run gets through again, so an outage of the pager is never silent afterwards.
# this as soon as a run gets through again, so an outage of the pager is never silent afterwards.
- alert: OpenVibeAlertRelayFailing
- alert: OpenVibeAlertRelayFailing
new file mode 100755
new file mode 100755
@@ -0,0 +1,149 @@
@@ -0,0 +1,149 @@
+#!/usr/bin/env bash
+# Sign-in canary for every OpenVibe site (docs/auth-health.md, openvibe-authhealth.timer).
+#
+# deploy/scripts/auth-health.sh every public origin in Network's registry
+# deploy/scripts/auth-health.sh --sites live,cam only these (a post-deploy run)
+# AUTH_HEALTH_REGISTRY=<url|file> where the list comes from (default Network's registry)
+# TEXTFILE_DIR=<dir> node_exporter's textfile collector (tests)
+#
+# For each site: GET <origin>/auth/health with a 10 s timeout (openvibe-sdk ≥ 0.73.0, src/sso/index.js
+# handlers.health). One textfile line per site, written to <TEXTFILE_DIR>/openvibe_auth_health.prom:
+#
+# openvibe_auth_health{site="<id>"} 1 200: the Network accepts the site's OAuth client
+# openvibe_auth_health{site="<id>"} 0 503 (the Network refuses it), any other answer (a 500, a 502,
+# a redirect) or no answer at all
+# (no line) 404: the site is not on the SDK's sign-in yet; 401: its own
+# sign-in gate answered (openvibe.ai's console is staff-only).
+# Neither is the SDK's health handler, so neither is judged.
+#
+# The file is written atomically (a temp file beside it, then rename), so Prometheus never reads half of
+# it. Exit: 0 when every probed site is 1 or 404 · 1 when one is 0 · 2 when the registry could not be
+# read or held no public origin (the previous file is kept: an unreadable list is not a green canary).
+set -uo pipefail
+
+REGISTRY=${AUTH_HEALTH_REGISTRY:-https://openvibe.network/api/v1/registry/services}
+DIR=${TEXTFILE_DIR:-/var/lib/prometheus/node-exporter}
+OUT=$DIR/openvibe_auth_health.prom
+SITES=
+TRIES=${AUTH_HEALTH_REGISTRY_TRIES:-3}
+WAIT=${AUTH_HEALTH_REGISTRY_WAIT:-10}
+
+usage() { sed -n '2,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; }
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --sites) SITES=${2:-}; shift 2 ;;
+ --sites=*) SITES=${1#*=}; shift ;;
+ --registry) REGISTRY=${2:-}; shift 2 ;;
+ -h|--help) usage; exit 0 ;;
+ *) echo "auth-health: unknown argument $1" >&2; exit 2 ;;
+ esac
+done
+
+command -v curl >/dev/null || { echo "auth-health: curl is needed" >&2; exit 2; }
+command -v node >/dev/null || { echo "auth-health: node is needed" >&2; exit 2; }
+
+TMP=$(mktemp -d) || exit 2
+trap 'rm -rf "$TMP"' EXIT
+
+# The list of sites: Network's registry, asked again a couple of times because a release of Network
+# restarts it (scripts/browser-check.js does the same). A file is read as is.
+if [[ $REGISTRY =~ ^https?:// ]]; then
+ attempt=1
+ while :; do
+ if curl -fsS --max-time 20 -H 'accept: application/json' -o "$TMP/registry.json" "$REGISTRY" 2>/dev/null; then break; fi
+ if [ "$attempt" -ge "$TRIES" ]; then
+ echo "auth-health: the registry did not answer after $TRIES tries ($REGISTRY); keeping $OUT" >&2
+ exit 2
+ fi
+ echo "auth-health: the registry did not answer (Network restarting?); asking again in ${WAIT}s" >&2
+ sleep "$WAIT"
+ attempt=$((attempt + 1))
+ done
+elif ! cp -- "$REGISTRY" "$TMP/registry.json" 2>/dev/null; then
+ echo "auth-health: cannot read the registry file $REGISTRY; keeping $OUT" >&2
+ exit 2
+fi
+
+# id<TAB>origin, one line per service with a public origin. Both shapes Network has served are read: an
+# array, or { "services": [...] }, with publicOrigin or public_origin. A trailing slash goes, and an id
+# that is not a service id is skipped (it must be a Prometheus label value).
+if ! node - "$TMP/registry.json" > "$TMP/sites" <<'JS'
+const fs = require('fs');
+let body;
+try { body = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); } catch { process.stderr.write('auth-health: the registry is not JSON\n'); process.exit(1); }
+const list = Array.isArray(body) ? body : (body && Array.isArray(body.services) ? body.services : []);
+const seen = new Set();
+for (const s of list) {
+ if (!s || typeof s !== 'object') continue;
+ const id = String(s.id || s.name || '').trim();
+ const origin = String(s.publicOrigin || s.public_origin || '').trim().replace(/\/+$/, '');
+ if (!/^[a-z][a-z0-9-]{1,39}$/.test(id)) continue;
+ if (!/^https?:\/\/[^/?#]+$/.test(origin)) continue;
+ if (seen.has(id)) continue;
+ seen.add(id);
+ process.stdout.write(`${id}\t${origin}\n`);
+}
+JS
+then
+ echo "auth-health: the registry is not JSON; keeping $OUT" >&2
+ exit 2
+fi
+
+if [ -n "$SITES" ]; then
+ : > "$TMP/wanted"
+ for s in ${SITES//,/ }; do echo "$s" >> "$TMP/wanted"; done
+ awk -F'\t' 'NR==FNR { want[$1]=1; next } ($1 in want)' "$TMP/wanted" "$TMP/sites" > "$TMP/sites.wanted"
+ mv "$TMP/sites.wanted" "$TMP/sites"
+fi
+
+if [ ! -s "$TMP/sites" ]; then
+ echo "auth-health: the registry named no site with a public origin; keeping $OUT" >&2
+ exit 2
+fi
+
+LINES=()
+FAILED=0
+PROBED=0
+while IFS=$'\t' read -r id origin; do
+ [ -n "$id" ] || continue
+ code=$(curl -sS -o /dev/null --max-time 10 -w '%{http_code}' "$origin/auth/health" 2>/dev/null) || code=000
+ case "$code" in
+ 200) value=1 ;;
+ 401|404) value= ;; # not the SDK's health handler: a staff gate, or a site not on the sign-in yet
+ *) value=0 ;;
+ esac
+ [ -n "${value-}" ] || continue
+ PROBED=$((PROBED + 1))
+ [ "$value" = 1 ] || FAILED=$((FAILED + 1))
+ LINES+=("openvibe_auth_health{site=\"$id\"} $value")
+done < "$TMP/sites"
+
+if [ "$PROBED" -eq 0 ]; then
+ echo "auth-health: no site answered 200, 503 or an error (all 404: none on the SDK's sign-in yet); keeping $OUT" >&2
+ exit 0
+fi
+
+if [ -d "$DIR" ]; then
+ TMPFILE=$OUT.tmp.$$
+ if ! {
+ echo '# HELP openvibe_auth_health 1 when GET /auth/health answered 200 (the Network accepts the site OAuth client), 0 when it answered 503 or did not answer.'
+ echo '# TYPE openvibe_auth_health gauge'
+ printf '%s\n' "${LINES[@]}"
+ } > "$TMPFILE"; then
+ rm -f "$TMPFILE"
+ echo "auth-health: could not write $TMPFILE" >&2
+ exit 2
+ fi
+ chmod 0644 "$TMPFILE" || { rm -f "$TMPFILE"; echo "auth-health: could not chmod $TMPFILE" >&2; exit 2; }
+ if ! mv -f "$TMPFILE" "$OUT"; then
+ rm -f "$TMPFILE"
+ echo "auth-health: could not rename $TMPFILE to $OUT" >&2
+ exit 2
+ fi
+fi
+
+printf '%s\n' "${LINES[@]}"
+
+[ "$FAILED" -eq 0 ] || exit 1
+exit 0
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
+[Unit]
+Description=OpenVibe sign-in canary: GET /auth/health on every public site (docs/auth-health.md)
+Documentation=https://github.com/OpenVibers/OpenVibe.Host/blob/main/docs/auth-health.md
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=oneshot
+# Root writes node_exporter's textfile collector; nothing else is touched.
+User=root
+ExecStart=/usr/local/lib/openvibe-host/deploy/scripts/auth-health.sh
+Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+Environment=OVHOST_INVENTORY=/etc/openvibe/host.json
+Nice=10
+TimeoutStartSec=10min
+PrivateTmp=true
+ProtectSystem=full
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+[Unit]
+Description=Check that every OpenVibe site's sign-in still works, every 15 minutes (docs/auth-health.md)
+
+[Timer]
+OnBootSec=5min
+OnUnitActiveSec=15min
+AccuracySec=30s
+
+[Install]
+WantedBy=timers.target
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
+# Sign-in canary
+
+A canary for every OpenVibe site's sign-in, on the production host. On 2026-10-29 openvibe.cam's OAuth
+client secret was the `.env.example` placeholder for two days: nobody could sign in, and nothing noticed.
+This notices.
+
+Sites on openvibe-sdk 0.73.0 or later answer `GET /auth/health` (`src/sso/index.js`, `handlers.health`):
+they make one token request with a code no sign-in ever has, and read which way the Network refuses it.
+`invalid_grant` means the client is fine, `invalid_client` that its id or secret is wrong. The answer is
+cached for ten minutes in the site's process.
+
+| Answer | Means | Metric |
+|---|---|---|
+| 200 | the Network accepts the site's OAuth client | `openvibe_auth_health{site="<id>"} 1` |
+| 503 | the Network refuses it (a wrong `OV_OAUTH_CLIENT_SECRET` or `OV_OAUTH_CLIENT_ID`) | `openvibe_auth_health{site="<id>"} 0` |
+| any other status (a 500, a 502, a redirect), or no answer at all | a broken site or a broken path, not a healthy one | `openvibe_auth_health{site="<id>"} 0` |
+| 404 | the site is not on the SDK's sign-in yet: nothing to judge | no line |
+| 401 | the site's own sign-in gate answered: openvibe.ai's operator console is staff-only, so `/auth/health` is never the SDK's handler there | no line |
+
+## The run
+
+`deploy/scripts/auth-health.sh` (openvibe-authhealth.timer, every 15 minutes) asks OpenVibe.Network's
+registry (`/api/v1/registry/services`, the same list `scripts/browser-check.js` uses) for every service
+with a public origin, and gets `<origin>/auth/health` with a 10 s timeout, one site at a time. It writes
+node_exporter's textfile collector in one rename (`/var/lib/prometheus/node-exporter/openvibe_auth_health.prom`),
+so Prometheus never reads half a file, and prints the lines it wrote.
+
+- **The registry does not answer**: it asks up to three times, ten seconds apart (a Network release
+ restarts it), then exits 2 and leaves the previous file alone. An unreadable list is not a green canary.
+- **No collector directory**: it writes nothing and exits 0, so it is harmless off the host.
+- **Exit code**: 0 every probed site is 1 or 404 · 1 one site is 0 · 2 the list could not be read.
+- **Secrets**: it reads no env file and prints nothing from one. The metric is a site id and 0 or 1.
+
+After a deploy, or when checking one site now:
+
+```bash
+sudo systemctl start openvibe-authhealth.service # every site, now
+sudo ovhost deploy <service> # a deploy is followed by the timer within 15 minutes
+deploy/scripts/auth-health.sh --sites live,cam # these sites, printing the lines
+```
+
+## The alert
+
+`OpenVibeSignInBroken` in [deploy/prometheus/openvibe-rules.yml](../deploy/prometheus/openvibe-rules.yml)
+fires on `openvibe_auth_health == 0` for 30 minutes, severity `ticket`: the owner's bell, not a push,
+because the site still serves; only its sign-in is broken. It travels the usual path
+([operator-alerts.md](operator-alerts.md)):
+
+> Sign-in is broken on cam: the Network refuses its OAuth client (check OV_OAUTH_CLIENT_SECRET in its env)
+
+The fix is the site's env file, never a change made by this check.
+
+## Install (once)
+
+```bash
+sudo cp /usr/local/lib/openvibe-host/deploy/systemd/openvibe-authhealth.{service,timer} /etc/systemd/system/
+sudo systemctl daemon-reload && sudo systemctl enable --now openvibe-authhealth.timer
+sudo cp /usr/local/lib/openvibe-host/deploy/prometheus/openvibe-rules.yml /etc/prometheus/rules/openvibe.yml
+sudo promtool check rules /etc/prometheus/rules/openvibe.yml && sudo systemctl reload prometheus
+```
+
+The script lives in the `ovhost` CLI's own checkout (`/usr/local/lib/openvibe-host`), which
+`sudo ovhost self-update` brings up to `main`; the unit runs it from there.
+
+## What it cannot see
+
+- A site on openvibe-sdk before 0.73.0 answers 404, and is silently outside the canary until its pin moves.
+ So is a site whose own sign-in gate answers first, as openvibe.ai's staff console does (401).
+- `/auth/health` tells a broken client secret from a working one. It does not finish a sign-in, so a
+ broken callback URL, a Network that is down, or a site whose sessions cannot be verified still look fine.
+- The check is one request per site per 15 minutes, from the host. It never signs anybody in.
@@ -3,7 +3,8 @@
@@ -3,7 +3,8 @@
Roadmap WS-H task 11. The host's Prometheus evaluates the rules in
Roadmap WS-H task 11. The host's Prometheus evaluates the rules in
[deploy/prometheus/openvibe-rules.yml](../deploy/prometheus/openvibe-rules.yml): targets down, error
[deploy/prometheus/openvibe-rules.yml](../deploy/prometheus/openvibe-rules.yml): targets down, error
rates, disk and memory, backups, deploy drift, the developer path, the Tools job proof, the browser check
rates, disk and memory, backups, deploy drift, the developer path, the Tools job proof, the browser check
-of each release and the relay itself. Until 2026-09-26 nothing delivered them.
+of each release, the sign-in canary of every site ([auth-health.md](auth-health.md)) and the relay itself.
+Until 2026-09-26 nothing delivered them.
## The path
## The path
@@ -76,7 +77,8 @@ systemctl list-timers openvibe-alerts.timer
@@ -76,7 +77,8 @@ systemctl list-timers openvibe-alerts.timer
```bash
```bash
sudo cp /usr/local/lib/openvibe-host/deploy/systemd/openvibe-alerts.{service,timer} /etc/systemd/system/
sudo cp /usr/local/lib/openvibe-host/deploy/systemd/openvibe-alerts.{service,timer} /etc/systemd/system/
sudo cp /usr/local/lib/openvibe-host/deploy/systemd/openvibe-browsercheck.{service,timer} /etc/systemd/system/
sudo cp /usr/local/lib/openvibe-host/deploy/systemd/openvibe-browsercheck.{service,timer} /etc/systemd/system/
-sudo systemctl daemon-reload && sudo systemctl enable --now openvibe-alerts.timer openvibe-browsercheck.timer
+sudo cp /usr/local/lib/openvibe-host/deploy/systemd/openvibe-authhealth.{service,timer} /etc/systemd/system/
+sudo systemctl daemon-reload && sudo systemctl enable --now openvibe-alerts.timer openvibe-browsercheck.timer openvibe-authhealth.timer
# Chrome and the check account (once):
# Chrome and the check account (once):
curl -fsSL https://dl.google.com/linux/linux_signing_key.pub | sudo gpg --dearmor -o /usr/share/keyrings/google-chrome.gpg
curl -fsSL https://dl.google.com/linux/linux_signing_key.pub | sudo gpg --dearmor -o /usr/share/keyrings/google-chrome.gpg
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome.gpg] https://dl.google.com/linux/chrome/deb/ stable main" | sudo tee /etc/apt/sources.list.d/google-chrome.list
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome.gpg] https://dl.google.com/linux/chrome/deb/ stable main" | sudo tee /etc/apt/sources.list.d/google-chrome.list
new file mode 100644
new file mode 100644
@@ -0,0 +1,183 @@
@@ -0,0 +1,183 @@
+'use strict';
+/**
+ * The sign-in canary (deploy/scripts/auth-health.sh, docs/auth-health.md): every public origin in Network's
+ * registry gets one GET /auth/health — 1 for 200, 0 for 503, another answer or no answer at all, and no
+ * line for 404 (a site not on the SDK's sign-in yet) — and the textfile collector's file is replaced in
+ * one rename. A registry it cannot read leaves the previous file alone, so a broken list is never green.
+ * The script runs for real here; the sites and the registry are local HTTP servers.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const http = require('http');
+const os = require('os');
+const path = require('path');
+const { execFile } = require('child_process');
+const { test, runTests } = require('./helpers');
+
+const SCRIPT = path.join(__dirname, '..', 'deploy', 'scripts', 'auth-health.sh');
+const PROM = 'openvibe_auth_health.prom';
+
+function site(status) {
+ const s = http.createServer((req, res) => {
+ s.hits.push(req.url);
+ res.statusCode = status;
+ res.setHeader('content-type', 'application/json');
+ res.end(JSON.stringify({ ok: status === 200, client: status === 200 ? 'ok' : 'invalid' }));
+ });
+ s.hits = [];
+ return new Promise((resolve) => s.listen(0, '127.0.0.1', () => resolve(s)));
+}
+
+const originOf = (s) => `http://127.0.0.1:${s.address().port}`;
+
+/** A port nothing listens on: the canary is asked to meet a site that does not answer. */
+function closedPort() {
+ return new Promise((resolve) => {
+ const s = http.createServer();
+ s.listen(0, '127.0.0.1', () => { const port = s.address().port; s.close(() => resolve(port)); });
+ });
+}
+
+function dir() {
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'auth-health-'));
+}
+
+function registryFile(dirPath, body) {
+ const file = path.join(dirPath, 'registry.json');
+ fs.writeFileSync(file, typeof body === 'string' ? body : JSON.stringify(body));
+ return file;
+}
+
+// execFile, not spawnSync: the sites and the registry are servers in this process, and a blocked event
+// loop could not answer them (the canary would meet four timeouts instead).
+function run(textfileDir, registry, args = []) {
+ return new Promise((resolve) => {
+ execFile('bash', [SCRIPT, '--registry', registry, ...args], {
+ timeout: 120000,
+ env: { ...process.env, TEXTFILE_DIR: textfileDir, AUTH_HEALTH_REGISTRY_TRIES: '1', AUTH_HEALTH_REGISTRY_WAIT: '0' },
+ }, (err, stdout, stderr) => resolve({ status: err ? (err.code == null ? null : err.code) : 0, stdout, stderr }));
+ });
+}
+
+const prom = (textfileDir) => {
+ try { return fs.readFileSync(path.join(textfileDir, PROM), 'utf8'); } catch { return null; }
+};
+
+runTests([
+ test('every public origin is probed once; 200 is 1, 503 and no answer are 0, 404 is no line', async () => {
+ const ok = await site(200);
+ const refused = await site(503);
+ const missing = await site(404);
+ const broken = await site(500);
+ const gate = await site(401);
+ const dead = await closedPort();
+ const textfile = dir();
+ const registry = registryFile(textfile, {
+ services: [
+ { id: 'live', publicOrigin: originOf(ok) },
+ { id: 'cam', public_origin: `${originOf(refused)}/` },
+ { id: 'blog', publicOrigin: originOf(missing) },
+ { id: 'chat', publicOrigin: originOf(broken) },
+ { id: 'tools', publicOrigin: `http://127.0.0.1:${dead}` },
+ { id: 'ai', publicOrigin: originOf(gate) },
+ { id: 'contracts' },
+ { id: 'Bad Id', publicOrigin: 'https://openvibe.example' },
+ { id: 'noscheme', publicOrigin: 'openvibe.example' },
+ { id: 'live', publicOrigin: originOf(ok) },
+ ],
+ });
+ const r = await run(textfile, registry);
+ for (const s of [ok, refused, missing, broken, gate]) s.close();
+ assert.strictEqual(r.status, 1, r.stderr);
+ assert.deepStrictEqual(ok.hits, ['/auth/health'], 'exactly one probe per site');
+ const file = prom(textfile);
+ assert.match(file, /^# TYPE openvibe_auth_health gauge$/m);
+ assert.match(file, /^openvibe_auth_health\{site="live"\} 1$/m);
+ assert.match(file, /^openvibe_auth_health\{site="cam"\} 0$/m, '503: the Network refuses its client');
+ assert.ok(!/site="blog"/.test(file), '404: not on the SDK sign-in, no line');
+ assert.ok(!/site="ai"/.test(file), '401: the site\'s own sign-in gate answered, no line');
+ assert.match(file, /^openvibe_auth_health\{site="chat"\} 0$/m, 'another status is not a pass');
+ assert.match(file, /^openvibe_auth_health\{site="tools"\} 0$/m, 'no answer is 0');
+ assert.ok(!/site="Bad Id"/.test(file) && !/site="noscheme"/.test(file) && !/site="contracts"/.test(file));
+ assert.strictEqual(file.match(/site="live"/g).length, 1, 'a repeated id keeps one line');
+ assert.deepStrictEqual(r.stdout.trim().split('\n'), [
+ 'openvibe_auth_health{site="live"} 1',
+ 'openvibe_auth_health{site="cam"} 0',
+ 'openvibe_auth_health{site="chat"} 0',
+ 'openvibe_auth_health{site="tools"} 0',
+ ]);
+ assert.deepStrictEqual(fs.readdirSync(textfile).filter((f) => f !== 'registry.json'), [PROM], 'no temp file is left behind');
+ }),
+ test('a bare array is read too, --sites narrows a run, and an all-good list exits 0', async () => {
+ const ok = await site(200);
+ const other = await site(200);
+ const textfile = dir();
+ const registry = registryFile(textfile, [{ id: 'live', publicOrigin: originOf(ok) }, { id: 'blog', publicOrigin: originOf(other) }]);
+ let r = await run(textfile, registry);
+ assert.strictEqual(r.status, 0, r.stderr);
+ assert.match(prom(textfile), /^openvibe_auth_health\{site="live"\} 1$/m);
+ assert.match(prom(textfile), /^openvibe_auth_health\{site="blog"\} 1$/m);
+ r = await run(textfile, registry, ['--sites', 'live']);
+ assert.strictEqual(r.status, 0, r.stderr);
+ assert.deepStrictEqual([ok.hits.length, other.hits.length], [2, 1], 'the filtered run leaves the other site alone');
+ assert.ok(!/site="blog"/.test(prom(textfile)));
+ ok.close();
+ other.close();
+ }),
+ test('the registry is taken from a URL, and one that does not answer keeps the previous file', async () => {
+ const ok = await site(200);
+ const registryServer = await site(200);
+ const registryBody = JSON.stringify({ services: [{ id: 'live', publicOrigin: originOf(ok) }] });
+ registryServer.removeAllListeners('request');
+ registryServer.on('request', (req, res) => { res.statusCode = 200; res.end(registryBody); });
+ const textfile = dir();
+ let r = await run(textfile, originOf(registryServer));
+ assert.strictEqual(r.status, 0, r.stderr);
+ assert.match(prom(textfile), /^openvibe_auth_health\{site="live"\} 1$/m);
+ const before = prom(textfile);
+ r = await run(textfile, 'http://127.0.0.1:1/api/v1/registry/services');
+ assert.strictEqual(r.status, 2);
+ assert.match(r.stderr, /the registry did not answer/);
+ assert.strictEqual(prom(textfile), before, 'a registry that cannot be read is not a green canary');
+ const deadServer = http.createServer((req, res) => { res.statusCode = 500; res.end('no'); });
+ await new Promise((resolve) => deadServer.listen(0, '127.0.0.1', resolve));
+ r = await run(textfile, `http://127.0.0.1:${deadServer.address().port}/api/v1/registry/services`);
+ assert.strictEqual(r.status, 2);
+ assert.strictEqual(prom(textfile), before, 'a 500 from the registry changes nothing');
+ ok.close();
+ registryServer.close();
+ deadServer.close();
+ }),
+ test('a registry that is not JSON or names no site keeps the previous file', async () => {
+ const ok = await site(200);
+ const textfile = dir();
+ fs.writeFileSync(path.join(textfile, PROM), 'openvibe_auth_health{site="live"} 1\n');
+ let r = await run(textfile, path.join(textfile, 'absent.json'));
+ assert.strictEqual(r.status, 2);
+ assert.match(r.stderr, /cannot read the registry file/);
+ r = await run(textfile, registryFile(textfile, '<html>502</html>'));
+ assert.strictEqual(r.status, 2);
+ assert.match(r.stderr, /not JSON/);
+ r = await run(textfile, registryFile(textfile, { services: [{ id: 'live' }] }));
+ assert.strictEqual(r.status, 2);
+ assert.match(r.stderr, /no site with a public origin/);
+ assert.strictEqual(prom(textfile), 'openvibe_auth_health{site="live"} 1\n');
+ ok.close();
+ }),
+ test('the file is replaced in one rename; without the collector directory nothing is written', async () => {
+ const ok = await site(200);
+ const textfile = dir();
+ const registry = registryFile(textfile, [{ id: 'live', publicOrigin: originOf(ok) }]);
+ assert.strictEqual((await run(textfile, registry)).status, 0);
+ const first = fs.statSync(path.join(textfile, PROM)).ino;
+ assert.strictEqual((await run(textfile, registry)).status, 0);
+ const second = fs.statSync(path.join(textfile, PROM)).ino;
+ assert.notStrictEqual(second, first, 'a rename, not a rewrite in place');
+ assert.ok(!fs.readdirSync(textfile).some((f) => f.endsWith('.tmp.' + process.pid) || f.startsWith(PROM + '.tmp')), 'no temp file');
+ const absent = path.join(textfile, 'no-collector-here');
+ const r = await run(absent, registry);
+ assert.strictEqual(r.status, 0);
+ assert.ok(!fs.existsSync(path.join(absent, PROM)), 'nothing to write to, nothing written');
+ ok.close();
+ }),
+]);