Skip to content

Commit 17c76b5

Browse files
committed
Use a built-in DNS server to support wildcard certs
1 parent 18491dc commit 17c76b5

12 files changed

Lines changed: 448 additions & 98 deletions

fly.toml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,14 @@ primary_region = 'cdg'
1212

1313
# If deploying a separate instance and wanting real certificates, customize these:
1414
ROOT_DOMAIN = 'testserver.host'
15-
PROACTIVE_CERT_DOMAINS = 'testserver.host,example.testserver.host,http1.testserver.host,http2.testserver.host'
15+
PROACTIVE_CERT_DOMAINS = 'testserver.host,example.testserver.host,revoked.testserver.host,expired.testserver.host,expired--revoked.testserver.host'
1616

1717
ACME_PROVIDER = 'google' # or 'letsencrypt', 'zerossl'
1818
# Set ACME_ACCOUNT_KEY secret (PEM format) - see src/tls-certificates/acme.ts for details
1919

20+
# Enable in-process DNS server on port 53 for wildcard certs via DNS-01 (requires NS delegation)
21+
DNS_SERVER = 'true'
22+
2023
[[services]]
2124
protocol = "tcp"
2225
internal = 8080
@@ -40,6 +43,14 @@ primary_region = 'cdg'
4043
tls_server_name = "testserver.host"
4144
tls_skip_verify = true # We don't verify by default, so this works for fresh empty-volume deploys
4245

46+
# DNS server
47+
[[services]]
48+
protocol = "udp"
49+
internal_port = 53
50+
51+
[[services.ports]]
52+
port = 53
53+
4354
# If we want to scale out within a single region, where there's already an app in the same region
4455
# using this volume, we need to fork it and create another with the same name.
4556
[mounts]

package-lock.json

