openvibers/openvibe.events Commits
Realtime streams: room for a shared address, and a refusal pages can read (CORS on nginx's 429) (#231)
Changes
@@@ -27,7 +27,10 @@## ═══════════════════════════════════════════════════════════════# Zone names carry this site's prefix (ovevents_apex_*) so they never collide with another vhost's in one config.--limit_req_zone $binary_remote_addr zone=ovevents_apex_connect:10m rate=2r/s;++# Streams: every open OpenVibe tab holds one (the shared footer's release notices, Live's live-now), and a household or++# a carrier's NAT puts many people behind one address. 11-03: at 20 streams and 2 new a second per address, a sweep of++# the sites from one address got 16 refusals, which reached pages as CORS errors (nginx's 429 had no CORS header).++limit_req_zone $binary_remote_addr zone=ovevents_apex_connect:10m rate=5r/s;limit_conn_zone $binary_remote_addr zone=ovevents_apex_streams:10m;limit_req_zone $binary_remote_addr zone=ovevents_apex_api:10m rate=20r/s;@@@ -81,8 +84,12 @@ server {# Cloudflare's 100 s idle limit. CORS is answered by the service (https://*.openvibe.* only).location = /realtime/stream {access_log /var/log/nginx/openvibe.events.access.log ov_events_noquery; # never the ?ticket= (log_format_events_noquery.conf)-- limit_req zone=ovevents_apex_connect burst=20 nodelay;-- limit_conn ovevents_apex_streams 20;++ limit_req zone=ovevents_apex_connect burst=60 nodelay;++ limit_conn ovevents_apex_streams 60;++ # Over a limit: a 429 the page can read (CORS, Retry-After), not an opaque refusal.++ limit_req_status 429;++ limit_conn_status 429;++ error_page 429 = @ovevents_stream_busy;proxy_pass http://127.0.0.1:4300;proxy_http_version 1.1;proxy_set_header Connection "";@@@ -101,6 +108,17 @@ server {send_timeout 1h;}++ # The refusal above, in the shape the service answers (problem+json), readable from any page: it carries no data.++ location @ovevents_stream_busy {++ default_type application/problem+json;++ add_header Access-Control-Allow-Origin $http_origin always;++ add_header Vary Origin always;++ add_header Retry-After 10 always;++ add_header Cache-Control no-store always;++ add_header X-Content-Type-Options nosniff always;++ return 429 '{"type":"about:blank","title":"realtime.busy","status":429,"code":"realtime.busy","detail":"too many realtime streams from this address; retry shortly"}';++ }++# The client address comes from $remote_addr only on every proxied location: never appended to,# never passed through. X-Forwarded-For on every API location: the service treats a request without it from# 127.0.0.1 as a local caller (GET /metrics answers only those).@@@ -258,6 +258,19 @@ t('llms-full.txt is the same map in full text, starting with the site name', asyassert.ok(r.body.includes('### Guide'), 'each page with its full text');});++t('deploy/nginx/openvibe.events.conf: realtime streams allow a shared address, and a refusal is readable (CORS)', () => {++ const conf = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'nginx', 'openvibe.events.conf'), 'utf8');++ const stream = /location = \/realtime\/stream \{([\s\S]*?)\n \}/.exec(conf)[1];++ assert.match(stream, /limit_conn ovevents_apex_streams 60;/);++ assert.match(stream, /limit_req zone=ovevents_apex_connect burst=60 nodelay;/);++ assert.match(stream, /limit_req_status 429;\s*limit_conn_status 429;\s*error_page 429 = @ovevents_stream_busy;/);++ const busy = /location @ovevents_stream_busy \{([\s\S]*?)\n \}/.exec(conf)[1];++ assert.match(busy, /add_header Access-Control-Allow-Origin \$http_origin always;/);++ assert.match(busy, /add_header Retry-After 10 always;/);++ assert.match(busy, /return 429 '\{"type":"about:blank","title":"realtime\.busy"/);++ assert.ok(!/Access-Control-Allow-Credentials/.test(busy), 'no credentials: the answer carries no data');++});++t('deploy/nginx/openvibe.events.conf is the API and product origin, with no redirect to the old host', () => {const conf = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'nginx', 'openvibe.events.conf'), 'utf8');assert.match(conf, /server_name openvibe\.events;/);
@@ -27,7 +27,10 @@
@@ -27,7 +27,10 @@
## ═══════════════════════════════════════════════════════════════
## ═══════════════════════════════════════════════════════════════
# Zone names carry this site's prefix (ovevents_apex_*) so they never collide with another vhost's in one config.
# Zone names carry this site's prefix (ovevents_apex_*) so they never collide with another vhost's in one config.
-limit_req_zone $binary_remote_addr zone=ovevents_apex_connect:10m rate=2r/s;
+# Streams: every open OpenVibe tab holds one (the shared footer's release notices, Live's live-now), and a household or
+# a carrier's NAT puts many people behind one address. 11-03: at 20 streams and 2 new a second per address, a sweep of
+# the sites from one address got 16 refusals, which reached pages as CORS errors (nginx's 429 had no CORS header).
+limit_req_zone $binary_remote_addr zone=ovevents_apex_connect:10m rate=5r/s;
limit_conn_zone $binary_remote_addr zone=ovevents_apex_streams:10m;
limit_conn_zone $binary_remote_addr zone=ovevents_apex_streams:10m;
limit_req_zone $binary_remote_addr zone=ovevents_apex_api:10m rate=20r/s;
limit_req_zone $binary_remote_addr zone=ovevents_apex_api:10m rate=20r/s;
@@ -81,8 +84,12 @@ server {
@@ -81,8 +84,12 @@ server {
# Cloudflare's 100 s idle limit. CORS is answered by the service (https://*.openvibe.* only).
# Cloudflare's 100 s idle limit. CORS is answered by the service (https://*.openvibe.* only).
location = /realtime/stream {
location = /realtime/stream {
access_log /var/log/nginx/openvibe.events.access.log ov_events_noquery; # never the ?ticket= (log_format_events_noquery.conf)
access_log /var/log/nginx/openvibe.events.access.log ov_events_noquery; # never the ?ticket= (log_format_events_noquery.conf)
- limit_req zone=ovevents_apex_connect burst=20 nodelay;
- limit_conn ovevents_apex_streams 20;
+ limit_req zone=ovevents_apex_connect burst=60 nodelay;
+ limit_conn ovevents_apex_streams 60;
+ # Over a limit: a 429 the page can read (CORS, Retry-After), not an opaque refusal.
+ limit_req_status 429;
+ limit_conn_status 429;
+ error_page 429 = @ovevents_stream_busy;
proxy_pass http://127.0.0.1:4300;
proxy_pass http://127.0.0.1:4300;
proxy_http_version 1.1;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Connection "";
@@ -101,6 +108,17 @@ server {
@@ -101,6 +108,17 @@ server {
send_timeout 1h;
send_timeout 1h;
}
}
+ # The refusal above, in the shape the service answers (problem+json), readable from any page: it carries no data.
+ location @ovevents_stream_busy {
+ default_type application/problem+json;
+ add_header Access-Control-Allow-Origin $http_origin always;
+ add_header Vary Origin always;
+ add_header Retry-After 10 always;
+ add_header Cache-Control no-store always;
+ add_header X-Content-Type-Options nosniff always;
+ return 429 '{"type":"about:blank","title":"realtime.busy","status":429,"code":"realtime.busy","detail":"too many realtime streams from this address; retry shortly"}';
+ }
+
# The client address comes from $remote_addr only on every proxied location: never appended to,
# The client address comes from $remote_addr only on every proxied location: never appended to,
# never passed through. X-Forwarded-For on every API location: the service treats a request without it from
# never passed through. X-Forwarded-For on every API location: the service treats a request without it from
# 127.0.0.1 as a local caller (GET /metrics answers only those).
# 127.0.0.1 as a local caller (GET /metrics answers only those).
@@ -258,6 +258,19 @@ t('llms-full.txt is the same map in full text, starting with the site name', asy
@@ -258,6 +258,19 @@ t('llms-full.txt is the same map in full text, starting with the site name', asy
assert.ok(r.body.includes('### Guide'), 'each page with its full text');
assert.ok(r.body.includes('### Guide'), 'each page with its full text');
});
});
+t('deploy/nginx/openvibe.events.conf: realtime streams allow a shared address, and a refusal is readable (CORS)', () => {
+ const conf = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'nginx', 'openvibe.events.conf'), 'utf8');
+ const stream = /location = \/realtime\/stream \{([\s\S]*?)\n \}/.exec(conf)[1];
+ assert.match(stream, /limit_conn ovevents_apex_streams 60;/);
+ assert.match(stream, /limit_req zone=ovevents_apex_connect burst=60 nodelay;/);
+ assert.match(stream, /limit_req_status 429;\s*limit_conn_status 429;\s*error_page 429 = @ovevents_stream_busy;/);
+ const busy = /location @ovevents_stream_busy \{([\s\S]*?)\n \}/.exec(conf)[1];
+ assert.match(busy, /add_header Access-Control-Allow-Origin \$http_origin always;/);
+ assert.match(busy, /add_header Retry-After 10 always;/);
+ assert.match(busy, /return 429 '\{"type":"about:blank","title":"realtime\.busy"/);
+ assert.ok(!/Access-Control-Allow-Credentials/.test(busy), 'no credentials: the answer carries no data');
+});
+
t('deploy/nginx/openvibe.events.conf is the API and product origin, with no redirect to the old host', () => {
t('deploy/nginx/openvibe.events.conf is the API and product origin, with no redirect to the old host', () => {
const conf = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'nginx', 'openvibe.events.conf'), 'utf8');
const conf = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'nginx', 'openvibe.events.conf'), 'utf8');
assert.match(conf, /server_name openvibe\.events;/);
assert.match(conf, /server_name openvibe\.events;/);