Skip to content
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

feat(console): DKIM, DMARC & SPF records for custom domains #2347

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/main-starbase.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@ jobs:
secrets: |
INTERNAL_CLOUDFLARE_ZONE_ID
TOKEN_CLOUDFLARE_API
INTERNAL_DKIM_SELECTOR
env:
NODE_ENV: 'development'
INTERNAL_CLOUDFLARE_ZONE_ID: ${{ secrets.INTERNAL_CLOUDFLARE_ZONE_ID }}
TOKEN_CLOUDFLARE_API: ${{ secrets.TOKEN_CLOUDFLARE_API }}
INTERNAL_DKIM_SELECTOR: ${{ secrets.INTERNAL_DKIM_SELECTOR }}
2 changes: 2 additions & 0 deletions .github/workflows/next-starbase.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ jobs:
secrets: |
INTERNAL_CLOUDFLARE_ZONE_ID
TOKEN_CLOUDFLARE_API
INTERNAL_DKIM_SELECTOR
env:
INTERNAL_CLOUDFLARE_ZONE_ID: ${{ secrets.INTERNAL_CLOUDFLARE_ZONE_ID }}
TOKEN_CLOUDFLARE_API: ${{ secrets.TOKEN_CLOUDFLARE_API }}
INTERNAL_DKIM_SELECTOR: ${{ secrets.INTERNAL_DKIM_SELECTOR }}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to add it to secret stanza above.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added

2 changes: 2 additions & 0 deletions .github/workflows/release-starbase.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ jobs:
secrets: |
INTERNAL_CLOUDFLARE_ZONE_ID
TOKEN_CLOUDFLARE_API
INTERNAL_DKIM_SELECTOR
env:
NODE_ENV: 'development'
INTERNAL_CLOUDFLARE_ZONE_ID: ${{ secrets.INTERNAL_CLOUDFLARE_ZONE_ID }}
TOKEN_CLOUDFLARE_API: ${{ secrets.TOKEN_CLOUDFLARE_API }}
INTERNAL_DKIM_SELECTOR: ${{ secrets.INTERNAL_DKIM_SELECTOR }}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add to secret stanza above as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added

44 changes: 25 additions & 19 deletions apps/console/app/routes/apps/$clientId/domain-wip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,7 @@ import { BadRequestError } from '@proofzero/errors'
import { getRollupReqFunctionErrorWrapper } from '@proofzero/utils/errors'

import createStarbaseClient from '@proofzero/platform-clients/starbase'
import {
getAuthzHeaderConditionallyFromToken,
getDNSRecordValue,
} from '@proofzero/utils'
import { getAuthzHeaderConditionallyFromToken } from '@proofzero/utils'
import { generateTraceContextHeaders } from '@proofzero/platform-middleware/trace'

import type { CustomDomain } from '@proofzero/platform.starbase/src/types'
Expand All @@ -37,6 +34,7 @@ import { DocumentationBadge } from '~/components/DocumentationBadge'
import { requireJWT } from '~/utilities/session.server'

import dangerVector from '~/images/danger.svg'

type AppData = { customDomain?: CustomDomain; hostname: string; cname: string }

export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper(
Expand All @@ -55,6 +53,7 @@ export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper(
})

const { hostname } = new URL(PASSPORT_URL)

