-
-
Notifications
You must be signed in to change notification settings - Fork 129
custom domains - middleware and verification #2145
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 19 commits
c50509a
5e80c3f
4d24845
b624c59
89f1eb4
9d1c137
dc119a8
67fb2c8
725ce81
f3930f7
01e319e
e132ad0
cd9cb68
9e96d7c
82a71f5
f95ab6a
4f49382
2382f3b
c732135
ca13d80
2a77fd1
072c1ae
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
import AWS from 'aws-sdk' | ||
|
||
AWS.config.update({ | ||
region: 'us-east-1' | ||
}) | ||
|
||
const config = {} | ||
|
||
export async function requestCertificate (domain) { | ||
// for local development, we use the LOCALSTACK_ENDPOINT | ||
if (process.env.NODE_ENV === 'development') { | ||
config.endpoint = process.env.LOCALSTACK_ENDPOINT | ||
} | ||
|
||
const acm = new AWS.ACM(config) | ||
const params = { | ||
DomainName: domain, | ||
ValidationMethod: 'DNS', | ||
Tags: [ | ||
{ | ||
Key: 'ManagedBy', | ||
Value: 'stacker.news' | ||
} | ||
] | ||
} | ||
|
||
const certificate = await acm.requestCertificate(params).promise() | ||
return certificate.CertificateArn | ||
} | ||
|
||
export async function describeCertificate (certificateArn) { | ||
if (process.env.NODE_ENV === 'development') { | ||
config.endpoint = process.env.LOCALSTACK_ENDPOINT | ||
} | ||
const acm = new AWS.ACM(config) | ||
const certificate = await acm.describeCertificate({ CertificateArn: certificateArn }).promise() | ||
return certificate | ||
} | ||
|
||
export async function getCertificateStatus (certificateArn) { | ||
const certificate = await describeCertificate(certificateArn) | ||
return certificate.Certificate.Status | ||
} | ||
|
||
export async function deleteCertificate (certificateArn) { | ||
if (process.env.NODE_ENV === 'development') { | ||
config.endpoint = process.env.LOCALSTACK_ENDPOINT | ||
} | ||
const acm = new AWS.ACM(config) | ||
const result = await acm.deleteCertificate({ CertificateArn: certificateArn }).promise() | ||
console.log(`delete certificate attempt for ${certificateArn}, result: ${JSON.stringify(result)}`) | ||
return result | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,175 @@ | ||
import { validateSchema, customDomainSchema } from '@/lib/validate' | ||
import { GqlAuthenticationError, GqlInputError } from '@/lib/error' | ||
import { randomBytes } from 'node:crypto' | ||
import { getDomainMapping } from '@/lib/domains' | ||
import { deleteDomainCertificate } from '@/lib/domain-verification' | ||
|
||
async function cleanDomainVerificationJobs (domain, models) { | ||
// delete any existing domain verification job left | ||
await models.$queryRaw` | ||
DELETE FROM pgboss.job | ||
WHERE name = 'domainVerification' | ||
AND data->>'domainId' = ${domain.id}::TEXT` | ||
} | ||
|
||
export default { | ||
Query: { | ||
domain: async (parent, { subName }, { models }) => { | ||
return models.domain.findUnique({ | ||
where: { subName }, | ||
include: { records: true, attempts: true, certificate: true } | ||
}) | ||
}, | ||
domainMapping: async (parent, { domainName }, { models }) => { | ||
const mapping = await getDomainMapping(domainName) | ||
return mapping | ||
} | ||
}, | ||
Mutation: { | ||
setDomain: async (parent, { subName, domainName }, { me, models }) => { | ||
if (!me) { | ||
throw new GqlAuthenticationError() | ||
} | ||
|
||
const sub = await models.sub.findUnique({ where: { name: subName } }) | ||
if (!sub) { | ||
throw new GqlInputError('sub not found') | ||
} | ||
|
||
if (sub.userId !== me.id) { | ||
throw new GqlInputError('you do not own this sub') | ||
} | ||
|
||
domainName = domainName.trim() // protect against trailing spaces | ||
if (domainName && !validateSchema(customDomainSchema, { domainName })) { | ||
throw new GqlInputError('invalid domain format') | ||
} | ||
|
||
// we need to get the existing domain if we're updating or re-verifying | ||
const existing = await models.domain.findUnique({ where: { subName } }) | ||
|
||
if (domainName) { | ||
// updating the domain name and recovering from HOLD is allowed | ||
if (existing && existing.domainName === domainName && existing.status !== 'HOLD') { | ||
throw new GqlInputError('domain already set') | ||
} | ||
|
||
// we should always make sure to get a new updatedAt timestamp | ||
// to know when should we put the domain in HOLD during verification | ||
const initializeDomain = { | ||
domainName, | ||
updatedAt: new Date(), | ||
status: 'PENDING' | ||
} | ||
|
||
const updatedDomain = await models.$transaction(async tx => { | ||
// clean any existing domain verification job left | ||
if (existing && existing.status === 'HOLD') { | ||
await cleanDomainVerificationJobs(existing, tx) | ||
} | ||
|
||
const domain = await tx.domain.upsert({ | ||
where: { subName }, | ||
update: initializeDomain, | ||
create: { | ||
...initializeDomain, | ||
sub: { connect: { name: subName } } | ||
} | ||
}) | ||
|
||
// if on HOLD, get the existing TXT record | ||
const existingTXT = existing && existing.status === 'HOLD' | ||
? await tx.domainVerificationRecord.findUnique({ | ||
where: { | ||
domainId_type_recordName: { | ||
domainId: existing.id, | ||
type: 'TXT', | ||
recordName: '_snverify.' + existing.domainName | ||
} | ||
} | ||
}) | ||
: null | ||
|
||
// create the verification records | ||
const verificationRecords = [ | ||
{ | ||
domainId: domain.id, | ||
type: 'CNAME', | ||
recordName: domainName, | ||
recordValue: new URL(process.env.NEXT_PUBLIC_URL).host | ||
}, | ||
{ | ||
domainId: domain.id, | ||
type: 'TXT', | ||
recordName: '_snverify.' + domainName, | ||
recordValue: existingTXT // if we're resuming from HOLD, use the existing TXT record | ||
? existingTXT.recordValue | ||
: randomBytes(32).toString('base64') | ||
} | ||
] | ||
|
||
// create the verification records | ||
for (const record of verificationRecords) { | ||
await tx.domainVerificationRecord.upsert({ | ||
where: { | ||
domainId_type_recordName: { | ||
domainId: domain.id, | ||
type: record.type, | ||
recordName: record.recordName | ||
} | ||
}, | ||
update: record, | ||
create: record | ||
}) | ||
} | ||
|
||
// create the job to verify the domain in 30 seconds | ||
await tx.$executeRaw` | ||
INSERT INTO pgboss.job (name, data, retrylimit, retrydelay, startafter, keepuntil) | ||
VALUES ('domainVerification', | ||
jsonb_build_object('domainId', ${domain.id}::INTEGER), | ||
3, | ||
60, | ||
now() + interval '30 seconds', | ||
now() + interval '2 days' | ||
)` | ||
|
||
return domain | ||
}) | ||
|
||
return updatedDomain | ||
} else { | ||
try { | ||
// Delete any existing domain verification jobs | ||
if (existing) { | ||
return await models.$transaction(async tx => { | ||
// delete any existing domain verification job left | ||
await cleanDomainVerificationJobs(existing, tx) | ||
|
||
// deleting a domain will also delete the domain certificate | ||
// but we need to make sure to delete the certificate from ACM | ||
if (existing.certificate) { | ||
await deleteDomainCertificate(existing.certificate.certificateArn) | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Making network requests from inside an interactive tx is a bad idea. It consumes the db connection for the entire roundtrip. I recommend deleting the certificate before entering the tx or after, depending on which makes sense. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh! You're right, totally missed this. Going to push changes in a bit :) |
||
|
||
// delete the domain | ||
return await tx.domain.delete({ where: { subName } }) | ||
}) | ||
} | ||
return null | ||
} catch (error) { | ||
console.error(error) | ||
throw new GqlInputError('failed to delete domain') | ||
} | ||
} | ||
} | ||
}, | ||
Domain: { | ||
records: async (domain) => { | ||
if (!domain.records) return [] | ||
|
||
// O(1) lookups by type, simpler checks for CNAME, TXT and ACM validation records. | ||
return Object.fromEntries(domain.records.map(record => [record.type, record])) | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we can define this once in the file scoped config variable rather than checking it in every function
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I feared AWS SDK (v2) reaction to undefined properties, but afaict it should ignore them, adjusted ^^