Files

147 lines
7.8 KiB
JavaScript
Raw Permalink Normal View History

const assert = require('node:assert/strict')
const { execFileSync } = require('node:child_process')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
const { setTimeout: delay } = require('node:timers/promises')
const frontend = path.resolve(__dirname, '../frontend')
const source = fs.readFileSync(path.join(frontend, 'nginx.conf'), 'utf8')
test('ingress access format exposes only approved fields and preserves nginx variables', () => {
const format = source.match(/log_format ingress_timing escape=json\s+([\s\S]*?);/)
assert.ok(format, 'escaped timing format exists')
const variables = [...new Set(format[1].match(/\$[a-z][a-z0-9_]*/g))].sort()
assert.deepEqual(variables, [
'$ingress_cf_ray', '$ingress_route_class', '$ingress_upstream_connect_time',
'$ingress_upstream_header_time', '$ingress_upstream_response_time', '$request_time', '$status',
'$time_iso8601',
].sort())
const active = source.split('\n').filter(line => !line.trim().startsWith('#')).join('\n')
assert.deepEqual([...active.matchAll(/access_log\s+([^;]+);/g)].map(match => match[1]), [
'/var/log/nginx/access.log ingress_timing',
])
const entrypoint = fs.readFileSync(path.join(frontend, 'docker-entrypoint.sh'), 'utf8')
const substituted = entrypoint.match(/envsubst '([^']+)'/)
assert.ok(substituted)
for (const variable of variables) assert.ok(!substituted[1].includes(variable))
})
const dockerContext = process.env.NGINX_TEST_DOCKER_CONTEXT
const docker = (...args) => execFileSync('docker', [
...(dockerContext ? ['--context', dockerContext] : []), ...args,
], { encoding: 'utf8', timeout: 30000, stdio: ['ignore', 'pipe', 'pipe'] }).trim()
// Enable only after the coordinated runtime freeze. No production network is used.
test('isolated nginx classifies requests and records timings without request secrets', {
skip: process.env.NGINX_TEST_RUN_INGRESS !== '1', timeout: 90000,
}, async () => {
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'weknora-ingress-test-'))
const container = `weknora-ingress-test-${process.pid}-${Date.now()}`
const seedContainer = `${container}-seed`
const volume = `${container}-fixtures`
const image = process.env.NGINX_TEST_IMAGE || fs.readFileSync(path.join(frontend, 'Dockerfile'), 'utf8').match(/^FROM (\S+)/m)[1]
let created = false
let seedCreated = false
let volumeCreated = false
try {
const site = path.join(temp, 'html')
fs.mkdirSync(site)
for (const [filename, body] of Object.entries({
'index.html': '<html>timing fixture</html>',
'embed.html': '<html>embed fixture</html>',
'config.js': 'window.fixture = true;',
'release.json': '{"release":"fixture"}',
})) fs.writeFileSync(path.join(site, filename), body)
const replacements = { APP_SCHEME: 'http', APP_HOST: '127.0.0.1', APP_PORT: '81', MAX_FILE_SIZE: '50M', MAX_SKILL_BUNDLE_SIZE: '256M' }
const config = source.replace(/\$\{(APP_SCHEME|APP_HOST|APP_PORT|MAX_FILE_SIZE|MAX_SKILL_BUNDLE_SIZE)\}/g, (_, key) => replacements[key])
.replaceAll('/usr/share/nginx/html', '/fixtures/html')
.replaceAll('/etc/nginx/api-proxy.conf', '/fixtures/api-proxy.conf')
.replaceAll('/var/log/nginx/access.log ingress_timing', '/proc/1/fd/1 ingress_timing')
.replaceAll('/var/log/nginx/error.log warn', '/proc/1/fd/2 warn')
fs.writeFileSync(path.join(temp, 'nginx.conf'),
'worker_processes 1; error_log /dev/stderr warn; pid /var/run/nginx.pid;\n'
+ 'events { worker_connections 128; }\nhttp { include /etc/nginx/mime.types;\n'
+ config + '\nserver { listen 127.0.0.1:81; access_log off; location / { return 204; } }\n}\n')
fs.copyFileSync(path.join(frontend, 'nginx-api-proxy.conf'), path.join(temp, 'api-proxy.conf'))
// Stage through Docker copy so a remote context needs no host-path sharing.
docker('volume', 'create', volume)
volumeCreated = true
docker('create', '--pull', 'never', '--read-only', '--network', 'none', '--name', seedContainer,
'--mount', `type=volume,src=${volume},dst=/fixtures`, '--entrypoint', 'true', image)
seedCreated = true
docker('cp', `${temp}/.`, `${seedContainer}:/fixtures`)
docker('rm', seedContainer)
seedCreated = false
docker('create', '--rm', '--pull', 'never', '--read-only', '--network', 'none', '--name', container,
'--tmpfs', '/var/cache/nginx:rw,noexec,nosuid,size=16m',
'--tmpfs', '/var/run:rw,noexec,nosuid,size=1m',
'--mount', `type=volume,src=${volume},dst=/fixtures,readonly`,
'--entrypoint', 'nginx', image, '-c', '/fixtures/nginx.conf', '-g', 'daemon off;')
created = true
docker('start', container)
docker('exec', container, 'nginx', '-t', '-c', '/fixtures/nginx.conf')
const fetchFixture = (url, extra = []) => docker('exec', container, 'wget', '-q', '-O', '/dev/null', ...extra, `http://127.0.0.1${url}`)
for (let attempt = 0; ; attempt += 1) {
try { fetchFixture('/release.json'); break } catch (error) {
if (attempt >= 19) throw error
await delay(250)
}
}
const secret = 'ingress-secret-sentinel'
const cases = [
['/admin', 'shell', 200], ['/platform/workbench/content', 'shell', 200],
['/api/v1/auth/me', 'auth_me', 204], ['/api/v1/access/me', 'access_me', 204],
['/api/v1/system/capabilities', 'capabilities', 204],
['/api/v1/workbench/resources/training-maps', 'resource_list', 204],
[`/api/v1/workbench/resources/training-maps/${secret}`, 'resource_detail', 204],
[`/api/v1/workbench/resources/training-maps/${secret}/jobs`, 'api', 204],
[`/r/${secret}`, 'resource_link', 204], [`/files/${secret}`, 'file', 200],
[`/embed/${secret}`, 'embed', 200], ['/config.js', 'config', 200],
['/release.json', 'release', 200], [`/unknown/${secret}`, 'other', 200],
]
const rays = cases.map((_, index) => `${(index + 1).toString(16).padStart(16, '0')}-AMS`)
for (const [index, [url]] of cases.entries()) fetchFixture(`${url}?token=${secret}`, [
`--header=CF-Ray: ${rays[index]}`, `--header=Authorization: Bearer ${secret}`,
`--header=Cookie: session=${secret}`, `--header=Referer: https://example.test/${secret}`,
`--header=User-Agent: ${secret}`,
])
fetchFixture('/api/body', [`--header=CF-Ray: ${secret}`, `--post-data=${secret}`])
const lines = docker('logs', container).split('\n').filter(line => line.startsWith('{"time":'))
assert.ok(lines.length >= cases.length + 2)
assert.ok(!lines.join('\n').includes(secret), 'no path, query, header, or body secret appears in access records')
const records = lines.map(line => JSON.parse(line))
const fields = ['time', 'route', 'status', 'request_time', 'upstream_connect_time', 'upstream_header_time', 'upstream_response_time', 'cf_ray'].sort()
for (const [index, [, route, status]] of cases.entries()) {
const matches = records.filter(record => record.cf_ray === rays[index])
assert.equal(matches.length, 1)
const record = matches[0]
assert.deepEqual(Object.keys(record).sort(), fields)
assert.equal(record.route, route)
assert.equal(record.status, status, `${route} ${record.cf_ray}`)
assert.match(record.request_time, /^\d+\.\d{3}$/)
for (const field of ['upstream_connect_time', 'upstream_header_time', 'upstream_response_time']) {
assert.match(record[field], status === 204 ? /^\d+\.\d{3}$/ : /^-$/)
}
}
assert.ok(records.some(record => record.route === 'api' && record.cf_ray === '-' && record.status === 204))
} finally {
try {
if (created) docker('rm', '--force', container)
} finally {
try {
if (seedCreated) docker('rm', '--force', seedContainer)
} finally {
try {
if (volumeCreated) docker('volume', 'rm', volume)
} finally {
fs.rmSync(temp, { recursive: true, force: true })
}
}
}
}
})