Skip to content

Commit 059100d

Browse files
Implement proper webhook content negotiation per external-dns spec
- Proxy now echoes Accept header back as Content-Type (per webhook spec) - Firewalla webhook validates Accept header and uses res.send() to avoid Express charset - All JSON endpoints now properly handle content negotiation - Fixes external-dns v0.20.0 strict content-type validation The external-dns webhook spec requires the server to read the Accept header and respond with that exact value as Content-Type: application/external.dns.webhook+json;version=1
1 parent 7290e29 commit 059100d

4 files changed

Lines changed: 69 additions & 17 deletions

File tree

src/controllers/adjustEndpoints.js

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,53 @@ const logger = require('../utils/logger');
88
const validator = require('../utils/validator');
99

1010
/**
11-
* Adjust endpoints - filter out unsupported record types
11+
* Handle adjust endpoints request
1212
*/
1313
function adjustEndpoints(req, res) {
1414
const endpoints = req.body;
15+
const acceptHeader = req.get('Accept');
1516

1617
logger.debug('Adjust endpoints request received', {
17-
count: Array.isArray(endpoints) ? endpoints.length : 0
18+
count: (endpoints || []).length,
19+
accept: acceptHeader
1820
});
1921

20-
// Validate input
22+
// Validate Accept header
23+
if (acceptHeader && !acceptHeader.includes(config.contentType)) {
24+
logger.warn('Unsupported Accept header', { accept: acceptHeader });
25+
return res.status(406).json({ error: 'Not Acceptable' });
26+
}
27+
28+
// Validate request body
2129
if (!Array.isArray(endpoints)) {
22-
logger.warn('Invalid request body for adjust endpoints (not an array)');
23-
return res.status(400).json({ error: 'Request body must be an array of endpoints' });
30+
logger.warn('Invalid request body for adjust endpoints');
31+
return res.status(400).json({ error: 'Invalid request body' });
2432
}
2533

34+
// Filter out unsupported record types
35+
// We only support A and TXT records
36+
const filtered = endpoints.filter(endpoint => {
37+
const supported = SUPPORTED_RECORD_TYPES.has(endpoint.recordType);
38+
if (!supported) {
39+
logger.debug('Filtering out unsupported record type', {
40+
dnsName: endpoint.dnsName,
41+
recordType: endpoint.recordType
42+
});
43+
}
44+
return supported;
45+
});
46+
47+
logger.info('Adjust endpoints completed', {
48+
input: endpoints.length,
49+
output: filtered.length,
50+
filtered: endpoints.length - filtered.length
51+
});
52+
53+
// Set exact content type (no charset) for external-dns webhook protocol
54+
res.setHeader('Content-Type', config.contentType);
55+
res.send(JSON.stringify(filtered));
56+
}
57+
2658
// Filter endpoints:
2759
// 1. Keep only A and TXT record types
2860
// 2. Remove invalid endpoints

src/controllers/negotiate.js

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,30 @@ const logger = require('../utils/logger');
1010
* Handle negotiate request
1111
*/
1212
function negotiate(req, res) {
13+
const acceptHeader = req.get('Accept');
14+
1315
logger.debug('Negotiate request received', {
14-
accept: req.get('Accept'),
16+
accept: acceptHeader,
1517
userAgent: req.get('User-Agent')
1618
});
1719

20+
// Validate Accept header matches our supported media type
21+
// External-DNS sends: application/external.dns.webhook+json;version=1
22+
if (acceptHeader && !acceptHeader.includes(config.contentType)) {
23+
logger.warn('Unsupported Accept header', { accept: acceptHeader });
24+
return res.status(406).json({ error: 'Not Acceptable' });
25+
}
26+
1827
// Return domain filter
1928
const response = {
2029
domainFilter: config.domainFilter
2130
};
2231

23-
// Set content type for external-dns webhook protocol
32+
// Respond with the EXACT content type from Accept header
33+
// Must be: application/external.dns.webhook+json;version=1 (no spaces, no charset)
34+
// Use res.send() instead of res.json() to prevent Express from adding charset
2435
res.setHeader('Content-Type', config.contentType);
25-
res.json(response);
36+
res.send(JSON.stringify(response));
2637

2738
logger.info('Negotiate response sent', { domainFilterCount: config.domainFilter.length });
2839
}

src/controllers/records.js

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,22 @@ const updateQueue = [];
1515
* Get all DNS records (GET /records)
1616
*/
1717
async function getRecords(req, res) {
18-
logger.debug('Get records request received');
18+
const acceptHeader = req.get('Accept');
19+
20+
logger.debug('Get records request received', { accept: acceptHeader });
21+
22+
// Validate Accept header
23+
if (acceptHeader && !acceptHeader.includes(config.contentType)) {
24+
logger.warn('Unsupported Accept header', { accept: acceptHeader });
25+
return res.status(406).json({ error: 'Not Acceptable' });
26+
}
1927

2028
try {
2129
const records = await dnsmasqService.getRecords();
2230

23-
// Set content type for external-dns webhook protocol
31+
// Set exact content type (no charset) for external-dns webhook protocol
2432
res.setHeader('Content-Type', config.contentType);
25-
res.json(records);
33+
res.send(JSON.stringify(records));
2634

2735
logger.info('Get records response sent', { count: records.length });
2836

webhook-proxy/proxy.js

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ console.log(`Metrics Port: ${METRICS_PORT}`);
3434
function proxyRequest(clientReq, clientRes, targetUrl) {
3535
const startTime = Date.now();
3636
const url = new URL(clientReq.url, targetUrl);
37+
const acceptHeader = clientReq.headers['accept'];
3738

3839
const options = {
3940
hostname: url.hostname,
@@ -52,15 +53,15 @@ function proxyRequest(clientReq, clientRes, targetUrl) {
5253
console.log(`[${clientReq.method}] ${clientReq.url} -> ${url.href}`);
5354

5455
const proxyReq = http.request(options, (proxyRes) => {
55-
// Clean up headers - remove charset from content-type for external-dns compatibility
56+
// Echo back the Accept header as Content-Type (per webhook spec)
57+
// External-DNS sends Accept: application/external.dns.webhook+json;version=1
58+
// We must respond with the exact same value in Content-Type
5659
const headers = { ...proxyRes.headers };
57-
if (headers['content-type']) {
58-
// External-DNS expects exactly: application/external.dns.webhook+json;version=1
59-
// Express adds charset which breaks strict validation
60-
headers['content-type'] = headers['content-type'].replace(/;\s*charset=[^;]+/, '');
60+
if (acceptHeader) {
61+
headers['content-type'] = acceptHeader;
6162
}
6263

63-
// Forward status code and cleaned headers
64+
// Forward status code and headers
6465
clientRes.writeHead(proxyRes.statusCode, headers);
6566

6667
// Pipe response body

0 commit comments

Comments
 (0)