Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ services:
dockerfile: nginx.dockerfile
args:
FRONTEND_BUILD_MODE: ${FRONTEND_BUILD_MODE:-fetch}
FRONTEND_VERSION: v2026.2.1
FRONTEND_VERSION: v2026.2.2
depends_on:
- service
- enketo
Expand Down
4 changes: 1 addition & 3 deletions files/nginx/odk.conf.template
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,6 @@ server {

ssl_certificate /etc/${SSL_TYPE}/live/${CERT_DOMAIN}/fullchain.pem;
ssl_certificate_key /etc/${SSL_TYPE}/live/${CERT_DOMAIN}/privkey.pem;
ssl_trusted_certificate /etc/${SSL_TYPE}/live/${CERT_DOMAIN}/fullchain.pem;

ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
Expand Down Expand Up @@ -209,9 +208,8 @@ server {
proxy_pass http://service:8383;
proxy_redirect off;

# buffer requests, but not responses, so streaming out works.
proxy_request_buffering on;
proxy_buffering off;
proxy_buffering on;
proxy_read_timeout 2m;
}

Expand Down
20 changes: 13 additions & 7 deletions files/nginx/setup-odk.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,26 @@ fi
# Generate self-signed keys for the incorrect (catch-all) HTTPS listener. This
# cert should never be seen by legitimate users, so it's not a big deal that
# it's self-signed and won't expire for 1,000 years.
mkdir -p /etc/nginx/ssl
openssl req -x509 -nodes -newkey rsa:2048 \
-subj "/CN=invalid.local" \
-keyout /etc/nginx/ssl/nginx.default.key \
-out /etc/nginx/ssl/nginx.default.crt \
-days 365000
BADHOST_DH_PATH=/etc/nginx/ssl/nginx.default
if ! [ -s "$BADHOST_DH_PATH.key" ] || ! [ -s "$BADHOST_DH_PATH.crt" ]; then
mkdir -p /etc/nginx/ssl
openssl req -x509 -nodes -newkey rsa:2048 \
-subj "/CN=invalid.local" \
-keyout "$BADHOST_DH_PATH.key" \
-out "$BADHOST_DH_PATH.crt" \
-days 365000
fi

DH_PATH=/etc/dh/nginx.pem
if [ "$SSL_TYPE" != "upstream" ] && [ ! -s "$DH_PATH" ]; then
openssl dhparam -out "$DH_PATH" 2048
fi

SELFSIGN_PATH="/etc/selfsign/live/$DOMAIN"
if [ "$SSL_TYPE" = "selfsign" ] && [ ! -s "$SELFSIGN_PATH/privkey.pem" ]; then
if [ "$SSL_TYPE" = "selfsign" ] && {
! [ -s "$SELFSIGN_PATH/privkey.pem" ] ||
! [ -s "$SELFSIGN_PATH/fullchain.pem" ];
}; then
mkdir -p "$SELFSIGN_PATH"
openssl req -x509 -newkey rsa:4096 \
-subj "/C=XX/ST=XXXX/L=XXXX/O=XXXX/CN=localhost" \
Expand Down
2 changes: 1 addition & 1 deletion server
52 changes: 52 additions & 0 deletions test/nginx/mock-http-server/index.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
const { Readable } = require('node:stream');

const express = require('express');

const port = process.env.PORT || 80;
const log = (...args) => console.log('[mock-http-server]', ...args);

const requests = [];
let openProcessorCount = 0;
let completedProcessorCount = 0;

const app = express();
app.set('case sensitive routing', true);
Expand All @@ -29,9 +33,57 @@ app.get('/__mock_http_server/health', (req, res) => res.send('OK'));
app.get('/__mock_http_server/request-log', (req, res) => res.json(requests));
app.get('/__mock_http_server/reset', (req, res) => {
requests.length = 0;
openProcessorCount = 0;
completedProcessorCount = 0;
res.json('OK');
});

app.get(new RegExp('^/v1/.*/100MB\\.csv$'), (req, res) => {
const csvSizeBytes = 100_000_000;

res.set('Content-Disposition', `attachment; filename="100MB.csv"; filename*=UTF-8''100MB.csv`);
res.set('Content-Type', 'text/csv; charset=utf-8');

++openProcessorCount;

async function* generateCsv(targetByteLength) {
let rowCount = 0;
let totalWritten = 0;

const batchSize = Math.pow(2, 18);

const header = Buffer.from('row_number,timestamp,random-number\n', 'utf8');
totalWritten += header.byteLength;
yield header;

while(totalWritten < targetByteLength) {
await new Promise(resolve => setTimeout(resolve, 1));

const batch = Buffer.allocUnsafe(Math.min(batchSize, targetByteLength - totalWritten));
let bufpos = 0;
while(bufpos < batch.length) {
const line = `${++rowCount},${new Date().toISOString()},${Math.random()}\n`;
const bytesWritten = batch.write(line, bufpos, batch.length-bufpos, 'utf8');
bufpos += bytesWritten;
totalWritten += bytesWritten;
}
yield batch;
}

++completedProcessorCount;
}

const randomStream = Readable.from(generateCsv(csvSizeBytes));
randomStream.pipe(res);
req.on('close', () => {
randomStream.destroy();
--openProcessorCount;
});
});
app.get('/__mock_http_server/open-processor-count', (req, res) => {
res.send({ openProcessorCount, completedProcessorCount });
});

app.get('/v1/reflect-headers', (req, res) => res.json(req.headers));

// Central-Backend can set Cache headers and those should have highest precedence
Expand Down
5 changes: 5 additions & 0 deletions test/nginx/src/lib.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ module.exports = {
assertSentryReceived,
requestSentryMock,
resetSentryMock,
sleep,
};

async function assertSentryReceived(...expectedRequests) {
Expand Down Expand Up @@ -56,3 +57,7 @@ function requestSentryMock(opts) {
req.end();
});
}

