diff --git a/src/constants/constants.ts b/src/constants/constants.ts index 6a718a2a9..2fe21a4c0 100644 --- a/src/constants/constants.ts +++ b/src/constants/constants.ts @@ -57,5 +57,7 @@ export const ISOMER_ADMIN_REPOS = [ export const INACTIVE_USER_THRESHOLD_DAYS = 60 export const GITHUB_ORG_REPOS_ENDPOINT = `https://api.github.com/orgs/${ISOMER_GITHUB_ORG_NAME}/repos` export const REDIRECTION_SERVER_IP = config.get("redirectionServer.elasticIp") +export const DNS_INDIRECTION_DOMAIN = "hostedon.isomer.gov.sg" +export const DNS_INDIRECTION_REPO = "isomer-indirection" export const ISOMER_ADMIN_EMAIL = "admin@isomer.gov.sg" export const ISOMER_SUPPORT_EMAIL = "support@isomer.gov.sg" diff --git a/src/errors/GitHubApiError.ts b/src/errors/GitHubApiError.ts new file mode 100644 index 000000000..bdb4a3af7 --- /dev/null +++ b/src/errors/GitHubApiError.ts @@ -0,0 +1,11 @@ +import { BaseIsomerError } from "./BaseError" + +export default class GitHubApiError extends BaseIsomerError { + constructor(message: string) { + super({ + status: 500, + code: "GitHubApiError", + message, + }) + } +} diff --git a/src/services/identity/LaunchesService.ts b/src/services/identity/LaunchesService.ts index 192eb4d08..9d9cc5ce9 100644 --- a/src/services/identity/LaunchesService.ts +++ b/src/services/identity/LaunchesService.ts @@ -31,6 +31,7 @@ export type SiteLaunchCreateParams = { domainValidationTarget: string redirectionDomainSource?: string redirectionDomainTarget?: string + indirectionDomain: string } interface LaunchesServiceProps { diff --git a/src/services/identity/ReposService.ts b/src/services/identity/ReposService.ts index 4f703d58e..c0217794b 100644 --- a/src/services/identity/ReposService.ts +++ b/src/services/identity/ReposService.ts @@ -5,6 +5,7 @@ import { Octokit } from "@octokit/rest" import { GetResponseTypeFromEndpointMethod } from "@octokit/types" import git from "isomorphic-git" import http from "isomorphic-git/http/node" +import { ResultAsync, errAsync, okAsync } from "neverthrow" import { ModelStatic } from "sequelize" import { config } from "@config/config" @@ -12,6 +13,9 @@ import { config } from "@config/config" import { UnprocessableError } from "@errors/UnprocessableError" import { Repo, Site } from "@database/models" +import { DNS_INDIRECTION_REPO } from "@root/constants" +import GitHubApiError from "@root/errors/GitHubApiError" +import logger from "@root/logger/logger" const SYSTEM_GITHUB_TOKEN = config.get("github.systemToken") const octokit = new Octokit({ auth: SYSTEM_GITHUB_TOKEN }) @@ -262,4 +266,111 @@ export default class ReposService { remoteRef: "master", }) } + + createDnsIndirectionFile = ( + indirectionSubdomain: string, + primaryDomain: string, + primaryDomainTarget: string + ): ResultAsync => { + const template = `import { Record } from "@pulumi/aws/route53"; +import { CLOUDFRONT_HOSTED_ZONE_ID } from "../constants"; + +export const createRecords = (zoneId: string): Record[] => { + const records = [ + new Record("${primaryDomain} A", { + name: "${indirectionSubdomain}", + type: "A", + zoneId: zoneId, + aliases: [ + { + name: "${primaryDomainTarget}", + zoneId: CLOUDFRONT_HOSTED_ZONE_ID, + evaluateTargetHealth: false, + }, + ], + }), + + new Record("${primaryDomain} AAAA", { + name: "${indirectionSubdomain}", + type: "AAAA", + zoneId: zoneId, + aliases: [ + { + name: "${primaryDomainTarget}", + zoneId: CLOUDFRONT_HOSTED_ZONE_ID, + evaluateTargetHealth: false, + }, + ], + }), + ]; + + return records; +}; +` + + return ResultAsync.fromPromise( + octokit.repos.getContent({ + owner: ISOMER_GITHUB_ORGANIZATION_NAME, + repo: DNS_INDIRECTION_REPO, + path: `dns/${primaryDomain}.ts`, + }), + () => errAsync(true) + ) + .andThen((response) => { + if (Array.isArray(response.data)) { + logger.error( + `Error creating DNS indirection file for ${primaryDomain}` + ) + + return errAsync( + new GitHubApiError("Unable to create DNS indirection file") + ) + } + + const { sha } = response.data + return okAsync(sha) + }) + .andThen((sha) => + ResultAsync.fromPromise( + octokit.repos.createOrUpdateFileContents({ + owner: ISOMER_GITHUB_ORGANIZATION_NAME, + repo: DNS_INDIRECTION_REPO, + path: `dns/${primaryDomain}.ts`, + message: `Update ${primaryDomain}.ts`, + content: Buffer.from(template).toString("base64"), + sha, + }), + (error) => { + logger.error( + `Error creating DNS indirection file for ${primaryDomain}: ${error}` + ) + + return new GitHubApiError("Unable to create DNS indirection file") + } + ) + ) + .orElse((error) => { + if (error instanceof GitHubApiError) { + return errAsync(error) + } + + return ResultAsync.fromPromise( + octokit.repos.createOrUpdateFileContents({ + owner: ISOMER_GITHUB_ORGANIZATION_NAME, + repo: DNS_INDIRECTION_REPO, + path: `dns/${primaryDomain}.ts`, + message: `Create ${primaryDomain}.ts`, + content: Buffer.from(template).toString("base64"), + }), + (error) => { + logger.error( + `Error creating DNS indirection file for ${primaryDomain}: ${error}` + ) + + return new GitHubApiError("Unable to create DNS indirection file") + } + ) + }) + .map(() => undefined) + } } diff --git a/src/services/infra/InfraService.ts b/src/services/infra/InfraService.ts index aa15e397e..198491ec2 100644 --- a/src/services/infra/InfraService.ts +++ b/src/services/infra/InfraService.ts @@ -22,7 +22,9 @@ import { RedirectionTypes, REDIRECTION_SERVER_IP, ISOMER_SUPPORT_EMAIL, + DNS_INDIRECTION_DOMAIN, } from "@root/constants" +import GitHubApiError from "@root/errors/GitHubApiError" import MissingSiteError from "@root/errors/MissingSiteError" import MissingUserEmailError from "@root/errors/MissingUserEmailError" import SiteLaunchError from "@root/errors/SiteLaunchError" @@ -207,6 +209,8 @@ export default class InfraService { return url } + convertDotsToDashes = (url: string) => url.replace(/\./g, "-") + isValidUrl(url: string): boolean { const schema = Joi.string().domain() // joi reports initial "_" for certificates as as an invalid url WRONGLY, @@ -319,6 +323,22 @@ export default class InfraService { }) } + getIndirectionDomain( + primaryDomain: string, + primaryDomainTarget: string + ): ResultAsync { + const indirectionSubdomain = this.convertDotsToDashes(primaryDomain) + const indirectionDomain = `${indirectionSubdomain}.${DNS_INDIRECTION_DOMAIN}` + + return this.reposService + .createDnsIndirectionFile( + indirectionSubdomain, + primaryDomain, + primaryDomainTarget + ) + .map(() => indirectionDomain) + } + getGeneratedDnsRecords = async ( siteName: string ): Promise< @@ -486,6 +506,23 @@ export default class InfraService { (subDomain) => subDomain.subDomainSetting?.prefix ) + // Indirection domain should look something like this: + // blah-gov-sg.hostedon.isomer.gov.sg + const indirectionDomain = await this.getIndirectionDomain( + primaryDomain, + primaryDomainTarget + ) + + if (indirectionDomain.isErr()) { + return errAsync( + new AmplifyError( + `Error creating indirection domain: ${indirectionDomain.error}`, + repoName, + appId + ) + ) + } + /** * Amplify only stores the prefix. * ie: if I wanted to have a www.blah.gov.sg -> gibberish.cloudfront.net, @@ -500,6 +537,7 @@ export default class InfraService { primaryDomainTarget, domainValidationSource, domainValidationTarget, + indirectionDomain: indirectionDomain.value, } if (redirectionDomainList?.length) { @@ -520,6 +558,7 @@ export default class InfraService { primaryDomainTarget, domainValidationSource, domainValidationTarget, + indirectionDomain: indirectionDomain.value, requestorEmail: requestor.email ? requestor.email : "", agencyEmail: agency.email ? agency.email : "", // TODO: remove conditional after making email not optional/nullable } diff --git a/src/services/infra/__tests__/DynamoDBService.spec.ts b/src/services/infra/__tests__/DynamoDBService.spec.ts index f7be023cb..84578fe14 100644 --- a/src/services/infra/__tests__/DynamoDBService.spec.ts +++ b/src/services/infra/__tests__/DynamoDBService.spec.ts @@ -13,6 +13,7 @@ const mockLaunch: SiteLaunchMessage = { primaryDomainTarget: "myapp.example.com", domainValidationSource: "example.com", domainValidationTarget: "myapp.example.com", + indirectionDomain: "example.hostedon.example.com", requestorEmail: "john@example.com", agencyEmail: "agency@example.com", githubRedirectionUrl: "https://github.com/my-repo", diff --git a/src/services/utilServices/SendDNSRecordEmailClient.ts b/src/services/utilServices/SendDNSRecordEmailClient.ts index 7d398be06..9593394fa 100644 --- a/src/services/utilServices/SendDNSRecordEmailClient.ts +++ b/src/services/utilServices/SendDNSRecordEmailClient.ts @@ -15,6 +15,7 @@ export interface DnsRecordsEmailProps { domainValidationTarget: string primaryDomainSource: string primaryDomainTarget: string + indirectionDomain: string redirectionDomainSource?: string redirectionDomainTarget?: string quadARecords?: QuadARecord[] @@ -87,7 +88,7 @@ export function getDNSRecordsEmailBody( ? `www.${dnsRecords.primaryDomainSource}` : dnsRecords.primaryDomainSource } - ${dnsRecords.primaryDomainTarget} + ${dnsRecords.indirectionDomain} CNAME ` diff --git a/src/types/siteLaunch.ts b/src/types/siteLaunch.ts index a499215b6..42a6ad06a 100644 --- a/src/types/siteLaunch.ts +++ b/src/types/siteLaunch.ts @@ -27,6 +27,7 @@ export interface SiteLaunchMessage { primaryDomainTarget: string domainValidationSource: string domainValidationTarget: string + indirectionDomain: string requestorEmail: string agencyEmail: string githubRedirectionUrl?: string @@ -55,6 +56,7 @@ export function isSiteLaunchMessage(obj: unknown): obj is SiteLaunchMessage { typeof message.primaryDomainTarget === "string" && typeof message.domainValidationSource === "string" && typeof message.domainValidationTarget === "string" && + typeof message.indirectionDomain === "string" && typeof message.requestorEmail === "string" && typeof message.agencyEmail === "string" && (typeof message.githubRedirectionUrl === "undefined" ||