From 9df257d4ad436e8fb90a5bd3f31284cef2a1b1f6 Mon Sep 17 00:00:00 2001 From: Kishore <42832651+kishore03109@users.noreply.github.com> Date: Wed, 15 May 2024 13:03:16 +0800 Subject: [PATCH] feat(monitoring): add dns reporter --- .aws/deploy/backend-task-definition.prod.json | 1 + .../backend-task-definition.staging.json | 1 + .aws/deploy/support-task-definition.prod.json | 1 + .../support-task-definition.staging.json | 1 + src/config/config.ts | 9 + src/errors/MonitoringError.ts | 11 + src/monitoring/index.ts | 295 ++++++++++++++++++ src/server.ts | 5 + src/services/identity/LaunchesService.ts | 6 + 9 files changed, 330 insertions(+) create mode 100644 src/errors/MonitoringError.ts create mode 100644 src/monitoring/index.ts diff --git a/.aws/deploy/backend-task-definition.prod.json b/.aws/deploy/backend-task-definition.prod.json index 70cad802a..d096efcf2 100644 --- a/.aws/deploy/backend-task-definition.prod.json +++ b/.aws/deploy/backend-task-definition.prod.json @@ -102,6 +102,7 @@ "valueFrom": "PROD_INCOMING_QUEUE_URL" }, { "name": "JWT_SECRET", "valueFrom": "PROD_JWT_SECRET" }, + { "name": "KEYCDN_API_KEY", "valueFrom": "PROD_KEYCDN_API_KEY" }, { "name": "MAX_NUM_OTP_ATTEMPTS", "valueFrom": "PROD_MAX_NUM_OTP_ATTEMPTS" diff --git a/.aws/deploy/backend-task-definition.staging.json b/.aws/deploy/backend-task-definition.staging.json index 9b75e7bad..fa5991edf 100644 --- a/.aws/deploy/backend-task-definition.staging.json +++ b/.aws/deploy/backend-task-definition.staging.json @@ -101,6 +101,7 @@ "valueFrom": "STAGING_INCOMING_QUEUE_URL" }, { "name": "JWT_SECRET", "valueFrom": "STAGING_JWT_SECRET" }, + { "name": "KEYCDN_API_KEY", "valueFrom": "STAGING_KEYCDN_API_KEY" }, { "name": "MAX_NUM_OTP_ATTEMPTS", "valueFrom": "STAGING_MAX_NUM_OTP_ATTEMPTS" diff --git a/.aws/deploy/support-task-definition.prod.json b/.aws/deploy/support-task-definition.prod.json index dbf82f21a..a87e5c872 100644 --- a/.aws/deploy/support-task-definition.prod.json +++ b/.aws/deploy/support-task-definition.prod.json @@ -98,6 +98,7 @@ "valueFrom": "PROD_ISOMERPAGES_REPO_PAGE_COUNT" }, { "name": "JWT_SECRET", "valueFrom": "PROD_JWT_SECRET" }, + { "name": "KEYCDN_API_KEY", "valueFrom": "PROD_KEYCDN_API_KEY" }, { "name": "MAX_NUM_OTP_ATTEMPTS", "valueFrom": "PROD_MAX_NUM_OTP_ATTEMPTS" diff --git a/.aws/deploy/support-task-definition.staging.json b/.aws/deploy/support-task-definition.staging.json index 3ee2296c0..2b6433926 100644 --- a/.aws/deploy/support-task-definition.staging.json +++ b/.aws/deploy/support-task-definition.staging.json @@ -107,6 +107,7 @@ "valueFrom": "STAGING_ISOMERPAGES_REPO_PAGE_COUNT" }, { "name": "JWT_SECRET", "valueFrom": "STAGING_JWT_SECRET" }, + { "name": "KEYCDN_API_KEY", "valueFrom": "STAGING_KEYCDN_API_KEY" }, { "name": "MAX_NUM_OTP_ATTEMPTS", "valueFrom": "STAGING_MAX_NUM_OTP_ATTEMPTS" diff --git a/src/config/config.ts b/src/config/config.ts index 022e5f5b1..87742c49b 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -195,6 +195,7 @@ const config = convict({ }, }, }, + github: { orgName: { doc: "GitHub organization that owns all site repositories", @@ -451,6 +452,14 @@ const config = convict({ default: "", }, }, + keyCdn: { + apiKey: { + doc: "KeyCDN API key", + env: "KEYCDN_API_KEY", + format: "required-string", + default: "", + }, + }, }) // Perform validation diff --git a/src/errors/MonitoringError.ts b/src/errors/MonitoringError.ts new file mode 100644 index 000000000..431aa5479 --- /dev/null +++ b/src/errors/MonitoringError.ts @@ -0,0 +1,11 @@ +import { BaseIsomerError } from "./BaseError" + +export default class MonitoringError extends BaseIsomerError { + constructor(message: string) { + super({ + status: 500, + code: "MonitoringError", + message, + }) + } +} diff --git a/src/monitoring/index.ts b/src/monitoring/index.ts new file mode 100644 index 000000000..e3038e339 --- /dev/null +++ b/src/monitoring/index.ts @@ -0,0 +1,295 @@ +import dns from "dns/promises" + +import { Octokit } from "@octokit/rest" +import autoBind from "auto-bind" +import { errAsync, okAsync, ResultAsync } from "neverthrow" +import Papa from "papaparse" + +import parentLogger from "@logger/logger" + +import config from "@root/config/config" +import MonitoringError from "@root/errors/MonitoringError" +import LaunchesService from "@root/services/identity/LaunchesService" + +interface MonitoringServiceInterface { + launchesService: LaunchesService +} + +const IsomerHostedDomainType = { + REDIRECTION: "redirection", + INDIRECTION: "indirection", + KEYCDN: "keycdn", + AMPLIFY: "amplify", +} as const + +interface IsomerHostedDomain { + domain: string + type: typeof IsomerHostedDomainType[keyof typeof IsomerHostedDomainType] +} + +type keyCdnZoneAlias = { + name: string +} + +interface KeyCdnResponse { + data: { + zonealiases: keyCdnZoneAlias[] + } +} + +interface RedirectionDomain { + source: string + target: string +} + +interface ReportCard { + domain: string + type: typeof IsomerHostedDomainType[keyof typeof IsomerHostedDomainType] + aRecord: string[] + quadArecord: string[] + cNameRecord: string[] + caaRecord: string[] +} + +function isKeyCdnZoneAlias(object: unknown): object is keyCdnZoneAlias { + return "name" in (object as keyCdnZoneAlias) +} + +function isKeyCdnResponse(object: unknown): object is KeyCdnResponse { + return "data" in (object as KeyCdnResponse) +} + +export default class MonitoringService { + private readonly launchesService: MonitoringServiceInterface["launchesService"] + + private readonly monitoringServiceLogger = parentLogger.child({ + module: "monitoringService", + }) + + constructor({ launchesService }: MonitoringServiceInterface) { + autoBind(this) + this.launchesService = launchesService + } + + getKeyCdnDomains() { + const keyCdnApiKey = config.get("keyCdn.apiKey") + + return ResultAsync.fromPromise( + fetch(`https://api.keycdn.com/zonealiases.json`, { + headers: { + Authorization: `Basic ${btoa(`${keyCdnApiKey}:`)}`, + }, + }), + (error) => new MonitoringError(`Failed to fetch zones: ${error}`) + ) + .andThen((response) => { + if (!response.ok) { + return errAsync( + new MonitoringError( + `Failed to retrieve zones: ${response.statusText}` + ) + ) + } + return okAsync(response) + }) + .andThen((response) => + ResultAsync.fromPromise( + response.json(), + (error) => new MonitoringError(`Failed to parse response: ${error}`) + ) + ) + .andThen((data: unknown) => { + if (!isKeyCdnResponse(data)) { + return errAsync(new MonitoringError("Failed to parse response")) + } + + const domains = data.data.zonealiases + .filter(isKeyCdnZoneAlias) + .map((zone: keyCdnZoneAlias) => zone.name) + .map( + (domain) => + ({ + domain, + type: IsomerHostedDomainType.KEYCDN, + } as IsomerHostedDomain) + ) + + return okAsync(domains) + }) + } + + getAmplifyDeployments() { + return this.launchesService.getAllDomains().map((domains) => + domains.map( + (domain) => + ({ + domain, + type: IsomerHostedDomainType.AMPLIFY, + } as IsomerHostedDomain) + ) + ) + } + + /** + * While most of our redirections are in our DB, we do have ad-hoc redirections. + * @returns List of redirection domains that are listed in the isomer-redirection repository + */ + getRedirectionDomains() { + const SYSTEM_GITHUB_TOKEN = config.get("github.systemToken") + const OctokitRetry = Octokit.plugin() + const octokitWithRetry = new OctokitRetry({ + auth: SYSTEM_GITHUB_TOKEN, + request: { retries: 5 }, + }) + + return ResultAsync.fromPromise( + octokitWithRetry.request( + "GET /repos/opengovsg/isomer-redirection/contents/src/certbot-websites.csv" + ), + (error) => + new MonitoringError(`Failed to fetch redirection domains: ${error}`) + ) + .andThen((response) => { + const content = Buffer.from(response.data.content, "base64").toString( + "utf-8" + ) + return ResultAsync.fromPromise( + new Promise((resolve, reject) => { + Papa.parse(content, { + header: true, + complete(results) { + // validate the csv + if (!results.data) { + reject(new MonitoringError("Failed to parse csv")) + } + resolve(results.data as RedirectionDomain[]) + }, + error(error: unknown) { + reject(error) + }, + }) + }), + (error) => new MonitoringError(`Failed to parse csv: ${error}`) + ) + }) + .map((redirectionDomains) => + redirectionDomains + .map((domain) => domain.source) + .map( + (domain) => + ({ + domain, + type: IsomerHostedDomainType.REDIRECTION, + } as IsomerHostedDomain) + ) + ) + } + + /** + * This is in charge of fetching all the domains that are are under Isomer, inclusive + * of any subdomains and redirects. + */ + getAllDomains() { + this.monitoringServiceLogger.info("Fetching all domains") + return ResultAsync.combine([ + this.getAmplifyDeployments().mapErr( + (error) => new MonitoringError(error.message) + ), + this.getRedirectionDomains(), + this.getKeyCdnDomains(), + ]).andThen(([amplifyDeployments, redirectionDomains, keyCdnDomains]) => { + this.monitoringServiceLogger.info("Fetched all domains") + return okAsync( + [...amplifyDeployments, ...redirectionDomains, ...keyCdnDomains].sort( + (a, b) => { + const domainA = a.domain + const domainB = b.domain + if ( + domainA.startsWith("www.") && + domainA.slice(`www.`.length) === domainB + ) { + return 0 + } + if ( + domainB.startsWith("www.") && + domainA === domainB.slice(`www.`.length) + ) { + return 0 + } + if (domainA === domainB) return 0 + return domainA > domainB ? 1 : -1 + } + ) + ) + }) + } + + // todo: once /siteup logic is merged into dev, we can add that as to alert isomer team + generateReportCard(domains: IsomerHostedDomain[]) { + const reportCard: ReportCard[] = [] + + const domainResolvers = domains.map(({ domain, type }) => { + const aRecord = ResultAsync.fromPromise( + dns.resolve(domain, "A"), + (e) => e + ).orElse(() => okAsync([])) + const quadArecord = ResultAsync.fromPromise( + dns.resolve(domain, "AAAA"), + (e) => e + ).orElse(() => okAsync([])) + + const cNameRecord = ResultAsync.fromPromise( + dns.resolve(domain, "CNAME"), + (e) => e + ).orElse(() => okAsync([])) + + const caaRecord = ResultAsync.fromPromise( + dns.resolve(domain, "CAA"), + (e) => e + ) + .orElse(() => okAsync([])) + .map((records) => records.map((record) => record.toString())) + + return ResultAsync.combineWithAllErrors([ + aRecord, + quadArecord, + cNameRecord, + caaRecord, + ]) + .andThen((resolvedDns) => + okAsync({ + domain, + type, + aRecord: resolvedDns[0], + quadArecord: resolvedDns[1], + cNameRecord: resolvedDns[2], + caaRecord: resolvedDns[3], + }) + ) + .map((value) => + reportCard.push({ + ...value, + }) + ) + .andThen(() => okAsync(reportCard)) + }) + + return ResultAsync.combineWithAllErrors(domainResolvers) + .andThen(() => { + this.monitoringServiceLogger.info({ + message: "Report card generated", + meta: { + reportCard, + date: new Date(), + }, + }) + return okAsync(reportCard) + }) + .orElse(() => okAsync([])) + } + + driver() { + this.monitoringServiceLogger.info("Monitoring service started") + return this.getAllDomains().andThen(this.generateReportCard) + } +} diff --git a/src/server.ts b/src/server.ts index 68beead2c..52a3621b7 100644 --- a/src/server.ts +++ b/src/server.ts @@ -80,6 +80,7 @@ import { mailer } from "@services/utilServices/MailClient" import { apiLogger } from "./middleware/apiLogger" import { NotificationOnEditHandler } from "./middleware/notificationOnEditHandler" +import MonitoringService from "./monitoring" import getAuthenticatedSubrouter from "./routes/v2/authenticated" import { ReviewsRouter } from "./routes/v2/authenticated/review" import getAuthenticatedSitesSubrouter from "./routes/v2/authenticatedSites" @@ -362,6 +363,10 @@ const authV2Router = new AuthRouter({ sgidAuthRouter, }) +const monitoringService = new MonitoringService({ + launchesService, +}) + const app = express() useSharedMiddleware(app) diff --git a/src/services/identity/LaunchesService.ts b/src/services/identity/LaunchesService.ts index 1f6c30715..4e713d648 100644 --- a/src/services/identity/LaunchesService.ts +++ b/src/services/identity/LaunchesService.ts @@ -353,6 +353,12 @@ export class LaunchesService { new SiteLaunchError(`Failed to update site status for ${siteName}`) ) }) + + getAllDomains = () => + ResultAsync.fromPromise( + this.launchesRepository.findAll(), + () => new SiteLaunchError("Failed to fetch launches") + ).map((launch) => launch.map((l) => l.primaryDomainSource)) } export default LaunchesService