openvibers/openvibe.live Commits

Socket activation that stays in effect, on loopback: the release layout's socket unit still listened on 0.0.0.0:3000 with NonBlocking= in [Socket] (ignored by systemd); installing it on 2026-09-25 dropped the socket's descriptor, so the service bound 0.0.0.0:3000 itself: Live answered on the public IP past Cloudflare and nginx, and each restart refused connections (four 502s in the WS-P task 3 drain probe). Both unit copies are now the loopback one, NonBlocking= moved to the service drop-in; deploy.sh checks that systemd really holds the listener (not just is-active) and rebinds a changed or dropped socket (stop, restart socket, start); without LISTEN_FDS the process binds 127.0.0.1 in production (LISTEN_HOST overrides; HOST still builds URLs). test/systemd-units.test.js. Production was repaired by hand first (systemd holds 127.0.0.1:3000, the public port is closed)

OP OpenVibers committed 26 Sept 2026 · ab8abff6dc · 8 files, +105 −11


Changes

ddiff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh
iindex 42e03b4e..030b2866 100755
---- a/deploy/scripts/deploy.sh
++++ b/deploy/scripts/deploy.sh
@@@ -55,6 +55,8 @@ BASE_DIR="${BASE_DIR:-/opt/openvibe.live}"
SERVICE="${SERVICE:-openvibe-live}"
SYSTEMD_DIR="${SYSTEMD_DIR:-/etc/systemd/system}"
SYSTEMCTL="${SYSTEMCTL:-systemctl}"
++SS="${SS:-ss}"
++SOCKET_CHANGED=false
# Unit files land in /etc: when systemctl is being run through sudo, so is the install.
SUDO_INSTALL=""; case "$SYSTEMCTL" in sudo*) SUDO_INSTALL="sudo";; esac; [ "$(id -u)" -eq 0 ] && SUDO_INSTALL=""
SITE_URL="${SITE_URL:-https://openvibe.live}"
@@@ -169,7 +171,14 @@ install_units() {
fi
}
install_unit "$unit_dir/${SERVICE}.service" "$SYSTEMD_DIR/${SERVICE}.service"
++ local socket_before=false
++ [ -f "$SYSTEMD_DIR/${SERVICE}.socket" ] && [ -f "$unit_dir/${SERVICE}.socket" ] && ! cmp -s "$unit_dir/${SERVICE}.socket" "$SYSTEMD_DIR/${SERVICE}.socket" && socket_before=true
install_unit "$unit_dir/${SERVICE}.socket" "$SYSTEMD_DIR/${SERVICE}.socket"
++ # A socket unit changed under a running socket: after daemon-reload systemd may drop its descriptor
++ # ("no socket file descriptors are open ... not functional until restarted"); restart_and_verify then
++ # restarts the socket itself. Left alone, the service binds the port on its own and every later
++ # restart refuses connections (what happened from 2026-09-25 to 2026-09-26).
++ [ "$socket_before" = true ] && SOCKET_CHANGED=true
install_unit "$unit_dir/${SERVICE}.service.d/socket.conf" "$SYSTEMD_DIR/${SERVICE}.service.d/socket.conf"
if [ "$changed" = true ]; then
run $SYSTEMCTL daemon-reload
@@@ -205,18 +214,42 @@ backup_db() {
fi
}
++# Does systemd (pid 1) hold the socket unit's listener? `is-active` is not enough: a socket unit whose
++# descriptor was dropped still reads active while the service binds the port itself.
++socket_held() {
++ local listen port
++ listen=$($SYSTEMCTL show "${SERVICE}.socket" -p Listen --value 2>/dev/null | head -1 | awk '{print $1}')
++ port=${listen##*:}
++ [ -n "$port" ] || return 1
++ $SS -Hltnp "sport = :$port" 2>/dev/null | grep -q '"systemd",pid=1,'
++}
++
restart_and_verify() {
-- local on_fail="$1"
++ local on_fail="$1" socketed=false
if $SYSTEMCTL list-unit-files "${SERVICE}.socket" >/dev/null 2>&1 && $SYSTEMCTL is-enabled --quiet "${SERVICE}.socket" 2>/dev/null; then
++ socketed=true
$SYSTEMCTL is-active --quiet "${SERVICE}.socket" || $SYSTEMCTL start "${SERVICE}.socket" || true
-- say "socket unit active — new HTTP connections queue through the restart"
fi
-- say "restarting ${SERVICE}…"
local start; start=$(date +%s)
-- $SYSTEMCTL restart "$SERVICE" 9>&-
++ if [ "$socketed" = true ] && { [ "$SOCKET_CHANGED" = true ] || ! socket_held; }; then
++ # The socket must be rebound: stop the service (it may hold the port itself), restart the
++ # socket, start the service on it. HTTP is refused for that moment, once.
++ say "socket unit $([ "$SOCKET_CHANGED" = true ] && echo changed || echo 'is not holding its listener') — stopping ${SERVICE}, restarting the socket"
++ $SYSTEMCTL stop "$SERVICE" 9>&- || true
++ $SYSTEMCTL restart "${SERVICE}.socket" || say "✗ could not restart ${SERVICE}.socket"
++ $SYSTEMCTL start "$SERVICE" 9>&-
++ SOCKET_CHANGED=false
++ else
++ [ "$socketed" = true ] && say "socket unit holds the listener — new HTTP connections queue through the restart"
++ say "restarting ${SERVICE}…"
++ $SYSTEMCTL restart "$SERVICE" 9>&-
++ fi
printf '[Deploy] waiting for readiness'
if wait_ready; then
printf '\n'; say "ready after $(( $(date +%s) - start ))s"
++ if [ "$socketed" = true ] && ! socket_held; then
++ say "✗ socket activation is not in effect: systemd does not hold the listener, so restarts refuse connections (check journalctl -u ${SERVICE}.socket)"
++ fi
return 0
fi
say "✗ not ready within ${READY_TIMEOUT}s"
ddiff --git a/deploy/systemd/openvibe-live.service.d/socket.conf b/deploy/systemd/openvibe-live.service.d/socket.conf
iindex 661b02c9..b4504221 100644
---- a/deploy/systemd/openvibe-live.service.d/socket.conf
++++ b/deploy/systemd/openvibe-live.service.d/socket.conf
@@@ -3,3 +3,7 @@
[Unit]
Requires=openvibe-live.socket
After=openvibe-live.socket
++
++[Service]
++# Node's libuv expects a non-blocking descriptor. (A [Service] option: in [Socket] systemd ignores it.)
++NonBlocking=true
ddiff --git a/deploy/systemd/openvibe-live.socket b/deploy/systemd/openvibe-live.socket
iindex eb9f73b3..d1009487 100644
---- a/deploy/systemd/openvibe-live.socket
++++ b/deploy/systemd/openvibe-live.socket
@@@ -19,8 +19,6 @@ Description=OpenVibe.Live HTTP socket
# Cloudflare and nginx (so past the client-address headers nginx sets), until 2026-09-23.
ListenStream=127.0.0.1:3000
NoDelay=true
--# Node's libuv expects a non-blocking descriptor.
--NonBlocking=true
# Match the listen backlog the process would have used itself.
Backlog=511
ddiff --git a/deploy/systemd/release/openvibe-live.service.d/socket.conf b/deploy/systemd/release/openvibe-live.service.d/socket.conf
iindex 661b02c9..b4504221 100644
---- a/deploy/systemd/release/openvibe-live.service.d/socket.conf
++++ b/deploy/systemd/release/openvibe-live.service.d/socket.conf
@@@ -3,3 +3,7 @@
[Unit]
Requires=openvibe-live.socket
After=openvibe-live.socket
++
++[Service]
++# Node's libuv expects a non-blocking descriptor. (A [Service] option: in [Socket] systemd ignores it.)
++NonBlocking=true
ddiff --git a/deploy/systemd/release/openvibe-live.socket b/deploy/systemd/release/openvibe-live.socket
iindex 7bf8f1a7..d1009487 100644
---- a/deploy/systemd/release/openvibe-live.socket
++++ b/deploy/systemd/release/openvibe-live.socket
@@@ -15,10 +15,10 @@ Description=OpenVibe.Live HTTP socket
# and connections arriving in that gap were refused. The socket must outlive every restart.
[Socket]
--ListenStream=0.0.0.0:3000
++# Loopback only: nginx is the only client. 0.0.0.0 left Live answering on the public IP, past
++# Cloudflare and nginx (so past the client-address headers nginx sets), until 2026-09-23.
++ListenStream=127.0.0.1:3000
NoDelay=true
--# Node's libuv expects a non-blocking descriptor.
--NonBlocking=true
# Match the listen backlog the process would have used itself.
Backlog=511
ddiff --git a/server/config.js b/server/config.js
iindex fec9b25b..996feb79 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -154,6 +154,9 @@ function buildConfig(registryValues) {
return {
port: parseInt(process.env.PORT || '3000', 10),
host: process.env.HOST || '0.0.0.0',
++ // What the HTTP server binds when systemd did not hand it a socket (LISTEN_FDS). In production that is
++ // loopback: nginx is the only client, and HOST (0.0.0.0) is for building URLs, not for binding.
++ listenHost: process.env.LISTEN_HOST || (process.env.NODE_ENV === 'production' ? '127.0.0.1' : (process.env.HOST || '0.0.0.0')),
baseUrl,
openvibeToolsUrl, // public-facing URL of the SSO provider (e.g. https://openvibe.network)
nodeEnv: process.env.NODE_ENV || 'development',
ddiff --git a/server/index.js b/server/index.js
iindex c3a8c4d3..88cc3e7a 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -1307,7 +1307,7 @@ async function start() {
// identical to run by hand, in dev, or on a box that has not been switched over.
const socketActivated = process.env.LISTEN_FDS === '1' && Number(process.env.LISTEN_PID) === process.pid;
if (socketActivated) console.log('[Server] socket-activated: listening on the socket systemd handed us (fd 3)');
-- server.listen(socketActivated ? { fd: 3 } : { port: config.port, host: config.host }, () => {
++ server.listen(socketActivated ? { fd: 3 } : { port: config.port, host: config.listenHost }, () => {
console.log('');
console.log(`[Server] HTTP server: http://${config.host}:${config.port}`);
console.log(`[Server] WebSocket: ws://${config.host}:${config.port}/ws/chat`);
@@@ -1606,7 +1606,7 @@ function startDrill() {
// Its port taken: stop (the process-wide handler would log EADDRINUSE and keep running unready).
server.once('error', (err) => { console.error(`[Drill] HTTP server: ${err.message}`); process.exit(1); });
// Never fd 3: a drill does not serve on a socket systemd handed over (assertSafe refuses that too).
-- server.listen({ port: config.port, host: config.host }, () => {
++ server.listen({ port: config.port, host: config.listenHost }, () => {
_bootComplete = true;
console.log(`[Drill] Ready: http://${config.host}:${config.port} (reads only)`);
});
ddiff --git a/test/systemd-units.test.js b/test/systemd-units.test.js
nnew file mode 100644
iindex 00000000..5bb46f6a
---- /dev/null
++++ b/test/systemd-units.test.js
@@@ -0,0 +1,52 @@
++'use strict';
++/**
++ * The systemd units Live runs under (deploy/systemd, and deploy/systemd/release for the release layout).
++ *
++ * From 2026-09-25 to 2026-09-26 the release layout's socket unit still listened on 0.0.0.0:3000 (the
++ * 2026-09-23 loopback fix had reached only the in-place copy) and carried NonBlocking= in [Socket],
++ * which systemd ignores. Installing it dropped the socket's descriptor, the service bound the port
++ * itself on 0.0.0.0, Live answered on the public IP past Cloudflare and nginx, and every restart
++ * refused connections for a moment. So:
++ * - both copies are the same file (one socket, one drop-in);
++ * - the socket listens on loopback only, and NonBlocking= lives in the service drop-in;
++ * - the process binds loopback in production when systemd did not hand it a socket.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const root = path.join(__dirname, '..', 'deploy', 'systemd');
++const read = (...p) => fs.readFileSync(path.join(root, ...p), 'utf8');
++
++for (const f of ['openvibe-live.socket', path.join('openvibe-live.service.d', 'socket.conf')]) {
++ assert.strictEqual(read('release', f), read(f), `deploy/systemd/release/${f} is the same file as deploy/systemd/${f}`);
++}
++
++const socket = read('openvibe-live.socket');
++const section = (text, name) => (text.split(/^\[/m).find((s) => s.startsWith(`${name}]`)) || '');
++const listens = [...section(socket, 'Socket').matchAll(/^ListenStream=(.+)$/gm)].map((m) => m[1].trim());
++assert.deepStrictEqual(listens, ['127.0.0.1:3000'], 'the socket listens on loopback only');
++assert.ok(!/^NonBlocking=/m.test(section(socket, 'Socket')), 'NonBlocking= is a [Service] option, not a [Socket] one');
++assert.ok(!/^PartOf=/m.test(section(socket, 'Unit')), 'no PartOf=: the socket outlives every restart');
++assert.match(section(read('openvibe-live.service.d', 'socket.conf'), 'Service'), /^NonBlocking=true$/m);
++
++// The fallback bind when there is no LISTEN_FDS.
++const load = (env) => {
++ const keep = { ...process.env };
++ for (const k of ['NODE_ENV', 'HOST', 'LISTEN_HOST', 'BASE_URL']) delete process.env[k];
++ Object.assign(process.env, env);
++ for (const k of Object.keys(require.cache)) if (k.endsWith(`${path.sep}server${path.sep}config.js`)) delete require.cache[k];
++ try { return require('../server/config'); } finally { process.env = keep; }
++};
++assert.strictEqual(load({ NODE_ENV: 'production', HOST: '0.0.0.0', BASE_URL: 'https://openvibe.live' }).listenHost, '127.0.0.1', 'production binds loopback whatever HOST says');
++assert.strictEqual(load({ NODE_ENV: 'production', LISTEN_HOST: '10.0.0.5', BASE_URL: 'https://openvibe.live' }).listenHost, '10.0.0.5', 'LISTEN_HOST overrides');
++assert.strictEqual(load({ NODE_ENV: 'development' }).listenHost, '0.0.0.0', 'development keeps HOST (default 0.0.0.0)');
++assert.strictEqual(load({ NODE_ENV: 'production', HOST: '0.0.0.0', BASE_URL: 'https://openvibe.live' }).host, '0.0.0.0', 'HOST still builds URLs');
++
++// deploy.sh checks that systemd really holds the listener, and restarts a socket unit that changed.
++const deploy = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'scripts', 'deploy.sh'), 'utf8');
++assert.match(deploy, /socket_held\(\)/, 'deploy.sh has the listener check');
++assert.match(deploy, /SOCKET_CHANGED=true/, 'a changed socket unit is noted');
++assert.match(deploy, /\$SYSTEMCTL restart "\$\{SERVICE\}\.socket"/, 'and restarted');
++
++console.log('systemd units: all checks passed');