diff --git a/.env.local.example b/.env.local.example index ed858786..a28082c0 100644 --- a/.env.local.example +++ b/.env.local.example @@ -15,6 +15,8 @@ # ************************************ platform ******************************************** ############################################################################################ PLATFORM_URL=http://localhost:3300 +# Generate by running in a terminal: openssl rand -hex 32 +PLATFORM_SECRET=secretsecretsecret diff --git a/apps/mail-bridge/postal-db/functions.ts b/apps/mail-bridge/postal-db/functions.ts index c5ddb7f7..8f3fffd3 100644 --- a/apps/mail-bridge/postal-db/functions.ts +++ b/apps/mail-bridge/postal-db/functions.ts @@ -119,6 +119,23 @@ export type GetDomainDNSRecordsOutput = optimal: string; acceptable: string; }; + mtaSts: { + dns: { + valid: boolean; + name: string; + value: string; + }; + tls: { + valid: boolean; + name: string; + value: string; + }; + policy: { + valid: boolean; + name: string; + value: string; + }; + }; } | { error: string }; @@ -168,6 +185,23 @@ export async function getDomainDNSRecords( name: '', optimal: '', acceptable: '' + }, + mtaSts: { + dns: { + valid: false, + name: '', + value: '' + }, + tls: { + valid: false, + name: '', + value: '' + }, + policy: { + valid: false, + name: '', + value: '' + } } }; @@ -317,7 +351,7 @@ export async function getDomainDNSRecords( } records.mx.name = domainInfo.name; records.mx.priority = 1; - records.mx.value = `mx.${postalServerUrl}`; + records.mx.value = `${postalServerUrl}`; records.mx.valid = true; if (domainInfo.mxStatus !== 'OK' || forceReverify) { @@ -360,6 +394,40 @@ export async function getDomainDNSRecords( records.dmarc.name = '_dmarc'; records.dmarc.optimal = buildDmarcRecord({ p: 'reject' }); records.dmarc.acceptable = buildDmarcRecord({ p: 'quarantine' }); + + const mtaStsDnsRecord = await lookupTXT(`_mta-sts.${domainInfo.name}`); + records.mtaSts.dns.name = '_mta-sts'; + records.mtaSts.dns.value = `v=STSv1; id=${Date.now()}`; + if (mtaStsDnsRecord.success && mtaStsDnsRecord.data.length > 0) { + records.mtaSts.dns.valid = + mtaStsDnsRecord.data.filter( + (_) => _.startsWith('v=STSv1;') && _.includes('id=') + ).length === 1; + } + + const mtaStsTlsRecord = await lookupTXT(`_smtp._tls.${domainInfo.name}`); + records.mtaSts.tls.name = '_smtp._tls'; + records.mtaSts.tls.value = `v=TLSRPTv1; rua=mailto:tlsrpt@reports.uninbox.com`; + if (mtaStsTlsRecord.success && mtaStsTlsRecord.data.length > 0) { + records.mtaSts.tls.valid = + mtaStsTlsRecord.data.filter( + (_) => + _.startsWith('v=TLSRPTv1;') && + _.includes('rua=') && + _.includes('mailto:tlsrpt@reports.uninbox.com') + ).length === 1; + } + + const mtaStsPolicyRecord = await lookupCNAME(`mta-sts.${domainInfo.name}`); + records.mtaSts.policy.name = 'mta-sts'; + records.mtaSts.policy.value = `mta-sts.${postalServerUrl}`; + if ( + mtaStsPolicyRecord.success && + mtaStsPolicyRecord.data.includes(records.mtaSts.policy.value) + ) { + records.mtaSts.policy.valid = true; + } + return records; } diff --git a/apps/mail-bridge/trpc/routers/domainRouter.ts b/apps/mail-bridge/trpc/routers/domainRouter.ts index 682e4355..ef3ba7da 100644 --- a/apps/mail-bridge/trpc/routers/domainRouter.ts +++ b/apps/mail-bridge/trpc/routers/domainRouter.ts @@ -178,6 +178,23 @@ export const domainRouter = router({ name: 'localhost', acceptable: 'v=DMARC1; p=quarantine;', optimal: 'v=DMARC1; p=reject;' + }, + mtaSts: { + dns: { + valid: true, + name: 'localhost', + value: 'v=STSv1; id=123456789' + }, + tls: { + valid: true, + name: 'localhost', + value: 'v=TLSRPTv1; rua=mailto:tlsrpt@localhost' + }, + policy: { + valid: true, + name: 'localhost', + value: 'mta-sts.localhost' + } } } satisfies GetDomainDNSRecordsOutput; } diff --git a/apps/platform/nitro.config.ts b/apps/platform/nitro.config.ts index 7fac7dab..c102261f 100644 --- a/apps/platform/nitro.config.ts +++ b/apps/platform/nitro.config.ts @@ -62,6 +62,9 @@ export default defineNitroConfig({ primaryDomain: process.env.PRIMARY_DOMAIN || 'localhost', mailDomains: mailDomains, transactionalCredentials: transactionalCredentials, + platform: { + secret: process.env.PLATFORM_SECRET || '' + }, auth: { baseUrl: process.env.WEBAPP_URL || 'http://localhost:3000', secret: process.env.WEBAPP_AUTH_SECRET, diff --git a/apps/platform/routes/caddy-check/[secret].get.ts b/apps/platform/routes/caddy-check/[secret].get.ts new file mode 100644 index 00000000..7c36088e --- /dev/null +++ b/apps/platform/routes/caddy-check/[secret].get.ts @@ -0,0 +1,43 @@ +import { useRuntimeConfig } from '#imports'; +import { db } from '@u22n/database'; +import { eq } from '@u22n/database/orm'; +import { domains } from '@u22n/database/schema'; +import { + eventHandler, + getQuery, + getRouterParam, + send, + setResponseStatus +} from 'h3'; + +export default eventHandler(async (event) => { + const secret = getRouterParam(event, 'secret'); + if (useRuntimeConfig().platform.secret !== secret) { + setResponseStatus(event, 401); + return send(event, 'Unauthorized'); + } + + const domain = getQuery(event).domain; + if (!domain || typeof domain !== 'string') { + setResponseStatus(event, 400); + return send(event, 'Bad Request'); + } + + if (!domain.startsWith('mta-sts.')) { + setResponseStatus(event, 400); + return send(event, 'Bad Request'); + } + + const rootDomain = domain.replace(/^mta-sts\./, ''); + const domainResponse = await db.query.domains.findFirst({ + where: eq(domains.domain, rootDomain) + }); + + if (!domainResponse) { + setResponseStatus(event, 403); + return send(event, 'Forbidden'); + } + + setResponseStatus(event, 200); + return send(event, 'Ok'); +}); diff --git a/apps/platform/trpc/routers/orgRouter/mail/domainsRouter.ts b/apps/platform/trpc/routers/orgRouter/mail/domainsRouter.ts index 12f2480e..18bffb76 100644 --- a/apps/platform/trpc/routers/orgRouter/mail/domainsRouter.ts +++ b/apps/platform/trpc/routers/orgRouter/mail/domainsRouter.ts @@ -299,7 +299,10 @@ export const domainsRouter = router({ spfDnsValid: dnsRecords.spf.valid, returnPathDnsValid: dnsRecords.returnPath.valid, verification: dnsRecords.verification.valid, - dmarkPolicy: dnsRecords.dmarc.policy + dmarcPolicy: dnsRecords.dmarc.policy, + mtaStsDns: dnsRecords.mtaSts.dns.valid, + mtaStsTls: dnsRecords.mtaSts.tls.valid, + mtaStsPolicy: dnsRecords.mtaSts.policy.valid }; // take all dns Records and count how many are valid, if all are valid then allOk @@ -368,6 +371,9 @@ export const domainsRouter = router({ dkimDnsValid: dnsStatus.dkimDnsValid, spfDnsValid: dnsStatus.spfDnsValid, returnPathDnsValid: dnsStatus.returnPathDnsValid, + mtaStsDnsValid: dnsStatus.mtaStsDns, + mtaStsTlsValid: dnsStatus.mtaStsTls, + mtaStsPolicyValid: dnsStatus.mtaStsPolicy, receivingMode: domainReceivingMode, sendingMode: domainSendingMode, lastDnsCheckAt: new Date(), diff --git a/apps/web-app/pages/[orgShortcode]/settings/org/mail/domains/[domainId].vue b/apps/web-app/pages/[orgShortcode]/settings/org/mail/domains/[domainId].vue index 0411f036..2d6e4926 100644 --- a/apps/web-app/pages/[orgShortcode]/settings/org/mail/domains/[domainId].vue +++ b/apps/web-app/pages/[orgShortcode]/settings/org/mail/domains/[domainId].vue @@ -150,7 +150,7 @@ { label: 'DMARC-Record', slot: 'dmarc-record', - status: domainDnsQuery.value?.dnsStatus?.dmarkPolicy || null + status: domainDnsQuery.value?.dnsStatus?.dmarcPolicy || null } ]; }); diff --git a/packages/caddy-mta-sts/Caddyfile b/packages/caddy-mta-sts/Caddyfile new file mode 100644 index 00000000..930c86bd --- /dev/null +++ b/packages/caddy-mta-sts/Caddyfile @@ -0,0 +1,13 @@ +{ + on_demand_tls { + ask {env.PLATFORM_URL}/caddy-check/{env.PLATFORM_SECRET} + } +} + +https:// { + tls { + on_demand + } + root * ./files + file_server +} diff --git a/packages/caddy-mta-sts/files/.well-known/mta-sts.txt b/packages/caddy-mta-sts/files/.well-known/mta-sts.txt new file mode 100644 index 00000000..d3caea1e --- /dev/null +++ b/packages/caddy-mta-sts/files/.well-known/mta-sts.txt @@ -0,0 +1,4 @@ +version: STSv1 +mode: enforce +mx: *.e.uninbox.com +max_age: 86400 \ No newline at end of file diff --git a/packages/database/schema.ts b/packages/database/schema.ts index b3e9ce7b..e4429612 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -559,6 +559,9 @@ export const domains = mysqlTable( returnPathDnsValid: boolean('return_path_dns_valid') .notNull() .default(false), + mtaStsDnsValid: boolean('mta_sts_dns_valid').notNull().default(false), + mtaStsTlsValid: boolean('mta_sts_tls_valid').notNull().default(false), + mtaStsPolicyValid: boolean('mta_sts_policy_valid').notNull().default(false), lastDnsCheckAt: timestamp('last_dns_check_at'), disabledAt: timestamp('disabled_at'), verifiedAt: timestamp('verified_at'),