6667d18db0
ci / changes (push) Successful in 4s
ci / tc_booking (push) Has been skipped
ci / tc_operator (push) Has been skipped
ci / tc_website (push) Has been skipped
ci / tc_platform_api (push) Has been skipped
ci / test_platform_api (push) Has been skipped
ci / build_booking (push) Has been skipped
ci / build_operator (push) Has been skipped
ci / build_platform_api (push) Has been skipped
ci / tc_portal (push) Successful in 26s
ci / build_portal (push) Successful in 49s
ci / deploy (push) Successful in 42s
Unsigned .mobileconfig installs trip macOS warnings ('unknown developer')
and an extra System Settings hunt. The route now wraps the profile in
PKCS#7 SignedData (node-forge, SHA-256, full chain embedded) using the
portal's own cert-manager LE certificate mounted read-only into the pod
(PROFILE_SIGN_CERT/KEY). Publicly-trusted chain → Apple shows Verified.
Dev (no env) and any signing failure fall back to unsigned — the
download must never break over the badge. Signature round-trip verified
with openssl smime.
171 lines
7.8 KiB
TypeScript
171 lines
7.8 KiB
TypeScript
// Apple configuration profile (.mobileconfig) for one-click Mail setup on
|
|
// macOS/iOS — the Apple answer to autodiscovery (Apple Mail ignores RFC 6186
|
|
// SRV records, and "Microsoft Exchange" needs EWS/EAS which Stalwart doesn't
|
|
// speak). The profile carries server settings + the address but NO password:
|
|
// profiles are plaintext XML, so Apple prompts for the password on install.
|
|
//
|
|
// Session-gated like every portal API. The mail host comes from runtime
|
|
// config (public.mailUrl), so dev (.local) and prod (.eu) generate correct
|
|
// profiles from one build. Includes CalDAV + CardDAV payloads (Traefik
|
|
// routes /dav + the well-knowns on the mail host through to Stalwart), so
|
|
// one install configures Mail, Calendar and Contacts.
|
|
|
|
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
|
|
import { randomUUID } from 'node:crypto'
|
|
import { readFileSync } from 'node:fs'
|
|
import forge from 'node-forge'
|
|
|
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
|
|
function xmlEscape(s: string): string {
|
|
return s
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
}
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
const session = await getUserSession(event).catch(() => null)
|
|
if (!(session as { accessToken?: string } | null)?.accessToken) {
|
|
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
|
|
}
|
|
|
|
const q = getQuery(event)
|
|
const email = String(q.email ?? '').trim().toLowerCase()
|
|
const name = String(q.name ?? '').trim() || email
|
|
if (!EMAIL_RE.test(email)) {
|
|
throw createError({ statusCode: 400, statusMessage: 'Invalid email' })
|
|
}
|
|
|
|
const mailHost = new URL(useRuntimeConfig().public.mailUrl as string).host
|
|
// The regex above guarantees an @, but noUncheckedIndexedAccess doesn't know.
|
|
const localPart = email.split('@')[0] ?? email
|
|
const accountUuid = randomUUID()
|
|
const caldavUuid = randomUUID()
|
|
const carddavUuid = randomUUID()
|
|
const profileUuid = randomUUID()
|
|
const e = xmlEscape(email)
|
|
const n = xmlEscape(name)
|
|
const h = xmlEscape(mailHost)
|
|
|
|
const profile = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>PayloadContent</key>
|
|
<array>
|
|
<dict>
|
|
<key>EmailAccountDescription</key><string>dezky mail (${e})</string>
|
|
<key>EmailAccountName</key><string>${n}</string>
|
|
<key>EmailAccountType</key><string>EmailTypeIMAP</string>
|
|
<key>EmailAddress</key><string>${e}</string>
|
|
<key>IncomingMailServerAuthentication</key><string>EmailAuthPassword</string>
|
|
<key>IncomingMailServerHostName</key><string>${h}</string>
|
|
<key>IncomingMailServerPortNumber</key><integer>993</integer>
|
|
<key>IncomingMailServerUseSSL</key><true/>
|
|
<key>IncomingMailServerUsername</key><string>${e}</string>
|
|
<key>OutgoingMailServerAuthentication</key><string>EmailAuthPassword</string>
|
|
<key>OutgoingMailServerHostName</key><string>${h}</string>
|
|
<key>OutgoingMailServerPortNumber</key><integer>465</integer>
|
|
<key>OutgoingMailServerUseSSL</key><true/>
|
|
<key>OutgoingMailServerUsername</key><string>${e}</string>
|
|
<key>OutgoingPasswordSameAsIncomingPassword</key><true/>
|
|
<key>PreventAppSheet</key><false/>
|
|
<key>PreventMove</key><false/>
|
|
<key>SMIMEEnabled</key><false/>
|
|
<key>PayloadDescription</key><string>Configures the ${e} mail account.</string>
|
|
<key>PayloadDisplayName</key><string>dezky mail</string>
|
|
<key>PayloadIdentifier</key><string>eu.dezky.mail.${xmlEscape(localPart)}</string>
|
|
<key>PayloadType</key><string>com.apple.mail.managed</string>
|
|
<key>PayloadUUID</key><string>${accountUuid}</string>
|
|
<key>PayloadVersion</key><integer>1</integer>
|
|
</dict>
|
|
<dict>
|
|
<key>CalDAVAccountDescription</key><string>dezky calendar (${e})</string>
|
|
<key>CalDAVHostName</key><string>${h}</string>
|
|
<key>CalDAVPort</key><integer>443</integer>
|
|
<key>CalDAVUseSSL</key><true/>
|
|
<key>CalDAVUsername</key><string>${e}</string>
|
|
<key>PayloadDescription</key><string>Configures the ${e} calendar account.</string>
|
|
<key>PayloadDisplayName</key><string>dezky calendar</string>
|
|
<key>PayloadIdentifier</key><string>eu.dezky.caldav.${xmlEscape(localPart)}</string>
|
|
<key>PayloadType</key><string>com.apple.caldav.account</string>
|
|
<key>PayloadUUID</key><string>${caldavUuid}</string>
|
|
<key>PayloadVersion</key><integer>1</integer>
|
|
</dict>
|
|
<dict>
|
|
<key>CardDAVAccountDescription</key><string>dezky contacts (${e})</string>
|
|
<key>CardDAVHostName</key><string>${h}</string>
|
|
<key>CardDAVPort</key><integer>443</integer>
|
|
<key>CardDAVUseSSL</key><true/>
|
|
<key>CardDAVUsername</key><string>${e}</string>
|
|
<key>PayloadDescription</key><string>Configures the ${e} contacts account.</string>
|
|
<key>PayloadDisplayName</key><string>dezky contacts</string>
|
|
<key>PayloadIdentifier</key><string>eu.dezky.carddav.${xmlEscape(localPart)}</string>
|
|
<key>PayloadType</key><string>com.apple.carddav.account</string>
|
|
<key>PayloadUUID</key><string>${carddavUuid}</string>
|
|
<key>PayloadVersion</key><integer>1</integer>
|
|
</dict>
|
|
</array>
|
|
<key>PayloadDescription</key><string>Sets up ${e} in Apple Mail, Calendar and Contacts. You'll be asked for the mailbox password during installation.</string>
|
|
<key>PayloadDisplayName</key><string>dezky mail — ${e}</string>
|
|
<key>PayloadIdentifier</key><string>eu.dezky.profile.${xmlEscape(localPart)}</string>
|
|
<key>PayloadOrganization</key><string>dezky</string>
|
|
<key>PayloadRemovalDisallowed</key><false/>
|
|
<key>PayloadType</key><string>Configuration</string>
|
|
<key>PayloadUUID</key><string>${profileUuid}</string>
|
|
<key>PayloadVersion</key><integer>1</integer>
|
|
</dict>
|
|
</plist>
|
|
`
|
|
|
|
setHeader(event, 'Content-Type', 'application/x-apple-aspen-config')
|
|
setHeader(
|
|
event,
|
|
'Content-Disposition',
|
|
`attachment; filename="dezky-mail-${localPart.replace(/[^a-z0-9.-]/gi, '_')}.mobileconfig"`,
|
|
)
|
|
return signProfile(profile)
|
|
})
|
|
|
|
// Sign the profile (PKCS#7 SignedData with embedded content) using the TLS
|
|
// cert mounted from cert-manager (PROFILE_SIGN_CERT/KEY in production —
|
|
// fleet/apps/portal.yaml). A publicly-trusted signature turns Apple's
|
|
// "unsigned profile" warning into a green Verified badge. Unset env (dev)
|
|
// → unsigned profile, still installable with the warning.
|
|
function signProfile(profileXml: string): Buffer | string {
|
|
const certPath = process.env.PROFILE_SIGN_CERT
|
|
const keyPath = process.env.PROFILE_SIGN_KEY
|
|
if (!certPath || !keyPath) return profileXml
|
|
try {
|
|
const certPem = readFileSync(certPath, 'utf8')
|
|
const keyPem = readFileSync(keyPath, 'utf8')
|
|
// cert-manager's tls.crt is the full chain — leaf first. Include every
|
|
// cert so Apple can build the path to the trusted root.
|
|
const certs = certPem
|
|
.split(/(?=-----BEGIN CERTIFICATE-----)/)
|
|
.filter((c) => c.includes('BEGIN CERTIFICATE'))
|
|
.map((c) => forge.pki.certificateFromPem(c))
|
|
const p7 = forge.pkcs7.createSignedData()
|
|
p7.content = forge.util.createBuffer(profileXml, 'utf8')
|
|
for (const c of certs) p7.addCertificate(c)
|
|
p7.addSigner({
|
|
key: forge.pki.privateKeyFromPem(keyPem) as forge.pki.rsa.PrivateKey,
|
|
certificate: certs[0]!,
|
|
digestAlgorithm: forge.pki.oids.sha256!,
|
|
authenticatedAttributes: [
|
|
{ type: forge.pki.oids.contentType!, value: forge.pki.oids.data! },
|
|
{ type: forge.pki.oids.messageDigest! },
|
|
{ type: forge.pki.oids.signingTime! },
|
|
],
|
|
})
|
|
p7.sign()
|
|
return Buffer.from(forge.asn1.toDer(p7.toAsn1()).getBytes(), 'binary')
|
|
} catch (err) {
|
|
// Never fail the download over signing — fall back to unsigned.
|
|
console.error('apple-mailconfig: profile signing failed, serving unsigned:', err)
|
|
return profileXml
|
|
}
|
|
}
|