openvibers/openvibe.host Commits

No 502s during a deploy: ov-socketize, the restart page, and a validate warning (#215)

OP OpenVibers committed 13 days ago · 0e3b1565eb · 6 files, +248 −0

Changes

ddiff --git a/deploy/nginx/snippets/ov-down.conf b/deploy/nginx/snippets/ov-down.conf
nnew file mode 100644
iindex 0000000..62b1c6d
---- /dev/null
++++ b/deploy/nginx/snippets/ov-down.conf
@@@ -0,0 +1,15 @@
++# OpenVibe: a friendly page while a service restarts or is down, instead of Cloudflare's 502 page (owner, 2026-10-31).
++#
++# Only nginx's own 502 and 504 (the upstream refused the connection or timed out) are caught: responses the app sends
++# itself pass through as they are (proxy_intercept_errors stays off). They become a 503 with Retry-After, because
++# Cloudflare shows an origin's 503 page as it is but swaps an origin's 502 and 504 for its own error page.
++# The page (/var/www/openvibe-down/down.html) has no outside assets, and reloads itself once the site answers again.
++# Included at server level from security-txt.conf, so every vhost that serves security.txt gets it.
++error_page 502 504 =503 @ov_down;
++location @ov_down {
++ root /var/www/openvibe-down;
++ add_header Retry-After 5 always;
++ add_header Cache-Control "no-store" always;
++ add_header X-OpenVibe-Down "1" always;
++ rewrite ^ /down.html break;
++}
ddiff --git a/deploy/www/openvibe-down/down.html b/deploy/www/openvibe-down/down.html
nnew file mode 100644
iindex 0000000..9af498f
---- /dev/null
++++ b/deploy/www/openvibe-down/down.html
@@@ -0,0 +1,71 @@
++<!doctype html>
++<html lang="en">
++<head>
++<meta charset="utf-8">
++<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
++<meta name="robots" content="noindex">
++<meta name="color-scheme" content="dark light">
++<title>Back in a moment · OpenVibe</title>
++<style>
++ :root { --bg: #0a0f1c; --card: #111a2b; --ink: #eef1f6; --ink2: #a9b4c8; --mute: #8c99b0; --line: #1c2840; --acc: #ff7a1a; }
++ @media (prefers-color-scheme: light) { :root { --bg: #f7f7f8; --card: #ffffff; --ink: #1a1a2e; --ink2: #4a4a6a; --mute: #68687b; --line: #e0e0e6; --acc: #e8590c; } }
++ * { box-sizing: border-box; }
++ html, body { height: 100%; }
++ body { margin: 0; display: grid; place-items: center; background: var(--bg); color: var(--ink);
++ font: 16px/1.5 Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
++ padding: max(24px, env(safe-area-inset-top)) max(20px, env(safe-area-inset-right)) max(24px, env(safe-area-inset-bottom)) max(20px, env(safe-area-inset-left)); }
++ main { width: 100%; max-width: 420px; text-align: center; }
++ .mark { width: 64px; height: 64px; margin: 0 auto 22px; position: relative; }
++ .mark::before { content: ""; position: absolute; inset: 0; border-radius: 20px; background: radial-gradient(circle at 35% 30%, #ffb36b, var(--acc) 60%, #c2410c); box-shadow: 0 18px 40px -16px rgba(255, 122, 26, .55); }
++ .mark::after { content: ""; position: absolute; inset: 18px; border-radius: 50%; border: 4px solid rgba(255, 255, 255, .92); border-top-color: transparent; animation: spin 1.1s linear infinite; }
++ @keyframes spin { to { transform: rotate(360deg); } }
++ @media (prefers-reduced-motion: reduce) { .mark::after { animation: none; border-top-color: rgba(255, 255, 255, .92); } }
++ h1 { margin: 0 0 8px; font-size: 1.5rem; line-height: 1.2; letter-spacing: -.01em; text-wrap: balance; }
++ p { margin: 0 auto; color: var(--ink2); max-width: 34ch; }
++ .card { margin-top: 22px; padding: 14px 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--card); color: var(--mute); font-size: .88rem; }
++ .card b { color: var(--ink); font-weight: 650; }
++ .row { margin-top: 18px; display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }
++ a, button { font: inherit; }
++ .btn { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: 0 18px; border-radius: 12px; border: 1px solid var(--line); background: var(--card); color: var(--ink); font-weight: 650; text-decoration: none; cursor: pointer; }
++ .btn.acc { background: var(--acc); border-color: var(--acc); color: #0a0f1c; }
++ .host { font-variant-numeric: tabular-nums; }
++</style>
++</head>
++<body>
++<main>
++ <div class="mark" aria-hidden="true"></div>
++ <h1>Back in a moment</h1>
++ <p><span class="host" id="host">This site</span> is restarting, usually for an update. It takes a few seconds.</p>
++ <div class="card" role="status" aria-live="polite" id="status">This page reloads by itself as soon as the site is back.</div>
++ <div class="row">
++ <button class="btn acc" type="button" id="retry">Try now</button>
++ <a class="btn" href="https://openvibe.network/">OpenVibe home</a>
++ </div>
++</main>
++<script>
++(function () {
++ var host = location.hostname.replace(/^www\./, '');
++ document.getElementById('host').textContent = host || 'This site';
++ var status = document.getElementById('status');
++ var tries = 0;
++ function check() {
++ tries++;
++ // A HEAD of this same address: anything but 502/503/504 means the service answers again.
++ fetch(location.href, { method: 'HEAD', cache: 'no-store', credentials: 'same-origin' }).then(function (r) {
++ if (r.status !== 502 && r.status !== 503 && r.status !== 504) { location.reload(); return; }
++ next();
++ }, next);
++ }
++ function next() {
++ var wait = Math.min(15000, 2000 + tries * 1000);
++ if (tries > 4) status.innerHTML = 'Still restarting. Checking again in <b>' + Math.round(wait / 1000) + ' s</b>.';
++ if (tries > 40) { status.textContent = 'This is taking longer than usual. The team has been told; try again in a few minutes.'; return; }
++ setTimeout(check, wait);
++ }
++ document.getElementById('retry').addEventListener('click', function () { location.reload(); });
++ setTimeout(check, 2000);
++})();
++</script>
++<noscript><meta http-equiv="refresh" content="8"></noscript>
++</body>
++</html>
ddiff --git a/docs/no-502.md b/docs/no-502.md
nnew file mode 100644
iindex 0000000..3afac31
---- /dev/null
++++ b/docs/no-502.md
@@@ -0,0 +1,47 @@
++# No 502s during a deploy (2026-10-31)
++
++On 2026-10-31 the owner opened openvibe.codes/auth/login in the same second a Codes deploy restarted the service, and
++got Cloudflare's "Bad gateway". The disk was fine (80%). The cause was the restart: Codes closed its port, nginx could
++not connect, and nginx answered 502. On that day 45 of the 50 services restarted this way. Every deploy, and so every
++Shared pin round, gave each site a window of 502s lasting from 1 to 7 seconds.
++
++There are two fixes, and both apply to every site.
++
++## 1. The listener outlives the restart: socket activation
++
++The service's server listens on the descriptor systemd hands it when it is socket-activated:
++
++```js
++const socketActivated = process.env.LISTEN_FDS === '1' && Number(process.env.LISTEN_PID) === process.pid;
++const server = app.listen(...(socketActivated ? [{ fd: 3 }] : [config.port, config.host]), () => { … });
++```
++
++Without a socket unit, the service listens on its port as before, so the code can be deployed first.
++
++Then, on the host, `scripts/ov-socketize.sh <service>` (installed as `/usr/local/sbin/ov-socketize`) switches the
++service over:
++1. It writes `<name>.socket` on `127.0.0.1:<port>` (loopback only; with no `PartOf=`, the socket outlives every
++ restart) and the drop-in `<unit>.d/socket.conf` (`Requires`/`After` on the socket, plus `NonBlocking=true`).
++2. It stops the service, starts the socket, then starts the service. It checks that systemd (pid 1) holds the port,
++ and records `socketUnit` in host.json, after backing host.json up to /root.
++3. **The proof:** it sends 40 requests while it restarts the service, and none may be refused. Codes passed on
++ 2026-10-31 with 0 of 40 refused.
++
++`ovhost validate` warns about any service that has a port but no socket unit.
++
++## 2. If a site is down anyway: our page, not Cloudflare's
++
++`deploy/nginx/snippets/ov-down.conf` turns nginx's own 502 and 504 (the upstream refused the connection or timed
++out) into a **503** with `Retry-After: 5`. It serves `deploy/www/openvibe-down/down.html` (installed in
++`/var/www/openvibe-down/`): "Back in a moment", with the site's name. The page reloads itself as soon as the site
++answers.
++
++- **Why 503:** Cloudflare passes an origin's 503 page through, but replaces an origin's 502 or 504 with its own
++ branded error page.
++- **What it does not catch:** responses the app sends itself pass through unchanged (`proxy_intercept_errors` stays
++ off).
++- **No outside assets:** the page loads nothing from anywhere, because the site it stands in for is down.
++
++On the host the snippet is included from `/etc/nginx/snippets/security-txt.conf`, which every proxying vhost already
++includes. The two API vhosts, `bench-render` and `s3.openvibe.media`, do not include it on purpose: an HTML page would
++be wrong for their clients.
ddiff --git a/lib/commands/validate.js b/lib/commands/validate.js
iindex cc72cfe..42bfa17 100644
---- a/lib/commands/validate.js
++++ b/lib/commands/validate.js
@@@ -131,6 +131,12 @@ async function validate(ctx, id, opts = {}) {
add('warn', 'units', `${svc.socketUnit} has PartOf=${st.partOf.join(' ')}: systemd propagates a restart of that service to the socket (ovhost itself never stops or restarts it)`);
}
}
++ // A service that serves HTTP on a port without a socket unit closes that port for its whole restart, and nginx
++ // answers 502 to everything in between (2026-10-31: a Codes deploy gave the owner a Cloudflare 502). The fix is
++ // the socket-activation lines in its server plus scripts/ov-socketize.sh on the host.
++ if (svc.port && !svc.socketUnit) {
++ add('warn', 'units', `${svc.units[0] || svc.id} has no socket unit: a restart refuses connections on :${svc.port} (nginx answers 502, the visitor sees the restart page). Switch it over with scripts/ov-socketize.sh ${svc.id} once its server listens on fd 3`);
++ }
for (const [unit, src] of Object.entries(svc.unitSources)) {
const want = await exec.readFile(path.join(svc.codeDir, src));
if (want == null) { add('warn', 'units', `unit source ${src} not in the checkout`); continue; }
ddiff --git a/scripts/ov-socketize.sh b/scripts/ov-socketize.sh
nnew file mode 100755
iindex 0000000..ba88116
---- /dev/null
++++ b/scripts/ov-socketize.sh
@@@ -0,0 +1,82 @@
++#!/usr/bin/env bash
++# ov-socketize.sh <service> : switch one ovhost service over to systemd socket activation, on the host, as root.
++#
++# Why (2026-10-31): a service without a .socket unit closes its port for its whole restart, and nginx answers 502 to
++# every request in that window (a Codes deploy gave the owner a Cloudflare 502). With the listener held by systemd,
++# requests wait in the kernel's accept queue and the new process serves them. The service's code must already listen
++# on fd 3 when LISTEN_FDS=1 (the socketActivated lines); this refuses otherwise.
++#
++# Steps: write <name>.socket (127.0.0.1:<port>, loopback only) and <unit>.d/socket.conf, daemon-reload, enable the
++# socket, stop the service, start the socket, start the service, check that systemd (pid 1) holds the port and the
++# ready URL answers, record socketUnit in /etc/openvibe/host.json (backed up first), then prove it: 40 requests through
++# a restart, none refused.
++set -euo pipefail
++svc=${1:?service}
++HJ=/etc/openvibe/host.json
++read -r unit port repo ready < <(node -e '
++const j = require(process.argv[1]); const s = j.services[process.argv[2]];
++if (!s) { console.error("no such service"); process.exit(2); }
++console.log([s.units[0], s.port, s.repo, (s.ready && s.ready.url) || ""].join(" "));' "$HJ" "$svc")
++[ -n "$port" ] && [ "$port" != "undefined" ] || { echo "$svc: no port in host.json"; exit 2; }
++base=${unit%.service}
++sock=$base.socket
++code="$repo/server/index.js"; [ -L "$repo/current" ] && code="$repo/current/server/index.js"
++grep -q 'socketActivated' "$code" || { echo "$svc: $code does not listen on fd 3 yet (deploy the socket-activation PR first)"; exit 3; }
++if [ "$(node -e 'const s=require(process.argv[1]).services[process.argv[2]];console.log(s.socketUnit||"")' "$HJ" "$svc")" = "$sock" ] && \
++ ss -ltnp "sport = :$port" | grep -q 'systemd'; then echo "$svc: already socket-activated ($sock holds :$port)"; exit 0; fi
++
++cat > "/etc/systemd/system/$sock" <<EOF
++# $base: listening socket owned by systemd, not by the process (written by ov-socketize.sh, 2026-10-31).
++# Requests that arrive while the service restarts wait in the kernel's accept queue instead of being refused
++# (nginx would answer 502). No PartOf=$unit: the socket must outlive every restart of the service.
++[Unit]
++Description=$base HTTP socket
++
++[Socket]
++# Loopback only: nginx is the only client (the host firewall is off, so 0.0.0.0 would be public).
++ListenStream=127.0.0.1:$port
++NoDelay=true
++Backlog=511
++
++[Install]
++WantedBy=sockets.target
++EOF
++mkdir -p "/etc/systemd/system/$unit.d"
++cat > "/etc/systemd/system/$unit.d/socket.conf" <<EOF
++# Bind $unit to the socket unit that owns its listener (ov-socketize.sh, 2026-10-31).
++[Unit]
++Requires=$sock
++After=$sock
++
++[Service]
++# Node's libuv expects a non-blocking descriptor (a [Service] option; in [Socket] systemd ignores it).
++NonBlocking=true
++EOF
++systemctl daemon-reload
++systemctl enable "$sock" >/dev/null 2>&1
++systemctl stop "$unit"
++systemctl start "$sock"
++systemctl start "$unit"
++for i in $(seq 1 30); do
++ if [ -n "$ready" ]; then code_=$(curl -s -o /dev/null -w '%{http_code}' -m 3 "$ready" || true); else code_=$(curl -s -o /dev/null -w '%{http_code}' -m 3 "http://127.0.0.1:$port/" || true); fi
++ [ "$code_" != "000" ] && [ "${code_:0:1}" != "5" ] && break
++ sleep 1
++done
++ss -ltnp "sport = :$port" | grep -q 'systemd' || { echo "$svc: systemd does NOT hold :$port after the switch-over"; exit 4; }
++systemctl is-active --quiet "$unit" || { echo "$svc: $unit is not active"; exit 5; }
++
++cp -p "$HJ" "/root/host.json.bak-$(date -u +%Y%m%dT%H%M%SZ)-$svc"
++node -e '
++const fs = require("fs"); const f = process.argv[1]; const j = JSON.parse(fs.readFileSync(f, "utf8"));
++j.services[process.argv[2]].socketUnit = process.argv[3];
++fs.writeFileSync(f + ".tmp", JSON.stringify(j, null, 2) + "\n"); fs.renameSync(f + ".tmp", f);' "$HJ" "$svc" "$sock"
++
++# The proof: requests straight to the port while the service restarts; none may be refused.
++url=${ready:-http://127.0.0.1:$port/}
++bad=0
++( for i in $(seq 1 40); do c=$(curl -s -o /dev/null -w '%{http_code}' -m 20 "$url" || echo 000); [ "$c" = "000" ] && echo refused; sleep 0.15; done ) > /tmp/ovsock.$$ &
++p=$!
++sleep 0.5; systemctl restart "$unit"; wait $p
++bad=$(grep -c refused /tmp/ovsock.$$ || true); rm -f /tmp/ovsock.$$
++echo "$svc: socket-activated ($sock holds :$port); restart proof: $bad of 40 refused"
++[ "$bad" = "0" ]
ddiff --git a/test/validate.test.js b/test/validate.test.js
iindex 9c62d47..e07f96f 100644
---- a/test/validate.test.js
++++ b/test/validate.test.js
@@@ -94,6 +94,33 @@ runTests([
noSecrets(r.out);
}),
++ test('a service with a port and no socket unit is pointed out: its restarts answer 502 (2026-10-31)', async () => {
++ const host = scenario();
++ const raw = JSON.parse(host.read('/etc/openvibe/host.json'));
++ assert.ok(raw.services.live.port && raw.services.live.socketUnit, 'the fixture has a socket-activated service');
++ const r1 = JSON.parse((await host.cli('validate', 'live', '--json')).out);
++ assert.ok(!r1.findings.some((f) => /has no socket unit/.test(f.message)), 'a socket-activated service is not flagged');
++ delete raw.services.live.socketUnit;
++ host.put('/etc/openvibe/host.json', JSON.stringify(raw), { mode: 0o640 });
++ const r2 = JSON.parse((await host.cli('validate', 'live', '--json')).out);
++ const w = r2.findings.find((f) => /has no socket unit/.test(f.message));
++ assert.ok(w && w.level === 'warn' && /ov-socketize\.sh live/.test(w.message), 'without one it is a warning naming the fix');
++ }),
++
++ test('the restart page: nginx turns its own 502 and 504 into a 503 page with no outside assets', () => {
++ const fs = require('fs');
++ const path = require('path');
++ const conf = fs.readFileSync(path.join(__dirname, '../deploy/nginx/snippets/ov-down.conf'), 'utf8');
++ assert.match(conf, /^error_page 502 504 =503 @ov_down;$/m, 'Cloudflare shows an origin 503 as it is, but swaps a 502 or 504 for its own page');
++ assert.match(conf, /add_header Retry-After 5 always;/);
++ assert.ok(!/proxy_intercept_errors\s+on/.test(conf), 'the apps\' own error answers pass through');
++ const page = fs.readFileSync(path.join(__dirname, '../deploy/www/openvibe-down/down.html'), 'utf8');
++ const outside = [...page.matchAll(/(?:src|href)="(https?:[^"]+)"/g)].map((m) => m[1]);
++ assert.deepStrictEqual(outside, ['https://openvibe.network/'], 'one plain link out, no assets from anywhere: the site it stands in for is down');
++ assert.match(page, /location\.reload\(\)/, 'it reloads itself once the site answers');
++ assert.match(page, /<meta name="robots" content="noindex">/);
++ }),
++
test('required: "from-example" takes every name the checkout .env.example declares', async () => {
const host = scenario();
host.put('/etc/openvibe/media.env', `PORT=4100\nMEDIA_B2_APP_KEY=${SECRET}\n`, { mode: 0o600 });