function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
73 changes: 73 additions & 0 deletions test/nginx/src/mocha/nginx.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const {
assertSentryReceived,
requestSentryMock,
resetSentryMock,
sleep,
} = require('../lib');
const request = require('./request');

Expand Down Expand Up @@ -437,6 +438,78 @@ function standardTestSuite({ fetchHttp, fetchHttp6, apiFetch, apiFetch6, forward
});
});

describe('response buffering', () => {
it('should buffer responses in nginx, not backend services', async function() {
const testTimeout = 5_000;
this.timeout(testTimeout);

let controller;

try {
// given
controller = new AbortController();
const { signal } = controller;

// when
const res = await apiFetch('/v1/projects/123/forms/some_form_id/attachments/100MB.csv', { signal });
// then
assert.equal(res.status, 200);

// when
const reader = res.body.getReader();
const initialRead = await reader.read();
let bytesRead = initialRead.value.length;
// then
assert.isFalse(initialRead.done);
assert.isAtMost(bytesRead, 16_384);
assert.equal(new TextDecoder('utf8').decode(initialRead.value).split('\n', 1)[0], 'row_number,timestamp,random-number');
// and
assert.deepEqual(await getOpenProcessorCount(), { openProcessorCount:1, completedProcessorCount:0 });

// when
await untilOpenProcessorCountIs({ timeout:testTimeout, openProcessorCount:0, completedProcessorCount:1 });
// and
while(true) {
const { done, value } = await reader.read();
if(done) break;
bytesRead += value.length;
}
// then
assert.equal(bytesRead, 100_000_000);
} finally {
controller.abort();
}
});

async function getOpenProcessorCount() {
const res = await request(`http://localhost:8383/__mock_http_server/open-processor-count`);
assert.isTrue(res.ok);
return await res.json();
}

async function untilOpenProcessorCountIs({ timeout, ...expected }) {
let timeoutId;
try {
let timedOut;
timeoutId = setTimeout(() => { timedOut = true; }, timeout);

while(true) {
const { openProcessorCount, completedProcessorCount } = await getOpenProcessorCount();
if(openProcessorCount === expected.openProcessorCount &&
completedProcessorCount === expected.completedProcessorCount) {
break;
}

if(timedOut) throw new Error(`Timeout of ${timeout} ms exceeded.`);

await sleep(100);
}
} finally {
clearTimeout(timeoutId);
}
}
});

it('should serve generated client-config.json', async () => {
// when
const res = await apiFetch('/client-config.json');
Expand Down
Loading