Skip to content
Draft
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
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
5 changes: 3 additions & 2 deletions test/nginx/src/mocha/setup-odk.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ describe('setup-odk.sh', function() {
dockerCompose({}, `logs --timestamps ${service}`);
log('--- END CONTAINER LOGS ---');
});
after(() => {
after(function() {
this.timeout(5000);
dockerCompose({}, `down --remove-orphans --volumes`);
});

Expand All @@ -27,7 +28,7 @@ describe('setup-odk.sh', function() {
[ 'bad-format', '' ],
[ 'https://abcdef0123456789abcdef0123456789@some-dsn.ingest.sentry.io/', '' ],
].forEach(([ SENTRY_DSN_FRONTEND, expectedCspEntry ]) => {
it(`should generated expected CSP for SENTRY_DSN_FRONTEND='${SENTRY_DSN_FRONTEND}'`, withNginx({
it(`should generate expected CSP for SENTRY_DSN_FRONTEND='${SENTRY_DSN_FRONTEND}'`, withNginx({
SENTRY_DSN_FRONTEND,
}, async () => {
// when
Expand Down
Loading