Skip to content

Commit

Permalink
feat(monitoring): add dns reporter
Browse files Browse the repository at this point in the history
  • Loading branch information
kishore03109 committed May 17, 2024
1 parent 35aa86b commit 9df257d
Show file tree
Hide file tree
Showing 9 changed files with 330 additions and 0 deletions.
1 change: 1 addition & 0 deletions .aws/deploy/backend-task-definition.prod.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions .aws/deploy/backend-task-definition.staging.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions .aws/deploy/support-task-definition.prod.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions .aws/deploy/support-task-definition.staging.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 9 additions & 0 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ const config = convict({
},
},
},

github: {
orgName: {
doc: "GitHub organization that owns all site repositories",
Expand Down Expand Up @@ -451,6 +452,14 @@ const config = convict({
default: "",
},
},
keyCdn: {
apiKey: {
doc: "KeyCDN API key",
env: "KEYCDN_API_KEY",
format: "required-string",
default: "",
},
},
})

// Perform validation
Expand Down
11 changes: 11 additions & 0 deletions src/errors/MonitoringError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { BaseIsomerError } from "./BaseError"

export default class MonitoringError extends BaseIsomerError {
constructor(message: string) {
super({
status: 500,
code: "MonitoringError",
message,
})
}
}
295 changes: 295 additions & 0 deletions src/monitoring/index.ts
Original file line number Diff line number Diff line change
@@ -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<RedirectionDomain[]>((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<ReportCard>({
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)
}
}
5 changes: 5 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -362,6 +363,10 @@ const authV2Router = new AuthRouter({
sgidAuthRouter,
})

const monitoringService = new MonitoringService({
launchesService,
})

const app = express()

useSharedMiddleware(app)
Expand Down
6 changes: 6 additions & 0 deletions src/services/identity/LaunchesService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

0 comments on commit 9df257d

Please sign in to comment.