Skip to content

Commit b708078

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

12 files changed

Lines changed: 425 additions & 96 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,*.testserver.host,example.testserver.host,http1.testserver.host,http2.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: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ 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';
1819

1920
declare module 'stream' {
2021
interface Duplex {
@@ -38,6 +39,13 @@ interface ServerOptions {
3839
certCacheDir?: string;
3940
localCaKey?: string;
4041
localCaCert?: string;
42+
dnsServer?: boolean;
43+
}
44+
45+
function isWildcardCoverable(domain: string, rootDomain: string): boolean {
46+
if (!domain.endsWith(`.${rootDomain}`)) return false;
47+
const prefix = domain.slice(0, -rootDomain.length - 1);
48+
return !prefix.includes('.'); // Single-level subdomain only
4149
}
4250

4351
async function generateTlsConfig(options: ServerOptions) {
@@ -95,7 +103,16 @@ async function generateTlsConfig(options: ServerOptions) {
95103
throw new Error(`Can't enable ACME without configuring an account key (via $ACME_ACCOUNT_KEY)`);
96104
}
97105

98-
const acmeCA = new AcmeCA(certCache!, options.acmeProvider, options.acmeAccountKey);
106+
// Set up in-process DNS server for wildcard certs via DNS-01 (optional)
107+
let dnsServer: DnsServer | undefined;
108+
109+
if (options.dnsServer) {
110+
dnsServer = new DnsServer(53);
111+
await dnsServer.listen();
112+
console.log('Using in-process DNS server for wildcard certs');
113+
}
114+
115+
const acmeCA = new AcmeCA(certCache!, options.acmeProvider, options.acmeAccountKey, dnsServer);
99116
acmeCA.tryGetCertificateSync(rootDomain, {}); // Preload the root domain every time
100117

101118
return {
@@ -105,22 +122,29 @@ async function generateTlsConfig(options: ServerOptions) {
105122
cert: defaultCert.cert,
106123
ca: caCert.cert,
107124
localCA,
108-
generateCertificate: async (domain: string, options: CertOptions) => {
109-
if (options.requiredType === 'local') {
110-
return await localCA.generateCertificate(domain, options);
125+
generateCertificate: async (domain: string, certOptions: CertOptions) => {
126+
if (certOptions.requiredType === 'local') {
127+
return await localCA.generateCertificate(domain, certOptions);
111128
}
112129

113-
const cert = acmeCA.tryGetCertificateSync(domain, options);
130+
// Use wildcard when: DNS server available, single-level subdomain, no overridePrefix
131+
const useWildcard = dnsServer
132+
&& isWildcardCoverable(domain, rootDomain)
133+
&& !certOptions.overridePrefix;
134+
135+
const effectiveDomain = useWildcard ? `*.${rootDomain}` : domain;
136+
137+
const cert = acmeCA.tryGetCertificateSync(effectiveDomain, certOptions);
114138

115139
if (cert) {
116140
return cert;
117141
} else {
118-
if (options.requiredType === 'acme') {
119-
return await acmeCA.waitForCertificate(domain, options);
142+
if (certOptions.requiredType === 'acme') {
143+
return await acmeCA.waitForCertificate(effectiveDomain, certOptions);
120144
}
121145
// Local CA fallback while ACME cert is pending - mark as temporary
122146
// so it gets a short cache time and ACME cert is used once available
123-
const fallbackCert = await localCA.generateCertificate(domain, options);
147+
const fallbackCert = await localCA.generateCertificate(domain, certOptions);
124148
return { ...fallbackCert, isTemporary: true };
125149
}
126150
},
@@ -193,7 +217,8 @@ if (wasRunDirectly) {
193217
acmeAccountKey: process.env.ACME_ACCOUNT_KEY,
194218
certCacheDir: process.env.CERT_CACHE_DIR,
195219
localCaKey: process.env.LOCAL_CA_KEY,
196-
localCaCert: process.env.LOCAL_CA_CERT
220+
localCaCert: process.env.LOCAL_CA_CERT,
221+
dnsServer: process.env.DNS_SERVER === 'true'
197222
}).then((tcpHandler) => {
198223
ports.forEach((port) => {
199224
const server = createTcpServer(tcpHandler);

0 commit comments

Comments
 (0)