Skip to content

Commit a1dfb62

Browse files
feat: added proxy
1 parent 449c6f5 commit a1dfb62

4 files changed

Lines changed: 335 additions & 4 deletions

File tree

.github/workflows/docker-build.yml

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
name: Build and Push Docker Image
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
tags:
8+
- 'v*'
9+
pull_request:
10+
branches:
11+
- main
12+
workflow_dispatch:
13+
14+
env:
15+
REGISTRY: ghcr.io
16+
IMAGE_NAME: ${{ github.repository }}
17+
18+
jobs:
19+
build-and-push:
20+
runs-on: ubuntu-latest
21+
permissions:
22+
contents: read
23+
packages: write
24+
25+
steps:
26+
- name: Checkout repository
27+
uses: actions/checkout@v4
28+
29+
- name: Set up QEMU
30+
uses: docker/setup-qemu-action@v3
31+
with:
32+
platforms: linux/amd64,linux/arm64,linux/arm/v7
33+
34+
- name: Set up Docker Buildx
35+
uses: docker/setup-buildx-action@v3
36+
with:
37+
platforms: linux/amd64,linux/arm64,linux/arm/v7
38+
39+
- name: Log in to GitHub Container Registry
40+
if: github.event_name != 'pull_request'
41+
uses: docker/login-action@v3
42+
with:
43+
registry: ${{ env.REGISTRY }}
44+
username: ${{ github.actor }}
45+
password: ${{ secrets.GITHUB_TOKEN }}
46+
47+
- name: Extract metadata
48+
id: meta
49+
uses: docker/metadata-action@v5
50+
with:
51+
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
52+
tags: |
53+
type=ref,event=branch
54+
type=ref,event=pr
55+
type=semver,pattern={{version}}
56+
type=semver,pattern={{major}}.{{minor}}
57+
type=semver,pattern={{major}}
58+
type=sha,prefix={{branch}}-
59+
type=raw,value=latest,enable={{is_default_branch}}
60+
61+
- name: Build and push Docker image
62+
uses: docker/build-push-action@v5
63+
with:
64+
context: .
65+
platforms: linux/amd64,linux/arm64,linux/arm/v7
66+
push: ${{ github.event_name != 'pull_request' }}
67+
tags: ${{ steps.meta.outputs.tags }}
68+
labels: ${{ steps.meta.outputs.labels }}
69+
cache-from: type=gha
70+
cache-to: type=gha,mode=max
71+
72+
- name: Generate artifact attestation
73+
if: github.event_name != 'pull_request'
74+
uses: actions/attest-build-provenance@v1
75+
with:
76+
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
77+
subject-digest: ${{ steps.meta.outputs.digest }}
78+
push-to-registry: true

Dockerfile

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# External-DNS Firewalla Webhook Proxy
2+
# Lightweight Node.js container that proxies external-dns requests to Firewalla
3+
4+
FROM node:20-alpine
5+
6+
LABEL org.opencontainers.image.source="https://github.com/TheOutdoorProgrammer/external-dns-firewalla-webhook"
7+
LABEL org.opencontainers.image.description="External-DNS webhook proxy for Firewalla devices"
8+
LABEL org.opencontainers.image.licenses="MIT"
9+
10+
# Create app directory
11+
WORKDIR /app
12+
13+
# Copy proxy script
14+
COPY webhook-proxy/proxy.js .
15+
16+
# Create non-root user
17+
RUN addgroup -g 1000 webhook && \
18+
adduser -D -u 1000 -G webhook webhook && \
19+
chown -R webhook:webhook /app
20+
21+
# Switch to non-root user
22+
USER webhook
23+
24+
# Expose ports
25+
# 8888: Webhook API (external-dns talks to this)
26+
# 8080: Health/Metrics endpoint
27+
EXPOSE 8888 8080
28+
29+
# Health check
30+
HEALTHCHECK --interval=10s --timeout=5s --start-period=5s --retries=3 \
31+
CMD node -e "require('http').get('http://localhost:8080/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1); }).on('error', () => { process.exit(1); });"
32+
33+
# Environment variables (can be overridden)
34+
ENV FIREWALLA_HOST=192.168.229.1 \
35+
FIREWALLA_PROVIDER_PORT=8888 \
36+
FIREWALLA_HEALTH_PORT=8080 \
37+
WEBHOOK_PORT=8888 \
38+
METRICS_PORT=8080 \
39+
NODE_ENV=production
40+
41+
# Start the proxy
42+
CMD ["node", "proxy.js"]