return json({ customDomain, hostname })
}
)
Expand Down Expand Up @@ -185,7 +184,9 @@ const HostnameStatus = ({
const [deleteModalOpen, setDeleteModalOpen] = useState(false)
const isPreValidated =
customDomain.status === 'active' && customDomain.ssl.status === 'active'
const isValidated = isPreValidated && customDomain.dns_records.every(r => r.value === r.expected_value)
const isValidated =
isPreValidated &&
customDomain.dns_records.every((r) => r.value?.includes(r.expected_value))
const bgStatusColor = isValidated ? 'bg-green-600' : 'bg-orange-500'
const textStatusColor = isValidated ? 'text-green-600' : 'text-orange-500'
const statusText = isValidated ? 'Validated' : 'Not Validated'
Expand Down Expand Up @@ -247,6 +248,7 @@ const HostnameStatus = ({
customDomain.ssl.validation_records?.[0].txt_value ||
'Setting up...'
}
disableCopier={customDomain.ssl.status === 'active'}
/>
<DNSRecord
title="Hostname pre-validation"
Expand All @@ -258,22 +260,25 @@ const HostnameStatus = ({
value={
customDomain.ownership_verification?.value || 'Setting up...'
}
disableCopier={customDomain.status === 'active'}
/>
</div>

<Text size="sm" weight="medium" className="text-gray-700">
Step 2: CNAME Record
</Text>
<div className="flex flex-col p-4 space-y-5 box-border border rounded-lg">
{isPreValidated && Array.from(customDomain.dns_records || []).map((r) => (
<DNSRecord
title={r.record_type === 'CNAME' ? '' : r.name}
type={r.record_type}
validated={r.value === r.expected_value}
value={r.expected_value}
key={r.expected_value}
/>
))}
{isPreValidated &&
Array.from(customDomain.dns_records || []).map((r) => (
<DNSRecord
name={r.name}
title={''}
type={r.record_type}
validated={r.value?.includes(r.expected_value) ?? false}
value={r.expected_value}
key={r.expected_value}
/>
))}
{!isPreValidated && (
<div className="flex flex-row p-4 space-x-4 bg-gray-50">
<TbInfoCircle size={20} className="text-gray-500 shrink-0" />
Expand All @@ -297,9 +302,10 @@ type DNSRecordProps = {
name?: string
value: string
type: 'TXT' | 'CNAME'
disableCopier?: boolean
}

const DNSRecord = ({ title, validated, name, value, type }: DNSRecordProps) => {
const DNSRecord = ({ title, validated, name, value, type, disableCopier = false }: DNSRecordProps) => {
const statusColor = validated ? 'bg-green-600' : 'bg-orange-500'
return (
<div className="flex flex-row flex-wrap space-x-4">
Expand All @@ -320,7 +326,7 @@ const DNSRecord = ({ title, validated, name, value, type }: DNSRecordProps) => {
>
{name}
</Text>
<div>
{!disableCopier && <div>
<Copier
value={name}
color="text-gray-500"
Expand All @@ -332,7 +338,7 @@ const DNSRecord = ({ title, validated, name, value, type }: DNSRecordProps) => {
)
}
/>
</div>
</div>}
</div>
</div>
</div>
Expand All @@ -350,7 +356,7 @@ const DNSRecord = ({ title, validated, name, value, type }: DNSRecordProps) => {
>
{value}
</Text>
<div>
{!disableCopier && <div>
<Copier
value={value}
color="text-gray-500"
Expand All @@ -362,7 +368,7 @@ const DNSRecord = ({ title, validated, name, value, type }: DNSRecordProps) => {
)
}
/>
</div>
</div>}
</div>
</div>
</div>
Expand Down
10 changes: 3 additions & 7 deletions apps/passport/app/routes/connect/email/otp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,9 @@ export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper(
appName: appProps.name,
}

// Commented out because
// we need to figure out DKIM
// for custom domains
// https://github.com/proofzero/rollupid/issues/2326
// if (customDomain) {
// themeProps.hostname = customDomain.hostname
// }
if (customDomain) {
themeProps.hostname = customDomain.hostname
}
}

const state = await addressClient.generateEmailOTP.mutate({
Expand Down
29 changes: 18 additions & 11 deletions packages/utils/getDNSRecordValue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as dnsPacket from '@dnsquery/dns-packet'
export default async function (
domain: string,
recordType: 'TXT' | 'CNAME'
): Promise<string | null> {
): Promise<string[] | undefined> {
function getRandomInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min
}
Expand Down Expand Up @@ -32,14 +32,21 @@ export default async function (

const responseBuffer = new Uint8Array(await dnsQuery.arrayBuffer())
const packet = dnsPacket.decode(responseBuffer)
if (!packet.answers || !packet.answers.length) return null

//We take only the first answer as if we're being specific enough with the
//domain, there should only be one
const response = packet.answers[0]
const recValue =
response.type === 'TXT'
? new TextDecoder().decode((response.data as Buffer[])[0])
: (response.data as string)
return recValue
if (!packet.answers || !packet.answers.length) return undefined

const td = new TextDecoder()
const values = packet.answers.map((a) => {
if (a.type === 'TXT') {
const strParts = []
for (const data of a.data) {
strParts.push(td.decode(data as Buffer))
}

return strParts.join('')
}

return a.data as string
})

return values
}
5 changes: 4 additions & 1 deletion platform/email/src/emailFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export async function send(
name: message.recipient.name,
},
],
dkim_domain: env.INTERNAL_DKIM_DOMAIN,
dkim_domain: message.customHostname ?? env.INTERNAL_DKIM_DOMAIN,
dkim_selector: env.INTERNAL_DKIM_SELECTOR,
dkim_private_key: env.KEY_DKIM_PRIVATEKEY,
},
Expand Down Expand Up @@ -94,6 +94,7 @@ export async function send(

export type NotificationSender =
| {
hostname: string
name: string
address: string
}
Expand All @@ -105,12 +106,14 @@ export async function sendNotification(
customSender?: NotificationSender
) {
let from: NotificationSender = {
hostname: env.INTERNAL_DKIM_DOMAIN,
name: env.NotificationFromName,
address: `${env.NotificationFromUser}@${env.INTERNAL_DKIM_DOMAIN}`,
}

if (customSender) {
from = {
hostname: customSender.hostname,
name: customSender.name,
address: customSender.address,
}
Expand Down
1 change: 1 addition & 0 deletions platform/email/src/jsonrpc/methods/sendOTPEmail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export const sendEmailNotificationMethod = async ({
let customSender: NotificationSender
if (input.themeProps?.hostname) {
customSender = {
hostname: input.themeProps.hostname,
address: `no-reply@${input.themeProps.hostname}`,
name: input.themeProps.appName,
}
Expand Down
1 change: 1 addition & 0 deletions platform/email/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export type EmailMessage = {
from: EmailAddressComponents
recipient: EmailAddressComponents
content: EmailContent
customHostname?: string
}

export type EmailNotification = Omit<EmailMessage, 'from'>
Expand Down
2 changes: 1 addition & 1 deletion platform/starbase/src/jsonrpc/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { ApplicationURN } from '@proofzero/urns/application'
import {
generateTraceContextHeaders,
generateTraceSpan,
TraceSpan,
} from '@proofzero/platform-middleware/trace'

/**
Expand All @@ -28,6 +27,7 @@ interface CreateInnerContextOptions
INTERNAL_PASSPORT_SERVICE_NAME: string
INTERNAL_CLOUDFLARE_ZONE_ID: string
TOKEN_CLOUDFLARE_API: string
INTERNAL_DKIM_SELECTOR: string
}
/**
* Inner context. Will always be available in your procedures, in contrast to the outer context.
Expand Down
5 changes: 3 additions & 2 deletions platform/starbase/src/jsonrpc/methods/createCustomDomain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,10 @@ export const createCustomDomain: CreateCustomDomainMethod = async ({
)
const customDomain: CustomDomain = {
...customHostname,
dns_records: getExpectedCustomDomainDNSRecords(
dns_records: await getExpectedCustomDomainDNSRecords(
customHostname.hostname,
input.passportHostname
input.passportHostname,
ctx
),
}
const node = await getApplicationNodeByClientId(clientId, ctx.StarbaseApp)
Expand Down
4 changes: 2 additions & 2 deletions platform/starbase/src/jsonrpc/methods/getAppPublicProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ export const getAppPublicProps = async ({
customDomain?.status === 'active' &&
customDomain?.ssl.status === 'active' &&
Boolean(
customDomain?.dns_records?.every(
(r) => r.expected_value === r.value
customDomain?.dns_records?.every((r) =>
r.value?.includes(r.expected_value)
)
),
},
Expand Down
2 changes: 1 addition & 1 deletion platform/starbase/src/jsonrpc/validators/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ export const CustomDomainDNSRecordsSchema = z.array(
name: z.string(),
record_type: z.union([z.literal('TXT'), z.literal('CNAME')]),
expected_value: z.string(),
value: z.string().optional().nullable(),
value: z.array(z.string()).optional(),
})
)

Expand Down
2 changes: 1 addition & 1 deletion platform/starbase/src/nodes/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ export default class StarbaseApp extends DOProxy {
if (
customDomain.status === 'active' &&
customDomain.ssl.status === 'active' &&
stored.dns_records.every((rec) => rec.value === rec.expected_value)
stored.dns_records.every((rec) => rec.value?.includes(rec.expected_value))
)
return this.unsetCustomDomainAlarm()

Expand Down
1 change: 1 addition & 0 deletions platform/starbase/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface Environment {
INTERNAL_PASSPORT_SERVICE_NAME: string
INTERNAL_CLOUDFLARE_ZONE_ID: string
TOKEN_CLOUDFLARE_API: string
INTERNAL_DKIM_SELECTOR: string
}

export const EDGE_APPLICATION: EdgeURN = EdgeSpace.urn('owns/app')
Expand Down
22 changes: 18 additions & 4 deletions platform/starbase/src/utils/cloudflare.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { InternalServerError } from '@proofzero/errors'
import { CustomDomain, CustomDomainDNSRecords } from '../types'
import { Context } from '../jsonrpc/context'

const API_URL = 'https://api.cloudflare.com/client/v4'

Expand Down Expand Up @@ -150,17 +151,30 @@ export const deleteWorkerRoute = async (
)
}

export const getExpectedCustomDomainDNSRecords = (
export const getExpectedCustomDomainDNSRecords = async (
customHostname: string,
passportUrl: string
): CustomDomainDNSRecords => {
passportUrl: string,
ctx: Context
): Promise<CustomDomainDNSRecords> => {
const result: CustomDomainDNSRecords = []

//Add other expected DNS records here, eg. DMARC, SPF, DKIM, etc
result.push({
name: customHostname,
record_type: 'CNAME',
expected_value: passportUrl,
})

result.push({
record_type: 'CNAME',
name: `${ctx.INTERNAL_DKIM_SELECTOR}._domainkey.${customHostname}`,
expected_value: `${ctx.INTERNAL_DKIM_SELECTOR}._domainkey.notifications.rollup.id`,
})

result.push({
record_type: 'CNAME',
name: `_dmarc.${customHostname}`,
expected_value: `_dmarc.notifications.rollup.id`,
})

return result
}