diff --git a/crates/capabilities/src/http_transport.js b/crates/capabilities/src/http_transport.js index 083d958..acd1c5c 100644 --- a/crates/capabilities/src/http_transport.js +++ b/crates/capabilities/src/http_transport.js @@ -1,8 +1,106 @@ const http = require('node:http') const https = require('node:https') +const fs = require('node:fs') let storage = f => f() +// libdatadog's automatic container-id / entity-id detection (libdd-common's +// entity_id module) is gated `#[cfg(unix)]` and therefore inert on the +// `wasm32-unknown-unknown` target we build for, and `DD_EXTERNAL_ENV` is also +// unreachable from wasm. Node, however, can read `/proc` and `process.env`, so +// we detect the same values here and add them as the standard Datadog exporter +// headers (`datadog-container-id`, `datadog-entity-id`, `datadog-external-env`) +// — the headers native libdatadog adds via `Endpoint::set_standard_headers`. +// +// The detection mirrors dd-trace-js's `exporters/common/docker.js` (the proven +// legacy-exporter path) and libdd-common's `compute_entity_id` +// (`ci-` else `in-`). + +// The second alternative is the PCF / Garden regexp; no suffix ($) to avoid +// matching pod UIDs. See +// https://github.com/DataDog/datadog-agent/blob/7.40.x/pkg/util/cgroups/reader.go#L50 +const uuidSource = String.raw`[0-9a-f]{8}[-_][0-9a-f]{4}[-_][0-9a-f]{4}[-_][0-9a-f]{4}[-_][0-9a-f]{12}|[0-9a-f]{8}(?:-[0-9a-f]{4}){4}$` +const containerSource = '[0-9a-f]{64}' +const taskSource = String.raw`[0-9a-f]{32}-\d+` +const lineReg = /^(\d+):([^:]*):(.+)$/m +const entityReg = new RegExp(String.raw`.*(${uuidSource}|${containerSource}|${taskSource})(?:\.scope)?$`, 'm') + +// Detect the entity headers. Parameterized for unit testing; production callers +// use the cached `getEntityHeaders()` with the real cgroup paths / environment. +function detectEntityHeaders (opts = {}) { + const cgroupPath = opts.cgroupPath ?? '/proc/self/cgroup' + const cgroupMount = opts.cgroupMount ?? '/sys/fs/cgroup' + const externalEnv = 'externalEnv' in opts ? opts.externalEnv : process.env.DD_EXTERNAL_ENV + + const headers = {} + + let cgroup = '' + let containerId + try { + cgroup = fs.readFileSync(cgroupPath, 'utf8').trim() + containerId = cgroup.match(entityReg)?.[1] + } catch { /* not in a cgroup, or not Linux */ } + + let inode = 0 + const inodePath = cgroup.match(lineReg)?.[3] + if (inodePath) { + const strippedPath = inodePath.replaceAll(/^\/|\/$/g, '') + try { + inode = fs.statSync(`${cgroupMount}/${strippedPath}`).ino + } catch { /* mount not present */ } + } + + // `ci-` when a container id is found, else `in-` + // — matching libdd-common's `compute_entity_id`. + const entityId = containerId ? `ci-${containerId}` : (inode ? `in-${inode}` : undefined) + + if (containerId) headers['datadog-container-id'] = containerId + if (entityId) headers['datadog-entity-id'] = entityId + // Only emit external-env if it is a clean header value (visible ASCII + space/ + // tab). This rejects CR/LF (header-injection / request-smuggling vector) and + // non-latin1 that the latin1-encoded head rewrite couldn't represent — native + // libdatadog likewise rejects invalid bytes via the http crate's HeaderValue. + if (externalEnv && /^[\t\u0020-\u007E]*$/.test(externalEnv)) { + headers['datadog-external-env'] = externalEnv + } + + return headers +} + +let cachedEntityHeaders +function getEntityHeaders () { + if (cachedEntityHeaders === undefined) { + cachedEntityHeaders = detectEntityHeaders() + } + return cachedEntityHeaders +} + +// Rewrite the Rust-rendered HTTP/1.1 request head (a `\r\n`-delimited byte +// buffer terminated by a blank line) to carry the detected entity headers. +// Any pre-existing line for a name we set is dropped first, so libdatadog's +// empty `datadog-container-id` is replaced rather than duplicated. Header +// names/values are ASCII, so latin1 is a lossless round-trip. +function applyEntityHeaders (headView, entity = getEntityHeaders()) { + const names = Object.keys(entity) + if (names.length === 0) return Buffer.from(headView) + + const head = Buffer.from(headView).toString('latin1') + const term = head.indexOf('\r\n\r\n') + if (term === -1) return Buffer.from(headView) // malformed; leave untouched + + const drop = new Set(names) + const lines = head.slice(0, term).split('\r\n') + const kept = lines.filter((line, i) => { + if (i === 0) return true // request line + const colon = line.indexOf(':') + const name = (colon === -1 ? line : line.slice(0, colon)).trim().toLowerCase() + return !drop.has(name) + }) + for (const name of names) kept.push(`${name}: ${entity[name]}`) + + return Buffer.from(`${kept.join('\r\n')}\r\n\r\n`, 'latin1') +} + // A retried write can race a wasm-memory detach; treat that as a transient error. function isDetachedBufferError (err) { return err instanceof TypeError && /detached/i.test(err.message) @@ -38,6 +136,13 @@ module.exports.setResponseHeaderObserver = function (new_observer) { responseHeaderObserver = new_observer } +// Exposed for unit tests. +module.exports.detectEntityHeaders = detectEntityHeaders +module.exports.applyEntityHeaders = applyEntityHeaders +module.exports._resetEntityHeadersCache = () => { + cachedEntityHeaders = undefined +} + module.exports.httpRequest = function (host, port, isHttps, socketPath, head_ptr, head_len, body_ptr, body_len, wasm_memory) { // A non-empty socketPath routes over a Unix domain socket (or Windows named // pipe) instead of TCP. Sockets are always plaintext HTTP/1.1, so https is @@ -92,7 +197,7 @@ module.exports.httpRequest = function (host, port, isHttps, socketPath, head_ptr // makes write/end skip _implicitHeader and _send prepends our bytes. try { - req._header = Buffer.from(headView) + req._header = applyEntityHeaders(headView) req.write(bodyView) req.end() } catch (error) { diff --git a/package.json b/package.json index 020736a..da5806c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@datadog/libdatadog", - "version": "0.13.0", + "version": "0.14.0", "description": "Node.js binding for libdatadog", "main": "index.js", "scripts": { diff --git a/test/http_transport.js b/test/http_transport.js index 656f713..057f5f0 100644 --- a/test/http_transport.js +++ b/test/http_transport.js @@ -171,3 +171,171 @@ describe('http_transport unix socket', { skip: process.platform === 'win32' }, ( assert.strictEqual(Buffer.from(body).toString('utf8'), RESPONSE_BODY) }) }) + +// Entity-header injection: container-id / entity-id / external-env detection +// (Node reads /proc + env; libdatadog's own detection is inert on wasm) and the +// rewrite of the Rust-rendered request head that carries them. +const { detectEntityHeaders, applyEntityHeaders } = transport + +const DOCKER_CGROUP = '12:memory:/docker/3726184226f5d3147c25fdeab5b60097e378e8a720503a5e19ecfdf29f869860' +const DOCKER_ID = '3726184226f5d3147c25fdeab5b60097e378e8a720503a5e19ecfdf29f869860' + +function writeTmpCgroup (contents) { + const p = path.join(os.tmpdir(), `ldn-cgroup-${process.pid}-${Math.random().toString(36).slice(2)}`) + fs.writeFileSync(p, contents) + return p +} + +function headBytes (lines) { + return Buffer.from(lines.join('\r\n') + '\r\n\r\n', 'latin1') +} + +describe('http_transport entity headers', () => { + describe('detectEntityHeaders', () => { + it('extracts a docker container-id and derives ci- entity-id', () => { + const cgroupPath = writeTmpCgroup(DOCKER_CGROUP) + try { + const h = detectEntityHeaders({ cgroupPath, cgroupMount: '/nonexistent', externalEnv: undefined }) + assert.strictEqual(h['datadog-container-id'], DOCKER_ID) + assert.strictEqual(h['datadog-entity-id'], `ci-${DOCKER_ID}`) + assert.strictEqual('datadog-external-env' in h, false) + } finally { + fs.rmSync(cgroupPath, { force: true }) + } + }) + + it('falls back to in- entity-id when no container-id is present', () => { + const cgroupPath = writeTmpCgroup('0::/') + try { + const h = detectEntityHeaders({ cgroupPath, cgroupMount: os.tmpdir(), externalEnv: undefined }) + assert.strictEqual('datadog-container-id' in h, false) + assert.match(h['datadog-entity-id'], /^in-\d+$/) + } finally { + fs.rmSync(cgroupPath, { force: true }) + } + }) + + it('emits datadog-external-env from the provided value', () => { + const h = detectEntityHeaders({ cgroupPath: '/nonexistent', cgroupMount: '/nonexistent', externalEnv: 'it-false,cn-svc,pu-x' }) + assert.strictEqual(h['datadog-external-env'], 'it-false,cn-svc,pu-x') + }) + + it('emits nothing without cgroup, mount, or external-env', () => { + const h = detectEntityHeaders({ cgroupPath: '/nonexistent', cgroupMount: '/nonexistent', externalEnv: undefined }) + assert.deepStrictEqual(h, {}) + }) + + it('rejects an external-env containing CR/LF (header-injection guard)', () => { + const h = detectEntityHeaders({ + cgroupPath: '/nonexistent', + cgroupMount: '/nonexistent', + externalEnv: 'ok\r\nx-evil: 1', + }) + assert.strictEqual('datadog-external-env' in h, false) + }) + }) + + describe('applyEntityHeaders (head rewrite)', () => { + const entity = { + 'datadog-container-id': DOCKER_ID, + 'datadog-entity-id': `ci-${DOCKER_ID}`, + 'datadog-external-env': 'it-false,cn-svc,pu-x', + } + + it('appends entity headers and preserves the request line + framing headers', () => { + const head = headBytes([ + 'POST /v0.4/traces HTTP/1.1', + 'Host: localhost:8126', + 'Content-Length: 42', + 'datadog-meta-lang: nodejs', + ]) + const out = applyEntityHeaders(head, entity).toString('latin1') + const lines = out.split('\r\n') + assert.strictEqual(lines[0], 'POST /v0.4/traces HTTP/1.1') + assert.ok(lines.includes('Host: localhost:8126')) + assert.ok(lines.includes('Content-Length: 42')) + assert.ok(lines.includes('datadog-meta-lang: nodejs')) + assert.ok(lines.includes(`datadog-container-id: ${DOCKER_ID}`)) + assert.ok(lines.includes(`datadog-entity-id: ci-${DOCKER_ID}`)) + assert.ok(lines.includes('datadog-external-env: it-false,cn-svc,pu-x')) + assert.ok(out.endsWith('\r\n\r\n')) + }) + + it('replaces libdatadog\'s empty datadog-container-id instead of duplicating it', () => { + const head = headBytes([ + 'POST /v0.4/traces HTTP/1.1', + 'Host: localhost', + 'Content-Length: 0', + 'datadog-container-id: ', + ]) + const out = applyEntityHeaders(head, entity).toString('latin1') + const count = out.split('\r\n').filter(l => l.toLowerCase().startsWith('datadog-container-id:')).length + assert.strictEqual(count, 1) + assert.ok(out.includes(`datadog-container-id: ${DOCKER_ID}`)) + }) + + it('returns the head unchanged when no entity headers are detected', () => { + const head = headBytes(['POST / HTTP/1.1', 'Host: x', 'Content-Length: 0']) + const out = applyEntityHeaders(head, {}) + assert.deepStrictEqual(out, Buffer.from(head)) + }) + + it('leaves a malformed head (no terminator) untouched', () => { + const bad = Buffer.from('POST / HTTP/1.1\r\nHost: x', 'latin1') + const out = applyEntityHeaders(bad, entity) + assert.deepStrictEqual(out, Buffer.from(bad)) + }) + }) + + describe('httpRequest end-to-end (real transport)', () => { + let server + let port + let received + const prevExternalEnv = process.env.DD_EXTERNAL_ENV + + before(async () => { + // Set the env then clear the memoized detection so this request re-reads + // it (earlier tests may have already populated the cache). + process.env.DD_EXTERNAL_ENV = 'it-false,cn-e2e,pu-1' + transport._resetEntityHeadersCache() + server = http.createServer((req, res) => { + received = req.headers + res.writeHead(200, { 'content-type': 'application/json' }) + res.end('{}') + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + port = server.address().port + }) + + after(() => new Promise(resolve => server.close(() => { + if (prevExternalEnv === undefined) delete process.env.DD_EXTERNAL_ENV + else process.env.DD_EXTERNAL_ENV = prevExternalEnv + transport._resetEntityHeadersCache() + resolve() + }))) + + it('sends the detected entity headers on the wire (via the Rust-rendered head)', async () => { + const body = Buffer.from('[]', 'latin1') + const head = Buffer.from( + `POST /v0.4/traces HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nContent-Length: ${body.length}\r\n` + + 'datadog-meta-lang: nodejs\r\ndatadog-container-id: \r\n\r\n', + 'latin1', + ) + const mem = new ArrayBuffer(head.length + body.length) + const view = new Uint8Array(mem) + view.set(head, 0) + view.set(body, head.length) + + const [status] = await transport.httpRequest( + '127.0.0.1', port, false, '', 0, head.length, head.length, body.length, { buffer: mem }, + ) + assert.strictEqual(status, 200) + assert.strictEqual(received['datadog-meta-lang'], 'nodejs') + assert.strictEqual(received['datadog-external-env'], 'it-false,cn-e2e,pu-1') + const detected = detectEntityHeaders() + if (detected['datadog-container-id']) { + assert.strictEqual(received['datadog-container-id'], detected['datadog-container-id']) + } + }) + }) +})