README.md

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,38 @@ A webhook provider for [external-dns](https://github.com/kubernetes-sigs/externa
66

77
This webhook provider allows Kubernetes external-dns to automatically create, update, and delete DNS records on your Firewalla device. It's perfect for home labs and small deployments where you want to use your Firewalla as the authoritative DNS server for your local domains.
88

9+
### Architecture
10+
11+
```mermaid
12+
graph TB
13+
subgraph "Kubernetes Cluster"
14+
A[Service/Ingress<br/>with DNS annotations] --> B[External-DNS<br/>Controller]
15+
B --> C[Webhook Proxy<br/>Sidecar Container]
16+
end
17+
18+
subgraph "Firewalla Device"
19+
D[Webhook Provider<br/>Node.js Server :8888] --> E[dnsmasq Config Files<br/>~/.firewalla/config/dnsmasq_local/]
20+
E --> F[firerouter_dns<br/>Service]
21+
F --> G[DNS Resolution<br/>Network-wide]
22+
end
23+
24+
C -->|HTTP Proxy| D
25+
26+
style A fill:#e1f5ff
27+
style B fill:#fff4e1
28+
style C fill:#ffe1e1
29+
style D fill:#e1ffe1
30+
style E fill:#f0f0f0
31+
style F fill:#e1e1ff
32+
style G fill:#ffe1ff
33+
```
34+
935
### How It Works
1036

11-
1. External-DNS running in your Kubernetes cluster detects services/ingresses that need DNS records
12-
2. External-DNS sends webhook requests to this provider running on your Firewalla
13-
3. The provider translates these requests into dnsmasq configuration files
14-
4. DNS records are immediately available on your network via Firewalla's DNS server
37+
1. **External-DNS** running in your Kubernetes cluster detects services/ingresses that need DNS records
38+
2. **Webhook Proxy** sidecar container receives requests from external-dns and forwards them to Firewalla
39+
3. **Firewalla Webhook Provider** translates these requests into dnsmasq configuration files
40+
4. **DNS records** are immediately available on your network via Firewalla's DNS server
1541

1642
### Features
1743

webhook-proxy/proxy.js

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
/**
2+
* External-DNS Webhook Proxy
3+
*
4+
* Lightweight HTTP proxy that forwards external-dns webhook requests
5+
* from the Kubernetes cluster to the Firewalla webhook provider.
6+
*
7+
* This runs as a sidecar in the external-dns pod and proxies requests
8+
* to the actual webhook provider running on the Firewalla device.
9+
*/
10+
11+
const http = require('http');
12+
const https = require('https');
13+
14+
// Configuration from environment variables
15+
const FIREWALLA_HOST = process.env.FIREWALLA_HOST || '192.168.229.1';
16+
const FIREWALLA_PROVIDER_PORT = process.env.FIREWALLA_PROVIDER_PORT || '8888';
17+
const FIREWALLA_HEALTH_PORT = process.env.FIREWALLA_HEALTH_PORT || '8080';
18+
const WEBHOOK_PORT = process.env.WEBHOOK_PORT || '8888';
19+
const METRICS_PORT = process.env.METRICS_PORT || '8080';
20+
21+
// Build Firewalla URLs
22+
const FIREWALLA_PROVIDER_URL = `http://${FIREWALLA_HOST}:${FIREWALLA_PROVIDER_PORT}`;
23+
const FIREWALLA_HEALTH_URL = `http://${FIREWALLA_HOST}:${FIREWALLA_HEALTH_PORT}`;
24+
25+
console.log('Starting External-DNS Webhook Proxy');
26+
console.log(`Firewalla Provider: ${FIREWALLA_PROVIDER_URL}`);
27+
console.log(`Firewalla Health: ${FIREWALLA_HEALTH_URL}`);
28+
console.log(`Webhook Port: ${WEBHOOK_PORT}`);
29+
console.log(`Metrics Port: ${METRICS_PORT}`);
30+
31+
/**
32+
* Proxy HTTP request to Firewalla
33+
*/
34+
function proxyRequest(clientReq, clientRes, targetUrl) {
35+
const startTime = Date.now();
36+
const url = new URL(clientReq.url, targetUrl);
37+
38+
const options = {
39+
hostname: url.hostname,
40+
port: url.port,
41+
path: url.pathname + url.search,
42+
method: clientReq.method,
43+
headers: {
44+
...clientReq.headers,
45+
'host': url.host,
46+
'x-forwarded-for': clientReq.socket.remoteAddress,
47+
'x-forwarded-proto': 'http',
48+
'x-forwarded-host': clientReq.headers.host
49+
}
50+
};
51+
52+
console.log(`[${clientReq.method}] ${clientReq.url} -> ${url.href}`);
53+
54+
const proxyReq = http.request(options, (proxyRes) => {
55+
// Forward status code and headers
56+
clientRes.writeHead(proxyRes.statusCode, proxyRes.headers);
57+
58+
// Pipe response body
59+
proxyRes.pipe(clientRes);
60+
61+
proxyRes.on('end', () => {
62+
const duration = Date.now() - startTime;
63+
console.log(`[${clientReq.method}] ${clientReq.url} - ${proxyRes.statusCode} (${duration}ms)`);
64+
});
65+
});
66+
67+
proxyReq.on('error', (err) => {
68+
console.error(`Proxy error for ${clientReq.url}:`, err.message);
69+
if (!clientRes.headersSent) {
70+
clientRes.writeHead(502, { 'Content-Type': 'application/json' });
71+
clientRes.end(JSON.stringify({
72+
error: 'Bad Gateway',
73+
message: `Failed to connect to Firewalla: ${err.message}`
74+
}));
75+
}
76+
});
77+
78+
// Forward request body if present
79+
if (clientReq.method !== 'GET' && clientReq.method !== 'HEAD') {
80+
clientReq.pipe(proxyReq);
81+
} else {
82+
proxyReq.end();
83+
}
84+
}
85+
86+
/**
87+
* Webhook server - proxies external-dns requests to Firewalla
88+
*/
89+
const webhookServer = http.createServer((req, res) => {
90+
proxyRequest(req, res, FIREWALLA_PROVIDER_URL);
91+
});
92+
93+
/**
94+
* Metrics/Health server - provides health and readiness endpoints
95+
*/
96+
const metricsServer = http.createServer((req, res) => {
97+
const url = new URL(req.url, `http://${req.headers.host}`);
98+
99+
// Health check endpoint
100+
if (url.pathname === '/health' || url.pathname === '/healthz') {
101+
// Proxy to Firewalla health endpoint
102+
proxyRequest(req, res, FIREWALLA_HEALTH_URL);
103+
return;
104+
}
105+
106+
// Readiness check endpoint
107+
if (url.pathname === '/ready' || url.pathname === '/readyz') {
108+
// Check if we can reach Firewalla
109+
const healthCheck = http.get(`${FIREWALLA_HEALTH_URL}/healthz`, (healthRes) => {
110+
if (healthRes.statusCode === 200) {
111+
res.writeHead(200, { 'Content-Type': 'text/plain' });
112+
res.end('ready');
113+
} else {
114+
res.writeHead(503, { 'Content-Type': 'text/plain' });
115+
res.end('not ready');
116+
}
117+
});
118+
119+
healthCheck.on('error', (err) => {
120+
console.error('Readiness check failed:', err.message);
121+
res.writeHead(503, { 'Content-Type': 'text/plain' });
122+
res.end('not ready');
123+
});
124+
125+
healthCheck.setTimeout(5000, () => {
126+
healthCheck.destroy();
127+
res.writeHead(503, { 'Content-Type': 'text/plain' });
128+
res.end('not ready - timeout');
129+
});
130+
return;
131+
}
132+
133+
// Metrics endpoint (basic)
134+
if (url.pathname === '/metrics') {
135+
res.writeHead(200, { 'Content-Type': 'text/plain' });
136+
res.end('# No metrics implemented yet\n');
137+
return;
138+
}
139+
140+
// Unknown endpoint
141+
res.writeHead(404, { 'Content-Type': 'text/plain' });
142+
res.end('Not Found');
143+
});
144+
145+
// Start servers
146+
webhookServer.listen(WEBHOOK_PORT, '0.0.0.0', () => {
147+
console.log(`Webhook proxy listening on port ${WEBHOOK_PORT}`);
148+
});
149+
150+
metricsServer.listen(METRICS_PORT, '0.0.0.0', () => {
151+
console.log(`Metrics server listening on port ${METRICS_PORT}`);
152+
});
153+
154+
// Graceful shutdown
155+
const shutdown = (signal) => {
156+
console.log(`Received ${signal}, shutting down gracefully...`);
157+
158+
webhookServer.close(() => {
159+
console.log('Webhook server closed');
160+
metricsServer.close(() => {
161+
console.log('Metrics server closed');
162+
process.exit(0);
163+
});
164+
});
165+
166+
// Force exit after 10 seconds
167+
setTimeout(() => {
168+
console.error('Forced shutdown after timeout');
169+
process.exit(1);
170+
}, 10000);
171+
};
172+
173+
process.on('SIGTERM', () => shutdown('SIGTERM'));
174+
process.on('SIGINT', () => shutdown('SIGINT'));
175+
176+
// Error handling
177+
process.on('uncaughtException', (err) => {
178+
console.error('Uncaught exception:', err);
179+
process.exit(1);
180+
});
181+
182+
process.on('unhandledRejection', (reason, promise) => {
183+
console.error('Unhandled rejection at:', promise, 'reason:', reason);
184+
process.exit(1);
185+
});

0 commit comments

Comments
 (0)