Skip to content

Commit 9eb8d96

Browse files
committed
Domain verification changes
- job will be scheduled after 30 seconds and will be maintained for 48 hours - step-by-step verification process (to be modularized) - don't throw an error if it's not critical - if verification process results in a still PENDING domain we schedule it again after 5 minutes - if the status is still NOT ACTIVE (e.g. PENDING) we put the status on HOLD to avoid re-scheduling
1 parent 07d90ab commit 9eb8d96

File tree

2 files changed

+116
-92
lines changed

2 files changed

+116
-92
lines changed

api/resolvers/domain.js

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,10 @@ export default {
2727
if (sub.userId !== me.id) {
2828
throw new GqlInputError('you do not own this sub')
2929
}
30+
3031
domain = domain.trim() // protect against trailing spaces
3132
if (domain && !validateSchema(customDomainSchema, { domain })) {
32-
throw new GqlInputError('Invalid domain format')
33+
throw new GqlInputError('invalid domain format')
3334
}
3435

3536
if (domain) {
@@ -44,7 +45,9 @@ export default {
4445
verification: {
4546
dns: {
4647
state: 'PENDING',
47-
cname: 'stacker.news'
48+
cname: 'stacker.news',
49+
// generate a random txt record only if it's a new domain
50+
txt: existing?.domain === domain ? existing.verification.dns.txt : randomBytes(32).toString('base64')
4851
},
4952
ssl: {
5053
state: 'WAITING',
@@ -62,28 +65,17 @@ export default {
6265
},
6366
create: {
6467
...initializeDomain,
65-
verification: {
66-
...initializeDomain.verification,
67-
dns: {
68-
...initializeDomain.verification.dns,
69-
txt: randomBytes(32).toString('base64')
70-
}
71-
},
7268
sub: {
7369
connect: { name: subName }
7470
}
7571
}
7672
})
7773

78-
// schedule domain verification in 5 seconds, apply exponential backoff and keep it for 2 days
79-
// 12 retries, 42 seconds of delay between retries will fit 48 hours of trying for DNS propagation
80-
await models.$executeRaw`INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, retrydelay, startafter, keepuntil)
74+
// schedule domain verification in 30 seconds
75+
await models.$executeRaw`INSERT INTO pgboss.job (name, data, startafter, keepuntil)
8176
VALUES ('domainVerification',
8277
jsonb_build_object('domainId', ${updatedDomain.id}::INTEGER),
83-
12,
84-
true,
85-
'42', -- 42 seconds of delay between retries
86-
now() + interval '5 seconds',
78+
now() + interval '30 seconds',
8779
now() + interval '2 days')`
8880

8981
return updatedDomain

worker/domainVerification.js

Lines changed: 108 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,110 +1,142 @@
11
import createPrisma from '@/lib/create-prisma'
22
import { verifyDomainDNS, issueDomainCertificate, checkCertificateStatus, getValidationValues } from '@/lib/domain-verification'
33

4-
// This worker verifies the DNS and SSL certificates for domains that are pending or failed
54
export async function domainVerification ({ data: { domainId }, boss }) {
5+
// establish connection to database
66
const models = createPrisma({ connectionParams: { connection_limit: 1 } })
7-
console.log('domainVerification', domainId)
87
try {
8+
// get domain from database
99
const domain = await models.customDomain.findUnique({ where: { id: domainId } })
10-
10+
// if we can't find the domain, bail without scheduling a retry
1111
if (!domain) {
1212
throw new Error(`domain with ID ${domainId} not found`)
1313
}
1414

15-
const result = await verifyDomain(domain, models)
16-
17-
if (result?.status === 'ACTIVE') {
18-
console.log(`domain ${domain.domain} verified`)
19-
return
20-
}
21-
22-
if (result?.status === 'HOLD') {
23-
console.log(`domain ${domain.domain} is on hold after too many failed attempts`)
24-
return
25-
}
26-
27-
if (result?.status === 'PENDING') {
28-
throw new Error(`domain ${domain.domain} is still pending verification, will retry`)
15+
// start verification process
16+
const result = await verifyDomain(domain, boss)
17+
18+
// update the domain with the result
19+
await models.customDomain.update({
20+
where: { id: domainId },
21+
data: { ...domain, ...result }
22+
})
23+
24+
// if the result is PENDING it means we still have to verify the domain
25+
// if it's not PENDING, we stop the verification process.
26+
if (result.status === 'PENDING') {
27+
// we still need to verify the domain, schedule the job to run again in 5 minutes
28+
const jobId = await boss.send('domainVerification', { domainId }, {
29+
startAfter: 1000 * 60 * 5 // start the job after 5 minutes
30+
})
31+
console.log(`domain ${domain.domain} is still pending verification, created job with ID ${jobId} to run in 5 minutes`)
2932
}
3033
} catch (error) {
3134
console.error(`couldn't verify domain with ID ${domainId}: ${error.message}`)
32-
throw error
3335
}
3436
}
3537

36-
async function verifyDomain (domain, models) {
37-
// track verification
38-
const data = { ...domain, lastVerifiedAt: new Date() }
39-
data.verification = data.verification || { dns: {}, ssl: {} }
40-
data.failedAttempts = data.failedAttempts || 0
41-
42-
if (data.verification?.dns?.state !== 'VERIFIED') {
43-
await verifyDNS(data)
38+
async function verifyDomain (domain) {
39+
const lastVerifiedAt = new Date()
40+
const verification = domain.verification || { dns: {}, ssl: {} }
41+
let status = domain.status || 'PENDING'
42+
43+
// step 1: verify DNS [CNAME and TXT]
44+
// if DNS is not already verified
45+
let dnsState = verification.dns.state || 'PENDING'
46+
if (dnsState !== 'VERIFIED') {
47+
dnsState = await verifyDNS(domain.domain, verification.dns.txt)
48+
49+
// log the result, throw an error if we couldn't verify the DNS
50+
switch (dnsState) {
51+
case 'VERIFIED':
52+
console.log(`DNS verification for ${domain.domain} is ${dnsState}, proceeding to SSL verification`)
53+
break
54+
case 'PENDING':
55+
console.log(`DNS verification for ${domain.domain} is ${dnsState}, will retry DNS verification in 5 minutes`)
56+
break
57+
default:
58+
dnsState = 'PENDING'
59+
console.log(`couldn't verify DNS for ${domain.domain}, will retry DNS verification in 5 minutes`)
60+
}
4461
}
4562

46-
if (data.verification?.dns?.state === 'VERIFIED' && (!data.verification?.ssl?.arn || data.verification?.ssl?.state === 'FAILED')) {
47-
await issueCertificate(data)
63+
// step 2: certificate issuance
64+
// if DNS is verified and we don't have a SSL certificate, issue one
65+
let sslArn = verification.ssl.arn || null
66+
let sslState = verification.ssl.state || 'PENDING'
67+
if (dnsState === 'VERIFIED' && !sslArn) {
68+
sslArn = await issueDomainCertificate(domain.domain)
69+
if (sslArn) {
70+
console.log(`SSL certificate issued for ${domain.domain} with ARN ${sslArn}, will verify with ACM in 5 minutes`)
71+
} else {
72+
console.log(`couldn't issue SSL certificate for ${domain.domain}, will retry certificate issuance in 5 minutes`)
73+
}
4874
}
4975

50-
if (data.verification?.dns?.state === 'VERIFIED' && data.verification?.ssl?.state !== 'VERIFIED' && data.verification?.ssl?.arn) {
51-
await updateCertificateStatus(data)
76+
// step 3: get validation values from ACM
77+
// if we have a certificate and we don't already have the validation values
78+
let acmValidationCname = verification.ssl.cname || null
79+
let acmValidationValue = verification.ssl.value || null
80+
if (sslArn && !acmValidationCname && !acmValidationValue) {
81+
const { cname, value } = await getValidationValues(sslArn)
82+
acmValidationCname = cname
83+
acmValidationValue = value
84+
if (acmValidationCname && acmValidationValue) {
85+
console.log(`Validation values retrieved for ${domain.domain}, will check ACM validation status`)
86+
} else {
87+
console.log(`couldn't retrieve validation values for ${domain.domain}, will retry to request validation values from ACM in 5 minutes`)
88+
}
5289
}
5390

54-
if (data.verification?.dns?.state === 'PENDING' || data.verification?.ssl?.state === 'PENDING') {
55-
data.failedAttempts += 1
56-
// exponential backoff at the 11th attempt is roughly 48 hours
57-
if (data.failedAttempts > 11) {
58-
data.status = 'HOLD'
59-
data.failedAttempts = 0
91+
// step 4: check if the certificate is validated by ACM
92+
// if DNS is verified and we have a SSL certificate
93+
// it can happen that we just issued the certificate and it's not yet validated by ACM
94+
if (dnsState === 'VERIFIED' && sslArn && sslState !== 'VERIFIED') {
95+
sslState = await checkCertificateStatus(sslArn)
96+
switch (sslState) {
97+
case 'VERIFIED':
98+
console.log(`SSL certificate for ${domain.domain} is ${sslState}, verification routine complete`)
99+
break
100+
case 'PENDING':
101+
console.log(`SSL certificate for ${domain.domain} is ${sslState}, will check again with ACM in 5 minutes`)
102+
break
103+
default:
104+
sslState = 'PENDING'
105+
console.log(`couldn't verify SSL certificate for ${domain.domain}, will retry certificate validation with ACM in 5 minutes`)
60106
}
61107
}
62108

63-
if (data.verification?.dns?.state === 'VERIFIED' && data.verification?.ssl?.state === 'VERIFIED') {
64-
data.status = 'ACTIVE'
109+
// step 5: update the status of the domain
110+
// if the domain is fully verified, set the status to active
111+
if (dnsState === 'VERIFIED' && sslState === 'VERIFIED') {
112+
status = 'ACTIVE'
113+
}
114+
// if the domain has failed in some way and it's been 48 hours, put it on hold
115+
if (status !== 'ACTIVE' && domain.createdAt < new Date(Date.now() - 1000 * 60 * 60 * 24 * 2)) {
116+
status = 'HOLD'
65117
}
66118

67-
return await models.customDomain.update({ where: { id: domain.id }, data })
68-
}
69-
70-
async function verifyDNS (data) {
71-
const { txtValid, cnameValid } = await verifyDomainDNS(data.domain, data.verification.dns.txt)
72-
console.log(`${data.domain}: TXT ${txtValid ? 'valid' : 'invalid'}, CNAME ${cnameValid ? 'valid' : 'invalid'}`)
73-
74-
data.verification.dns.state = txtValid && cnameValid ? 'VERIFIED' : 'PENDING'
75-
return data
76-
}
77-
78-
async function issueCertificate (data) {
79-
const certificateArn = await issueDomainCertificate(data.domain)
80-
console.log(`${data.domain}: Certificate issued: ${certificateArn}`)
81-
82-
if (certificateArn) {
83-
const sslState = await checkCertificateStatus(certificateArn)
84-
console.log(`${data.domain}: Issued certificate status: ${sslState}`)
85-
86-
if (sslState) data.verification.ssl.state = sslState
87-
data.verification.ssl.arn = certificateArn
88-
89-
if (sslState !== 'VERIFIED') {
90-
try {
91-
const { cname, value } = await getValidationValues(certificateArn)
92-
data.verification.ssl.cname = cname
93-
data.verification.ssl.value = value
94-
} catch (error) {
95-
console.error(`Failed to get validation values for domain ${data.domain}:`, error)
119+
return {
120+
lastVerifiedAt,
121+
status,
122+
verification: {
123+
dns: {
124+
state: dnsState
125+
},
126+
ssl: {
127+
arn: sslArn,
128+
state: sslState,
129+
cname: acmValidationCname,
130+
value: acmValidationValue
96131
}
97132
}
98-
} else {
99-
data.verification.ssl.state = 'PENDING'
100133
}
101-
102-
return data
103134
}
104135

105-
async function updateCertificateStatus (data) {
106-
const sslState = await checkCertificateStatus(data.verification.ssl.arn)
107-
console.log(`${data.domain}: Certificate status: ${sslState}`)
108-
if (sslState) data.verification.ssl.state = sslState
109-
return data
136+
async function verifyDNS (cname, txt) {
137+
const { cnameValid, txtValid } = await verifyDomainDNS(cname, txt)
138+
console.log(`${cname}: CNAME ${cnameValid ? 'valid' : 'invalid'}, TXT ${txtValid ? 'valid' : 'invalid'}`)
139+
140+
const dnsState = cnameValid && txtValid ? 'VERIFIED' : 'PENDING'
141+
return dnsState
110142
}

0 commit comments

Comments
 (0)