Lines changed: 30 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,15 @@
3737
"@peculiar/x509": "^1.14.3",
3838
"acme-client": "^5.4.0",
3939
"cookie": "^1.0.2",
40+
"dns-packet": "^5.6.1",
4041
"lodash": "^4.17.23",
4142
"parse-multipart-data": "^1.5.0",
4243
"read-tls-client-hello": "^1.1.0",
4344
"tsx": "^4.19.3"
4445
},
4546
"devDependencies": {
4647
"@types/chai": "^4.3.14",
48+
"@types/dns-packet": "^5.6.5",
4749
"@types/lodash": "^4.17.0",
4850
"@types/mocha": "^10.0.6",
4951
"@types/node": "^22.15.30",

src/dns-server.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import * as dgram from 'dgram';
2+
import * as dnsPacket from 'dns-packet';
3+
4+
/**
5+
* Minimal authoritative DNS server for ACME DNS-01 challenges.
6+
* Responds to TXT record queries with values set via setTxtRecord().
7+
* All other queries receive an empty authoritative response.
8+
*/
9+
export class DnsServer {
10+
private socket: dgram.Socket;
11+
private txtRecords = new Map<string, Set<string>>();
12+
13+
constructor(private port = 53) {
14+
this.socket = dgram.createSocket('udp4');
15+
this.socket.on('message', (msg, rinfo) => this.handleQuery(msg, rinfo));
16+
this.socket.on('error', (err) => {
17+
console.error('DNS server error:', err);
18+
});
19+
}
20+
21+
setTxtRecord(fqdn: string, value: string) {
22+
const key = fqdn.toLowerCase();
23+
if (!this.txtRecords.has(key)) this.txtRecords.set(key, new Set());
24+
this.txtRecords.get(key)!.add(value);
25+
console.log(`DNS: Set TXT record for ${fqdn} = ${value}`);
26+
}
27+
28+
removeTxtRecord(fqdn: string, value: string) {
29+
const key = fqdn.toLowerCase();
30+
this.txtRecords.get(key)?.delete(value);
31+
if (this.txtRecords.get(key)?.size === 0) {
32+
this.txtRecords.delete(key);
33+
}
34+
console.log(`DNS: Removed TXT record for ${fqdn}`);
35+
}
36+
37+
private handleQuery(msg: Buffer, rinfo: dgram.RemoteInfo) {
38+
try {
39+
const query = dnsPacket.decode(msg);
40+
const question = query.questions?.[0];
41+
if (!question) return;
42+
43+
const name = question.name.toLowerCase();
44+
const values = this.txtRecords.get(name);
45+
46+
const answers: dnsPacket.TxtAnswer[] =
47+
(question.type === 'TXT' && values?.size)
48+
? [...values].map(v => ({
49+
type: 'TXT' as const,
50+
class: 'IN' as const,
51+
name: question.name,
52+
ttl: 60,
53+
data: v
54+
}))
55+
: [];
56+
57+
const response = dnsPacket.encode({
58+
type: 'response',
59+
id: query.id,
60+
flags: dnsPacket.AUTHORITATIVE_ANSWER,
61+
questions: query.questions,
62+
answers
63+
});
64+
65+
this.socket.send(response, rinfo.port, rinfo.address);
66+
} catch (err) {
67+
console.error('DNS: Failed to handle query:', err);
68+
}
69+
}
70+
71+
listen(): Promise<void> {
72+
return new Promise<void>((resolve, reject) => {
73+
this.socket.once('error', reject);
74+
this.socket.bind(this.port, '0.0.0.0', () => {
75+
this.socket.removeListener('error', reject);
76+
console.log(`DNS server listening on port ${this.port}`);
77+
resolve();
78+
});
79+
});
80+
}
81+
82+
close(): Promise<void> {
83+
return new Promise<void>((resolve) => {
84+
this.socket.close(() => resolve());
85+
});
86+
}
87+
88+
}

src/server.ts

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import { ConnectionProcessor } from './process-connection.js';
1515
import { AcmeCA, AcmeProvider } from './tls-certificates/acme.js';
1616
import { LocalCA, generateCACertificate } from './tls-certificates/local-ca.js';
1717
import { PersistentCertCache } from './tls-certificates/cert-cache.js';
18+
import { DnsServer } from './dns-server.js';
19+
import { tlsEndpoints } from './endpoints/endpoint-index.js';
1820

1921
declare module 'stream' {
2022
interface Duplex {
@@ -38,6 +40,13 @@ interface ServerOptions {
3840
certCacheDir?: string;
3941
localCaKey?: string;
4042
localCaCert?: string;
43+
dnsServer?: boolean;
44+
}
45+
46+
function isWildcardCoverable(domain: string, rootDomain: string): boolean {
47+
if (!domain.endsWith(`.${rootDomain}`)) return false;
48+
const prefix = domain.slice(0, -rootDomain.length - 1);
49+
return !prefix.includes('.'); // Single-level subdomain only
4150
}
4251

4352
async function generateTlsConfig(options: ServerOptions) {
@@ -58,7 +67,20 @@ async function generateTlsConfig(options: ServerOptions) {
5867
}
5968

6069
if (certCache) {
61-
await certCache.loadCache();
70+
const validSniParts = new Set(tlsEndpoints.map(e => e.sniPart));
71+
await certCache.loadCache((domain) => { // Temp logic to clean up old cached certs
72+
// Root domain and wildcard are always valid
73+
if (domain === rootDomain || domain === `*.${rootDomain}`) return true;
74+
75+
// Strip root domain suffix to get the prefix
76+
if (!domain.endsWith(`.${rootDomain}`)) return false;
77+
const prefix = domain.slice(0, -rootDomain.length - 1);
78+
if (!prefix) return false;
79+
80+
// Split by -- or . (same logic as getSNIPrefixParts)
81+
const parts = prefix.includes('--') ? prefix.split('--') : prefix.split('.');
82+
return parts.every(part => validSniParts.has(part));
83+
});
6284
}
6385

6486
const localCA = await LocalCA.create(caCert);
@@ -95,7 +117,16 @@ async function generateTlsConfig(options: ServerOptions) {
95117
throw new Error(`Can't enable ACME without configuring an account key (via $ACME_ACCOUNT_KEY)`);
96118
}
97119

98-
const acmeCA = new AcmeCA(certCache!, options.acmeProvider, options.acmeAccountKey);
120+
// Set up in-process DNS server for wildcard certs via DNS-01 (optional)
121+
let dnsServer: DnsServer | undefined;
122+
123+
if (options.dnsServer) {
124+
dnsServer = new DnsServer(53);
125+
await dnsServer.listen();
126+
console.log('Using in-process DNS server for wildcard certs');
127+
}
128+
129+
const acmeCA = new AcmeCA(certCache!, options.acmeProvider, options.acmeAccountKey, dnsServer);
99130
acmeCA.tryGetCertificateSync(rootDomain, {}); // Preload the root domain every time
100131

101132
return {
@@ -105,22 +136,29 @@ async function generateTlsConfig(options: ServerOptions) {
105136
cert: defaultCert.cert,
106137
ca: caCert.cert,
107138
localCA,
108-
generateCertificate: async (domain: string, options: CertOptions) => {
109-
if (options.requiredType === 'local') {
110-
return await localCA.generateCertificate(domain, options);
139+
generateCertificate: async (domain: string, certOptions: CertOptions) => {
140+
if (certOptions.requiredType === 'local') {
141+
return await localCA.generateCertificate(domain, certOptions);
111142
}
112143

113-
const cert = acmeCA.tryGetCertificateSync(domain, options);
144+
// Use wildcard when: DNS server available, single-level subdomain, no overridePrefix
145+
const useWildcard = dnsServer
146+
&& isWildcardCoverable(domain, rootDomain)
147+
&& !certOptions.overridePrefix;
148+
149+
const effectiveDomain = useWildcard ? `*.${rootDomain}` : domain;
150+
151+
const cert = acmeCA.tryGetCertificateSync(effectiveDomain, certOptions);
114152

115153
if (cert) {
116154
return cert;
117155
} else {
118-
if (options.requiredType === 'acme') {
119-
return await acmeCA.waitForCertificate(domain, options);
156+
if (certOptions.requiredType === 'acme') {
157+
return await acmeCA.waitForCertificate(effectiveDomain, certOptions);
120158
}
121159
// Local CA fallback while ACME cert is pending - mark as temporary
122160
// so it gets a short cache time and ACME cert is used once available
123-
const fallbackCert = await localCA.generateCertificate(domain, options);
161+
const fallbackCert = await localCA.generateCertificate(domain, certOptions);
124162
return { ...fallbackCert, isTemporary: true };
125163
}
126164
},
@@ -193,7 +231,8 @@ if (wasRunDirectly) {
193231
acmeAccountKey: process.env.ACME_ACCOUNT_KEY,
194232
certCacheDir: process.env.CERT_CACHE_DIR,
195233
localCaKey: process.env.LOCAL_CA_KEY,
196-
localCaCert: process.env.LOCAL_CA_CERT
234+
localCaCert: process.env.LOCAL_CA_CERT,
235+
dnsServer: process.env.DNS_SERVER === 'true'
197236
}).then((tcpHandler) => {
198237
ports.forEach((port) => {
199238
const server = createTcpServer(tcpHandler);

0 commit comments

Comments
 (0)