openvibers/openvibe.host Commits
No 502s during a deploy: ov-socketize, the restart page, and a validate warning (#215)
Changes
nnew file mode 100644@@@ -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;++}nnew file mode 100644@@@ -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>nnew file mode 100644@@@ -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.@@@ -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; }nnew file mode 100755@@@ -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" ]@@@ -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 });
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -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;
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -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>
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -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.
@@ -131,6 +131,12 @@ async function validate(ctx, id, opts = {}) {
@@ -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)`);
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)) {
for (const [unit, src] of Object.entries(svc.unitSources)) {
const want = await exec.readFile(path.join(svc.codeDir, src));
const want = await exec.readFile(path.join(svc.codeDir, src));
if (want == null) { add('warn', 'units', `unit source ${src} not in the checkout`); continue; }
if (want == null) { add('warn', 'units', `unit source ${src} not in the checkout`); continue; }
new file mode 100755
new file mode 100755
@@ -0,0 +1,82 @@
@@ -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" ]
@@ -94,6 +94,33 @@ runTests([
@@ -94,6 +94,33 @@ runTests([
noSecrets(r.out);
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 () => {
test('required: "from-example" takes every name the checkout .env.example declares', async () => {
const host = scenario();
const host = scenario();
host.put('/etc/openvibe/media.env', `PORT=4100\nMEDIA_B2_APP_KEY=${SECRET}\n`, { mode: 0o600 });
host.put('/etc/openvibe/media.env', `PORT=4100\nMEDIA_B2_APP_KEY=${SECRET}\n`, { mode: 0o600 });