openvibers/openvibe.network Commits
nginx generator: Live's ingest hostnames, connection budgets, static cache, gzip and query-less logs
Changes
@@@ -6,7 +6,7 @@"scripts": {"start": "node server/index.js","dev": "NODE_ENV=development node server/index.js",-- "test": "node test/registry-bootstrap.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js",++ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js","reset-db": "node server/reset-db.js","grant-admin": "node server/grant-admin.js"},@@@ -96,12 +96,24 @@ const DEFAULT_SERVICE_MAP = {live: {port: 3000,domains: ['openvibe.live', 'www.openvibe.live'],++ // WHIP ingest and the RTMP-info hostname are served by the same app.++ extraServerNames: ['whip.openvibe.live', 'ingest.openvibe.live'],wildcardDomain: 'openvibe.live',maxBodySize: '3m',rateZones: [{ name: 'streamer_api', rate: '10r/s' },{ name: 'streamer_login', rate: '5r/m' },],++ // Two per-IP connection budgets: long-lived WebSockets (5-6 per open tab) must not use up the++ // budget a page load needs for its stylesheets and scripts. Mirrors OpenVibe.Live's++ // deploy/nginx/openvibe.live.conf, which is what production runs.++ connZones: ['addr_limit', 'asset_limit'],++ connLimit: { zone: 'addr_limit', n: 256 },++ // Static responses cached in front of Node; Node decides cacheability (content-hashed ?v=).++ proxyCaches: [{ path: '/var/cache/nginx/openvibe-live', zone: 'live_static', size: '20m', maxSize: '1g', inactive: '30d' }],++ gzip: true,++ // WebSocket URLs carry session tokens in the query string; keep them out of access logs.++ logWithoutQuery: true,locations: [{match: '= /api/auth/register',@@@ -138,9 +150,15 @@ const DEFAULT_SERVICE_MAP = {match: '/ws/',websocket: true,},++ {++ match: '~ ^/(?!api/|ws/|data/|media/).*\\.(?:css|js|mjs|map|woff2?|ttf|otf|svg|png|jpe?g|gif|ico|webp|mp3|wav)$',++ connLimit: { zone: 'asset_limit', n: 100 },++ proxyCache: 'live_static',++ },],defaultLocationExtras: {websocket: true,++ cfConnectingIp: true,headers: {'Permissions-Policy': 'camera=*, microphone=*, display-capture=*',},@@@ -367,6 +385,10 @@ function generateProxyBlock(port, loc = {}) {lines.push('chunked_transfer_encoding off;');}++ if (loc.cfConnectingIp) {++ lines.push('proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;');++ }++if (loc.readTimeout) {lines.push(`proxy_read_timeout ${loc.readTimeout};`);}@@@ -394,6 +416,19 @@ function generateLocationBlock(port, loc) {lines.push(`client_max_body_size ${loc.maxBodySize};`);}++ if (loc.connLimit) {++ lines.push(`limit_conn ${loc.connLimit.zone} ${loc.connLimit.n};`);++ }++++ if (loc.proxyCache) {++ // Only responses the app marks cacheable are stored (nginx honours Cache-Control).++ lines.push(`proxy_cache ${loc.proxyCache};`);++ lines.push('proxy_cache_key "$host$uri$is_args$args";');++ lines.push('proxy_cache_lock on;');++ lines.push('proxy_cache_use_stale error timeout updating http_502 http_503;');++ lines.push('add_header X-Cache $upstream_cache_status always;');++ }++lines.push(generateProxyBlock(port, loc));if (loc.cacheTime) {@@@ -437,7 +472,7 @@ function parseDurationToSeconds(dur) {*/function generateServiceConfig(serviceId, svc, opts = {}) {const lines = [];-- const allDomains = [...(svc.domains || [])];++ const allDomains = [...(svc.domains || []), ...(svc.extraServerNames || [])];if (svc.wildcardDomain && !svc.parentDomain) {// Add wildcard to server_name if this service owns the domainif (!allDomains.includes(`*.${svc.wildcardDomain}`)) {@@@ -462,6 +497,18 @@ function generateServiceConfig(serviceId, svc, opts = {}) {lines.push('');}++ if (svc.logWithoutQuery) {++ lines.push(`log_format ${serviceId}_noquery '$remote_addr - $remote_user [$time_local] "$request_method $uri $server_protocol" $status $body_bytes_sent "$http_referer" "$http_user_agent"';`);++ }++ // Connection budgets and response caches++ for (const zone of svc.connZones || []) {++ lines.push(`limit_conn_zone $binary_remote_addr zone=${zone}:10m;`);++ }++ for (const c of svc.proxyCaches || []) {++ lines.push(`proxy_cache_path ${c.path} levels=1:2 keys_zone=${c.zone}:${c.size || '10m'} max_size=${c.maxSize || '512m'} inactive=${c.inactive || '7d'} use_temp_path=off;`);++ }++ if ((svc.connZones || []).length || (svc.proxyCaches || []).length) lines.push('');++// HTTP → HTTPS redirectlines.push('# HTTP → HTTPS redirect');lines.push('server {');@@@ -494,7 +541,7 @@ function generateServiceConfig(serviceId, svc, opts = {}) {// Logsconst logName = svc.domains?.[0]?.replace(/\./g, '-') || serviceId;-- lines.push(` access_log /var/log/nginx/${logName}.access.log;`);++ lines.push(` access_log /var/log/nginx/${logName}.access.log${svc.logWithoutQuery ? ` ${serviceId}_noquery` : ''};`);lines.push(` error_log /var/log/nginx/${logName}.error.log;`);lines.push('');@@@ -502,6 +549,22 @@ function generateServiceConfig(serviceId, svc, opts = {}) {lines.push(` client_max_body_size ${svc.maxBodySize || '5m'};`);lines.push('');++ if (svc.connLimit) {++ lines.push(` limit_conn ${svc.connLimit.zone} ${svc.connLimit.n};`);++ lines.push('');++ }++++ if (svc.gzip) {++ // Compress text on the hop to the CDN too; the global config does not compress proxied responses.++ lines.push(' gzip on;');++ lines.push(' gzip_proxied any;');++ lines.push(' gzip_vary on;');++ lines.push(' gzip_comp_level 5;');++ lines.push(' gzip_min_length 1024;');++ lines.push(' gzip_types text/css application/javascript text/javascript application/json image/svg+xml text/plain application/xml text/xml;');++ lines.push('');++ }++// Security headersif (svc.headers) {for (const [k, v] of Object.entries(svc.headers)) {nnew file mode 100644@@@ -0,0 +1,30 @@++/**++ * The generated openvibe.live config must carry what production's hand-maintained config++ * (OpenVibe.Live deploy/nginx/openvibe.live.conf) depends on, or regenerating it from the admin++ * panel silently drops WHIP ingest, the connection budgets, the static cache and compression.++ *++ * node test/nginx-generator-live.test.js++ */++'use strict';++const assert = require('assert');++const g = require('../server/deploy/nginx-generator');++++const conf = g.generateServiceConfig('live', g.DEFAULT_SERVICE_MAP.live, { sslEnabled: true, certPath: '/c/f.pem', keyPath: '/c/k.pem' });++const must = [++ ['WHIP hostname', /server_name [^;]*whip\.openvibe\.live/],++ ['ingest hostname', /server_name [^;]*ingest\.openvibe\.live/],++ ['socket connection budget', /limit_conn_zone \$binary_remote_addr zone=addr_limit:10m;[\s\S]*limit_conn addr_limit 256;/],++ ['asset connection budget', /limit_conn_zone \$binary_remote_addr zone=asset_limit:10m;[\s\S]*limit_conn asset_limit 100;/],++ ['static cache zone', /proxy_cache_path \/var\/cache\/nginx\/openvibe-live [^;]*keys_zone=live_static:/],++ ['static cache on the asset location', /location ~ \^\/\(\?!api\/\|ws\/\|data\/\|media\/\)[^{]*\{[^}]*proxy_cache live_static;/],++ ['gzip for proxied text', /gzip_proxied any;/],++ ['upload body size', /location ~ \^\/api\/vods\/\(upload\|clips\|stream\/\) \{[^}]*client_max_body_size 500m;/],++ ['websocket upgrade on catch-all', /location \/ \{[^}]*proxy_set_header Upgrade \$http_upgrade;/],++ ['Cloudflare client IP forwarded', /proxy_set_header CF-Connecting-IP \$http_cf_connecting_ip;/],++ ['access log without query strings (tokens)', /log_format live_noquery [^;]*\$uri[^;]*;[\s\S]*access_log [^;]* live_noquery;/],++ ['capture permissions header', /Permissions-Policy "camera=\*, microphone=\*, display-capture=\*"/],++];++for (const [name, re] of must) assert.ok(re.test(conf), `generated live config is missing: ${name}`);++// The asset regex must not swallow /api/ or /ws/ (a regex location beats prefix locations).++assert.ok(!/location ~ \^\/\.\*\\\.\(\?:css/.test(conf), 'asset location must exclude /api/ and /ws/');++console.log(`nginx generator (live): ${must.length} checks passed`);
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
- "test": "node test/registry-bootstrap.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js",
+ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js",
"reset-db": "node server/reset-db.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
@@ -96,12 +96,24 @@ const DEFAULT_SERVICE_MAP = {
@@ -96,12 +96,24 @@ const DEFAULT_SERVICE_MAP = {
live: {
live: {
port: 3000,
port: 3000,
domains: ['openvibe.live', 'www.openvibe.live'],
domains: ['openvibe.live', 'www.openvibe.live'],
+ // WHIP ingest and the RTMP-info hostname are served by the same app.
+ extraServerNames: ['whip.openvibe.live', 'ingest.openvibe.live'],
wildcardDomain: 'openvibe.live',
wildcardDomain: 'openvibe.live',
maxBodySize: '3m',
maxBodySize: '3m',
rateZones: [
rateZones: [
{ name: 'streamer_api', rate: '10r/s' },
{ name: 'streamer_api', rate: '10r/s' },
{ name: 'streamer_login', rate: '5r/m' },
{ name: 'streamer_login', rate: '5r/m' },
],
],
+ // Two per-IP connection budgets: long-lived WebSockets (5-6 per open tab) must not use up the
+ // budget a page load needs for its stylesheets and scripts. Mirrors OpenVibe.Live's
+ // deploy/nginx/openvibe.live.conf, which is what production runs.
+ connZones: ['addr_limit', 'asset_limit'],
+ connLimit: { zone: 'addr_limit', n: 256 },
+ // Static responses cached in front of Node; Node decides cacheability (content-hashed ?v=).
+ proxyCaches: [{ path: '/var/cache/nginx/openvibe-live', zone: 'live_static', size: '20m', maxSize: '1g', inactive: '30d' }],
+ gzip: true,
+ // WebSocket URLs carry session tokens in the query string; keep them out of access logs.
+ logWithoutQuery: true,
locations: [
locations: [
{
{
match: '= /api/auth/register',
match: '= /api/auth/register',
@@ -138,9 +150,15 @@ const DEFAULT_SERVICE_MAP = {
@@ -138,9 +150,15 @@ const DEFAULT_SERVICE_MAP = {
match: '/ws/',
match: '/ws/',
websocket: true,
websocket: true,
},
},
+ {
+ match: '~ ^/(?!api/|ws/|data/|media/).*\\.(?:css|js|mjs|map|woff2?|ttf|otf|svg|png|jpe?g|gif|ico|webp|mp3|wav)$',
+ connLimit: { zone: 'asset_limit', n: 100 },
+ proxyCache: 'live_static',
+ },
],
],
defaultLocationExtras: {
defaultLocationExtras: {
websocket: true,
websocket: true,
+ cfConnectingIp: true,
headers: {
headers: {
'Permissions-Policy': 'camera=*, microphone=*, display-capture=*',
'Permissions-Policy': 'camera=*, microphone=*, display-capture=*',
},
},
@@ -367,6 +385,10 @@ function generateProxyBlock(port, loc = {}) {
@@ -367,6 +385,10 @@ function generateProxyBlock(port, loc = {}) {
lines.push('chunked_transfer_encoding off;');
lines.push('chunked_transfer_encoding off;');
}
}
+ if (loc.cfConnectingIp) {
+ lines.push('proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;');
+ }
+
if (loc.readTimeout) {
if (loc.readTimeout) {
lines.push(`proxy_read_timeout ${loc.readTimeout};`);
lines.push(`proxy_read_timeout ${loc.readTimeout};`);
}
}
@@ -394,6 +416,19 @@ function generateLocationBlock(port, loc) {
@@ -394,6 +416,19 @@ function generateLocationBlock(port, loc) {
lines.push(`client_max_body_size ${loc.maxBodySize};`);
lines.push(`client_max_body_size ${loc.maxBodySize};`);
}
}
+ if (loc.connLimit) {
+ lines.push(`limit_conn ${loc.connLimit.zone} ${loc.connLimit.n};`);
+ }
+
+ if (loc.proxyCache) {
+ // Only responses the app marks cacheable are stored (nginx honours Cache-Control).
+ lines.push(`proxy_cache ${loc.proxyCache};`);
+ lines.push('proxy_cache_key "$host$uri$is_args$args";');
+ lines.push('proxy_cache_lock on;');
+ lines.push('proxy_cache_use_stale error timeout updating http_502 http_503;');
+ lines.push('add_header X-Cache $upstream_cache_status always;');
+ }
+
lines.push(generateProxyBlock(port, loc));
lines.push(generateProxyBlock(port, loc));
if (loc.cacheTime) {
if (loc.cacheTime) {
@@ -437,7 +472,7 @@ function parseDurationToSeconds(dur) {
@@ -437,7 +472,7 @@ function parseDurationToSeconds(dur) {
*/
*/
function generateServiceConfig(serviceId, svc, opts = {}) {
function generateServiceConfig(serviceId, svc, opts = {}) {
const lines = [];
const lines = [];
- const allDomains = [...(svc.domains || [])];
+ const allDomains = [...(svc.domains || []), ...(svc.extraServerNames || [])];
if (svc.wildcardDomain && !svc.parentDomain) {
if (svc.wildcardDomain && !svc.parentDomain) {
// Add wildcard to server_name if this service owns the domain
// Add wildcard to server_name if this service owns the domain
if (!allDomains.includes(`*.${svc.wildcardDomain}`)) {
if (!allDomains.includes(`*.${svc.wildcardDomain}`)) {
@@ -462,6 +497,18 @@ function generateServiceConfig(serviceId, svc, opts = {}) {
@@ -462,6 +497,18 @@ function generateServiceConfig(serviceId, svc, opts = {}) {
lines.push('');
lines.push('');
}
}
+ if (svc.logWithoutQuery) {
+ lines.push(`log_format ${serviceId}_noquery '$remote_addr - $remote_user [$time_local] "$request_method $uri $server_protocol" $status $body_bytes_sent "$http_referer" "$http_user_agent"';`);
+ }
+ // Connection budgets and response caches
+ for (const zone of svc.connZones || []) {
+ lines.push(`limit_conn_zone $binary_remote_addr zone=${zone}:10m;`);
+ }
+ for (const c of svc.proxyCaches || []) {
+ lines.push(`proxy_cache_path ${c.path} levels=1:2 keys_zone=${c.zone}:${c.size || '10m'} max_size=${c.maxSize || '512m'} inactive=${c.inactive || '7d'} use_temp_path=off;`);
+ }
+ if ((svc.connZones || []).length || (svc.proxyCaches || []).length) lines.push('');
+
// HTTP → HTTPS redirect
// HTTP → HTTPS redirect
lines.push('# HTTP → HTTPS redirect');
lines.push('# HTTP → HTTPS redirect');
lines.push('server {');
lines.push('server {');
@@ -494,7 +541,7 @@ function generateServiceConfig(serviceId, svc, opts = {}) {
@@ -494,7 +541,7 @@ function generateServiceConfig(serviceId, svc, opts = {}) {
// Logs
// Logs
const logName = svc.domains?.[0]?.replace(/\./g, '-') || serviceId;
const logName = svc.domains?.[0]?.replace(/\./g, '-') || serviceId;
- lines.push(` access_log /var/log/nginx/${logName}.access.log;`);
+ lines.push(` access_log /var/log/nginx/${logName}.access.log${svc.logWithoutQuery ? ` ${serviceId}_noquery` : ''};`);
lines.push(` error_log /var/log/nginx/${logName}.error.log;`);
lines.push(` error_log /var/log/nginx/${logName}.error.log;`);
lines.push('');
lines.push('');
@@ -502,6 +549,22 @@ function generateServiceConfig(serviceId, svc, opts = {}) {
@@ -502,6 +549,22 @@ function generateServiceConfig(serviceId, svc, opts = {}) {
lines.push(` client_max_body_size ${svc.maxBodySize || '5m'};`);
lines.push(` client_max_body_size ${svc.maxBodySize || '5m'};`);
lines.push('');
lines.push('');
+ if (svc.connLimit) {
+ lines.push(` limit_conn ${svc.connLimit.zone} ${svc.connLimit.n};`);
+ lines.push('');
+ }
+
+ if (svc.gzip) {
+ // Compress text on the hop to the CDN too; the global config does not compress proxied responses.
+ lines.push(' gzip on;');
+ lines.push(' gzip_proxied any;');
+ lines.push(' gzip_vary on;');
+ lines.push(' gzip_comp_level 5;');
+ lines.push(' gzip_min_length 1024;');
+ lines.push(' gzip_types text/css application/javascript text/javascript application/json image/svg+xml text/plain application/xml text/xml;');
+ lines.push('');
+ }
+
// Security headers
// Security headers
if (svc.headers) {
if (svc.headers) {
for (const [k, v] of Object.entries(svc.headers)) {
for (const [k, v] of Object.entries(svc.headers)) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+/**
+ * The generated openvibe.live config must carry what production's hand-maintained config
+ * (OpenVibe.Live deploy/nginx/openvibe.live.conf) depends on, or regenerating it from the admin
+ * panel silently drops WHIP ingest, the connection budgets, the static cache and compression.
+ *
+ * node test/nginx-generator-live.test.js
+ */
+'use strict';
+const assert = require('assert');
+const g = require('../server/deploy/nginx-generator');
+
+const conf = g.generateServiceConfig('live', g.DEFAULT_SERVICE_MAP.live, { sslEnabled: true, certPath: '/c/f.pem', keyPath: '/c/k.pem' });
+const must = [
+ ['WHIP hostname', /server_name [^;]*whip\.openvibe\.live/],
+ ['ingest hostname', /server_name [^;]*ingest\.openvibe\.live/],
+ ['socket connection budget', /limit_conn_zone \$binary_remote_addr zone=addr_limit:10m;[\s\S]*limit_conn addr_limit 256;/],
+ ['asset connection budget', /limit_conn_zone \$binary_remote_addr zone=asset_limit:10m;[\s\S]*limit_conn asset_limit 100;/],
+ ['static cache zone', /proxy_cache_path \/var\/cache\/nginx\/openvibe-live [^;]*keys_zone=live_static:/],
+ ['static cache on the asset location', /location ~ \^\/\(\?!api\/\|ws\/\|data\/\|media\/\)[^{]*\{[^}]*proxy_cache live_static;/],
+ ['gzip for proxied text', /gzip_proxied any;/],
+ ['upload body size', /location ~ \^\/api\/vods\/\(upload\|clips\|stream\/\) \{[^}]*client_max_body_size 500m;/],
+ ['websocket upgrade on catch-all', /location \/ \{[^}]*proxy_set_header Upgrade \$http_upgrade;/],
+ ['Cloudflare client IP forwarded', /proxy_set_header CF-Connecting-IP \$http_cf_connecting_ip;/],
+ ['access log without query strings (tokens)', /log_format live_noquery [^;]*\$uri[^;]*;[\s\S]*access_log [^;]* live_noquery;/],
+ ['capture permissions header', /Permissions-Policy "camera=\*, microphone=\*, display-capture=\*"/],
+];
+for (const [name, re] of must) assert.ok(re.test(conf), `generated live config is missing: ${name}`);
+// The asset regex must not swallow /api/ or /ws/ (a regex location beats prefix locations).
+assert.ok(!/location ~ \^\/\.\*\\\.\(\?:css/.test(conf), 'asset location must exclude /api/ and /ws/');
+console.log(`nginx generator (live): ${must.length} checks passed`);