diff --git a/.github/workflows/test-and-release.yaml b/.github/workflows/test-and-release.yaml index 07886f5ff..ae424429b 100644 --- a/.github/workflows/test-and-release.yaml +++ b/.github/workflows/test-and-release.yaml @@ -65,6 +65,7 @@ jobs: - name: Fake nRF Cloud account device run: | ./cli.sh fake-nrfcloud-account-device + ./cli.sh create-fake-nrfcloud-health-check-device - name: Deploy test resources stack run: | diff --git a/README.md b/README.md index 4ae37c7cd..afa6e60bc 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ need to prepare nRF Cloud API key. ```bash ./cli.sh configure thirdParty nrfcloud apiKey ./cli.sh initialize-nrfcloud-account +./cli.sh create-health-check-device ``` ### Deploy diff --git a/cdk/BackendLambdas.d.ts b/cdk/BackendLambdas.d.ts index f73cfc26d..4ff2b21a7 100644 --- a/cdk/BackendLambdas.d.ts +++ b/cdk/BackendLambdas.d.ts @@ -9,4 +9,5 @@ type BackendLambdas = { onDeviceMessage: PackedLambda onWebsocketConnectOrDisconnect: PackedLambda storeMessagesInTimestream: PackedLambda + healthCheck: PackedLambda } diff --git a/cdk/backend.ts b/cdk/backend.ts index a7fb35ab7..05e851d0a 100644 --- a/cdk/backend.ts +++ b/cdk/backend.ts @@ -41,6 +41,9 @@ const packagesInLayer: string[] = [ 'lodash-es', '@middy/core', ] + +const healthCheckPackagesInLayer: string[] = ['mqtt', 'ws'] + const certsDir = path.join( process.cwd(), 'certificates', @@ -84,6 +87,10 @@ new BackendApp({ id: 'baseLayer', dependencies: packagesInLayer, }), + healthCheckLayer: await packLayer({ + id: 'healthCheckLayer', + dependencies: healthCheckPackagesInLayer, + }), iotEndpoint: await getIoTEndpoint({ iot })(), mqttBridgeCertificate, caCertificate, diff --git a/cdk/packBackendLambdas.ts b/cdk/packBackendLambdas.ts index 7fe0d5bdc..95e540939 100644 --- a/cdk/packBackendLambdas.ts +++ b/cdk/packBackendLambdas.ts @@ -31,4 +31,5 @@ export const packBackendLambdas = async (): Promise => ({ 'storeMessagesInTimestream', 'lambda/storeMessagesInTimestream.ts', ), + healthCheck: await packLambdaFromPath('healthCheck', 'lambda/healthCheck.ts'), }) diff --git a/cdk/resources/HealthCheckMqttBridge.ts b/cdk/resources/HealthCheckMqttBridge.ts new file mode 100644 index 000000000..d54f4c0cc --- /dev/null +++ b/cdk/resources/HealthCheckMqttBridge.ts @@ -0,0 +1,76 @@ +import { + Duration, + aws_events_targets as EventTargets, + aws_events as Events, + aws_iam as IAM, + aws_lambda as Lambda, + Stack, +} from 'aws-cdk-lib' +import { Construct } from 'constructs' +import { type Settings as BridgeSettings } from '../../bridge/settings.js' +import type { PackedLambda } from '../helpers/lambdas/packLambda.js' +import type { DeviceStorage } from './DeviceStorage.js' +import { LambdaLogGroup } from './LambdaLogGroup.js' +import type { WebsocketAPI } from './WebsocketAPI.js' + +export type BridgeImageSettings = BridgeSettings + +export class HealthCheckMqttBridge extends Construct { + public constructor( + parent: Construct, + { + websocketAPI, + deviceStorage, + layers, + lambdaSources, + }: { + websocketAPI: WebsocketAPI + deviceStorage: DeviceStorage + layers: Lambda.ILayerVersion[] + lambdaSources: { + healthCheck: PackedLambda + } + }, + ) { + super(parent, 'healthCheckMqttBridge') + + const scheduler = new Events.Rule(this, 'scheduler', { + description: `Scheduler to health check mqtt bridge`, + schedule: Events.Schedule.rate(Duration.minutes(1)), + }) + + // Lambda functions + const healthCheck = new Lambda.Function(this, 'healthCheck', { + handler: lambdaSources.healthCheck.handler, + architecture: Lambda.Architecture.ARM_64, + runtime: Lambda.Runtime.NODEJS_18_X, + timeout: Duration.seconds(15), + memorySize: 1792, + code: Lambda.Code.fromAsset(lambdaSources.healthCheck.zipFile), + description: 'End to end test for mqtt bridge', + environment: { + VERSION: this.node.tryGetContext('version'), + LOG_LEVEL: this.node.tryGetContext('logLevel'), + NODE_NO_WARNINGS: '1', + STACK_NAME: Stack.of(this).stackName, + DEVICES_TABLE_NAME: deviceStorage.devicesTable.tableName, + WEBSOCKET_URL: websocketAPI.websocketURI, + }, + initialPolicy: [], + layers, + }) + const ssmReadPolicy = new IAM.PolicyStatement({ + effect: IAM.Effect.ALLOW, + actions: ['ssm:GetParametersByPath'], + resources: [ + `arn:aws:ssm:${Stack.of(this).region}:${ + Stack.of(this).account + }:parameter/${Stack.of(this).stackName}/thirdParty/nrfcloud`, + ], + }) + healthCheck.addToRolePolicy(ssmReadPolicy) + scheduler.addTarget(new EventTargets.LambdaFunction(healthCheck)) + deviceStorage.devicesTable.grantWriteData(healthCheck) + new LambdaLogGroup(this, 'healthCheckLog', healthCheck) + } +} diff --git a/cdk/stacks/BackendStack.ts b/cdk/stacks/BackendStack.ts index d7f83cb81..67068c082 100644 --- a/cdk/stacks/BackendStack.ts +++ b/cdk/stacks/BackendStack.ts @@ -13,6 +13,7 @@ import { ContinuousDeployment } from '../resources/ContinuousDeployment.js' import { ConvertDeviceMessages } from '../resources/ConvertDeviceMessages.js' import { DeviceShadow } from '../resources/DeviceShadow.js' import { DeviceStorage } from '../resources/DeviceStorage.js' +import { HealthCheckMqttBridge } from '../resources/HealthCheckMqttBridge.js' import { HistoricalData } from '../resources/HistoricalData.js' import { Integration, @@ -28,6 +29,7 @@ export class BackendStack extends Stack { { lambdaSources, layer, + healthCheckLayer, iotEndpoint, mqttBridgeCertificate, caCertificate, @@ -38,6 +40,7 @@ export class BackendStack extends Stack { }: { lambdaSources: BackendLambdas layer: PackedLayer + healthCheckLayer: PackedLayer iotEndpoint: string mqttBridgeCertificate: CertificateFiles caCertificate: CAFiles @@ -72,6 +75,15 @@ export class BackendStack extends Stack { 'parameterStoreExtensionLayer', parameterStoreLayerARN[Stack.of(this).region] as string, ) + const healthCheckLayerVersion = new Lambda.LayerVersion( + this, + 'healthCheckLayer', + { + code: Lambda.Code.fromAsset(healthCheckLayer.layerZipFile), + compatibleArchitectures: [Lambda.Architecture.ARM_64], + compatibleRuntimes: [Lambda.Runtime.NODEJS_18_X], + }, + ) const lambdaLayers: Lambda.ILayerVersion[] = [ baseLayer, @@ -100,6 +112,13 @@ export class BackendStack extends Stack { bridgeImageSettings, }) + new HealthCheckMqttBridge(this, { + websocketAPI, + deviceStorage, + layers: [...lambdaLayers, healthCheckLayerVersion], + lambdaSources, + }) + new ConvertDeviceMessages(this, { deviceStorage, websocketAPI, diff --git a/cli/cli.ts b/cli/cli.ts index 275b027de..f3545abbf 100644 --- a/cli/cli.ts +++ b/cli/cli.ts @@ -14,7 +14,9 @@ import psjon from '../package.json' import type { CommandDefinition } from './commands/CommandDefinition' import { configureDeviceCommand } from './commands/configure-device.js' import { configureCommand } from './commands/configure.js' -import { createFakeNrfCloudAccountDeviceCredentials } from './commands/createFakeNrfCloudAccountDeviceCredentials.js' +import { createFakeNrfCloudAccountDeviceCredentials } from './commands/create-fake-nrfcloud-account-device-credentials.js' +import { createFakeNrfCloudHealthCheckDevice } from './commands/create-fake-nrfcloud-health-check-device.js' +import { createHealthCheckDevice } from './commands/create-health-check-device.js' import { importDevicesCommand } from './commands/import-devices.js' import { initializeNRFCloudAccountCommand } from './commands/initialize-nrfcloud-account.js' import { logsCommand } from './commands/logs.js' @@ -66,6 +68,12 @@ const CLI = async ({ isCI }: { isCI: boolean }) => { ssm, }), ) + commands.push( + createFakeNrfCloudHealthCheckDevice({ + iot, + ssm, + }), + ) } else { commands.push( initializeNRFCloudAccountCommand({ @@ -74,6 +82,13 @@ const CLI = async ({ isCI }: { isCI: boolean }) => { stackName: STACK_NAME, }), ) + commands.push( + createHealthCheckDevice({ + ssm, + stackName: STACK_NAME, + env: accountEnv, + }), + ) try { const outputs = await stackOutput( new CloudFormationClient({}), diff --git a/cli/commands/createFakeNrfCloudAccountDeviceCredentials.ts b/cli/commands/create-fake-nrfcloud-account-device-credentials.ts similarity index 94% rename from cli/commands/createFakeNrfCloudAccountDeviceCredentials.ts rename to cli/commands/create-fake-nrfcloud-account-device-credentials.ts index 98bcec18f..4bb281286 100644 --- a/cli/commands/createFakeNrfCloudAccountDeviceCredentials.ts +++ b/cli/commands/create-fake-nrfcloud-account-device-credentials.ts @@ -18,13 +18,14 @@ import { SSMClient, } from '@aws-sdk/client-ssm' import chalk from 'chalk' +import { chunk } from 'lodash-es' import { randomUUID } from 'node:crypto' import { getIoTEndpoint } from '../../aws/getIoTEndpoint.js' import { STACK_NAME } from '../../cdk/stacks/stackConfig.js' import { updateSettings, type Settings } from '../../nrfcloud/settings.js' import { isString } from '../../util/isString.js' import { settingsPath } from '../../util/settings.js' -import type { CommandDefinition } from './CommandDefinition' +import type { CommandDefinition } from './CommandDefinition.js' export const createFakeNrfCloudAccountDeviceCredentials = ({ iot, @@ -114,11 +115,14 @@ export const createFakeNrfCloudAccountDeviceCredentials = ({ ...(parameters.Parameters?.map((p) => p.Name) ?? []), fakeTenantParameter, ] - await ssm.send( - new DeleteParametersCommand({ - Names: names as string[], - }), - ) + const namesChunk = chunk(names, 10) + for (const names of namesChunk) { + await ssm.send( + new DeleteParametersCommand({ + Names: names as string[], + }), + ) + } return } const tenantId = randomUUID() diff --git a/cli/commands/create-fake-nrfcloud-health-check-device.ts b/cli/commands/create-fake-nrfcloud-health-check-device.ts new file mode 100644 index 000000000..7e466f3ba --- /dev/null +++ b/cli/commands/create-fake-nrfcloud-health-check-device.ts @@ -0,0 +1,77 @@ +import { + AttachPolicyCommand, + CreateKeysAndCertificateCommand, + IoTClient, +} from '@aws-sdk/client-iot' +import { GetParameterCommand, SSMClient } from '@aws-sdk/client-ssm' +import chalk from 'chalk' +import { STACK_NAME } from '../../cdk/stacks/stackConfig.js' +import { + updateSettings, + type Settings, +} from '../../nrfcloud/healthCheckSettings.js' +import { isString } from '../../util/isString.js' +import type { CommandDefinition } from './CommandDefinition.js' + +export const createFakeNrfCloudHealthCheckDevice = ({ + iot, + ssm, +}: { + iot: IoTClient + ssm: SSMClient +}): CommandDefinition => ({ + command: 'create-fake-nrfcloud-health-check-device', + action: async () => { + const fakeTenantParameter = `/${STACK_NAME}/fakeTenant` + const tenantId = ( + await ssm.send( + new GetParameterCommand({ + Name: fakeTenantParameter, + }), + ) + ).Parameter?.Value + if (tenantId === undefined) { + throw new Error(`${STACK_NAME} has no fake nRF Cloud Account device`) + } + + const policyName = `fake-nrfcloud-account-device-policy-${tenantId}` + console.debug(chalk.magenta(`Creating IoT certificate`)) + const credentials = await iot.send( + new CreateKeysAndCertificateCommand({ + setAsActive: true, + }), + ) + + console.debug(chalk.magenta(`Attaching policy to IoT certificate`)) + await iot.send( + new AttachPolicyCommand({ + policyName, + target: credentials.certificateArn, + }), + ) + + const pk = credentials.keyPair?.PrivateKey + if ( + !isString(credentials.certificatePem) || + !isString(pk) || + !isString(credentials.certificateArn) + ) { + throw new Error(`Failed to create certificate!`) + } + + const settings: Settings = { + healthCheckClientCert: credentials.certificatePem, + healthCheckPrivateKey: pk, + healthCheckClientId: 'health-check', + healthCheckModel: 'PCA20035+solar', + healthCheckFingerPrint: '29a.ch3ckr', + } + await updateSettings({ ssm, stackName: STACK_NAME })(settings) + + console.debug(chalk.white(`Fake nRF Cloud health check device settings:`)) + Object.entries(settings).forEach(([k, v]) => { + console.debug(chalk.yellow(`${k}:`), chalk.blue(v)) + }) + }, + help: 'Creates fake nRF Cloud health check device used by the stack to end-to-end health check', +}) diff --git a/cli/commands/create-health-check-device.ts b/cli/commands/create-health-check-device.ts new file mode 100644 index 000000000..aebf71819 --- /dev/null +++ b/cli/commands/create-health-check-device.ts @@ -0,0 +1,129 @@ +import { SSMClient } from '@aws-sdk/client-ssm' +import type { Environment } from 'aws-cdk-lib' +import chalk from 'chalk' +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import { apiClient } from '../../nrfcloud/apiClient.js' +import { + updateSettings, + type Settings, +} from '../../nrfcloud/healthCheckSettings.js' +import { getAPISettings } from '../../nrfcloud/settings.js' +import { run } from '../../util/run.js' +import { ensureCertificateDir } from '../certificates.js' +import { createCA, createDeviceCertificate } from '../createCertificate.js' +import type { CommandDefinition } from './CommandDefinition.js' + +export const createHealthCheckDevice = ({ + ssm, + stackName, + env, +}: { + ssm: SSMClient + stackName: string + env: Required +}): CommandDefinition => ({ + command: 'create-health-check-device', + action: async () => { + const { apiKey, apiEndpoint } = await getAPISettings({ + ssm, + stackName, + })() + + const client = apiClient({ + endpoint: apiEndpoint, + apiKey, + }) + + const dir = ensureCertificateDir(env) + + // CA certificate + const caCertificates = await createCA(dir) + console.log( + chalk.yellow('CA certificate:'), + chalk.blue(caCertificates.certificate), + ) + + const deviceId = `health-check` + console.log(chalk.yellow('Device ID:'), chalk.blue(deviceId)) + + // Device private key + const deviceCertificates = await createDeviceCertificate({ + dest: dir, + caCertificates, + deviceId, + }) + console.log( + chalk.yellow('Private key:'), + chalk.blue(deviceCertificates.privateKey), + ) + + console.log( + chalk.yellow( + 'Device certificate', + chalk.blue(deviceCertificates.certificate), + ), + ) + + console.log( + chalk.yellow( + 'Signed device certificate', + chalk.blue(deviceCertificates.signedCert), + ), + ) + console.log( + await run({ + command: 'openssl', + args: ['x509', '-text', '-noout', '-in', deviceCertificates.signedCert], + }), + ) + + const registration = await client.registerDevices([ + { + deviceId, + subType: 'PCA20035-solar', + tags: ['health-check', 'simulators', 'hello-nrfcloud-backend'], + certPem: await readFile( + path.join(deviceCertificates.signedCert), + 'utf-8', + ), + }, + ]) + + if ('error' in registration) { + console.error(registration.error.message) + process.exit(1) + } + + if ('success' in registration && registration.success === false) { + console.error(chalk.red(`Registration failed`)) + process.exit(1) + } + + console.log( + chalk.green(`Registered device with nRF Cloud`), + chalk.cyan(deviceId), + ) + + const settings: Settings = { + healthCheckClientCert: await readFile( + path.join(deviceCertificates.signedCert), + 'utf-8', + ), + healthCheckPrivateKey: await readFile( + path.join(deviceCertificates.privateKey), + 'utf-8', + ), + healthCheckClientId: 'health-check', + healthCheckModel: 'PCA20035+solar', + healthCheckFingerPrint: '29a.ch3ckr', + } + await updateSettings({ ssm, stackName })(settings) + + console.debug(chalk.white(`nRF Cloud health check device settings:`)) + Object.entries(settings).forEach(([k, v]) => { + console.debug(chalk.yellow(`${k}:`), chalk.blue(v)) + }) + }, + help: 'Creates nRF Cloud health check device used by the stack to end-to-end health check', +}) diff --git a/cli/commands/register-simulator-device.ts b/cli/commands/register-simulator-device.ts index 483a95d01..a80bdec4d 100644 --- a/cli/commands/register-simulator-device.ts +++ b/cli/commands/register-simulator-device.ts @@ -2,20 +2,15 @@ import type { DynamoDBClient } from '@aws-sdk/client-dynamodb' import { SSMClient } from '@aws-sdk/client-ssm' import type { Environment } from 'aws-cdk-lib' import chalk from 'chalk' -import { readFile, stat } from 'node:fs/promises' +import { readFile } from 'node:fs/promises' import path from 'node:path' import { registerDevice } from '../../devices/registerDevice.js' import { apiClient } from '../../nrfcloud/apiClient.js' import { getAPISettings } from '../../nrfcloud/settings.js' -import { run } from '../../util/run.js' import { ulid } from '../../util/ulid.js' -import { - deviceCertificateLocations, - ensureCertificateDir, - simulatorCALocations, -} from '../certificates.js' +import { ensureCertificateDir } from '../certificates.js' +import { createCA, createDeviceCertificate } from '../createCertificate.js' import { fingerprintGenerator } from '../devices/fingerprintGenerator.js' -import { signDeviceCertificate } from '../devices/signDeviceCertificate.js' import type { CommandDefinition } from './CommandDefinition.js' export const registerSimulatorDeviceCommand = ({ @@ -46,122 +41,42 @@ export const registerSimulatorDeviceCommand = ({ const dir = ensureCertificateDir(env) // CA certificate - const { - privateKey: caPrivateKeyLocation, - certificate: caCertificateLocation, - } = simulatorCALocations(dir) - try { - await stat(caCertificateLocation) - } catch { - // Create a CA private key - await run({ - command: 'openssl', - args: ['genrsa', '-out', caPrivateKeyLocation, '2048'], - }) - await run({ - command: 'openssl', - args: [ - 'req', - '-x509', - '-new', - '-nodes', - '-key', - caPrivateKeyLocation, - '-sha256', - '-days', - '10957', - '-out', - caCertificateLocation, - '-subj', - '/OU=Cellular IoT Applications Team, CN=Device Simulator', - ], - }) - } + const caCertificates = await createCA(dir) console.log( chalk.yellow('CA certificate:'), - chalk.blue(caCertificateLocation), + chalk.blue(caCertificates.certificate), ) const deviceId = `simulator-${ulid()}` console.log(chalk.yellow('Device ID:'), chalk.blue(deviceId)) - // Device certificate locations - const { - privateKey: devicePrivateKeyLocation, - certificate: deviceCertificateLocation, - CSR: deviceCSRLocation, - signedCert: deviceSignedCertLocation, - } = deviceCertificateLocations(dir, deviceId) - // Device private key - await run({ - command: 'openssl', - args: [ - 'ecparam', - '-out', - devicePrivateKeyLocation, - '-name', - 'prime256v1', - '-genkey', - ], + const deviceCertificates = await createDeviceCertificate({ + dest: dir, + caCertificates, + deviceId, }) console.log( chalk.yellow('Private key:'), - chalk.blue(devicePrivateKeyLocation), + chalk.blue(deviceCertificates.privateKey), ) - // Device certificate - await run({ - command: 'openssl', - args: [ - 'req', - '-x509', - '-new', - '-nodes', - '-key', - devicePrivateKeyLocation, - '-sha256', - '-days', - '10957', - '-out', - deviceCertificateLocation, - '-subj', - `/CN=${deviceId}`, - ], - }) console.log( - chalk.yellow('Device certificate', chalk.blue(deviceCertificateLocation)), + chalk.yellow( + 'Device certificate', + chalk.blue(deviceCertificates.certificate), + ), ) - // Create CSR - await run({ - command: 'openssl', - args: [ - 'req', - '-key', - devicePrivateKeyLocation, - '-new', - '-out', - deviceCSRLocation, - '-subj', - `/CN=${deviceId}`, - ], - }) - - // Sign device cert - await signDeviceCertificate({ - dir, - deviceId, - caCertificateLocation, - caPrivateKeyLocation, - }) - const registration = await client.registerDevices([ { deviceId, subType: 'PCA20035-solar', tags: ['simulators', 'hello-nrfcloud-backend'], - certPem: await readFile(path.join(deviceSignedCertLocation), 'utf-8'), + certPem: await readFile( + path.join(deviceCertificates.signedCert), + 'utf-8', + ), }, ]) diff --git a/cli/createCertificate.ts b/cli/createCertificate.ts new file mode 100644 index 000000000..26ce495fd --- /dev/null +++ b/cli/createCertificate.ts @@ -0,0 +1,124 @@ +import { stat } from 'node:fs/promises' +import { run } from '../util/run.js' +import { + deviceCertificateLocations, + simulatorCALocations, +} from './certificates.js' +import { signDeviceCertificate } from './devices/signDeviceCertificate.js' + +export const createCA = async ( + dest: string, +): Promise<{ + privateKey: string + certificate: string +}> => { + // CA certificate + const certificates = simulatorCALocations(dest) + + // Create a CA private key + try { + await stat(certificates.certificate) + } catch { + await run({ + command: 'openssl', + args: ['genrsa', '-out', certificates.privateKey, '2048'], + }) + await run({ + command: 'openssl', + args: [ + 'req', + '-x509', + '-new', + '-nodes', + '-key', + certificates.privateKey, + '-sha256', + '-days', + '10957', + '-out', + certificates.certificate, + '-subj', + '/OU=Cellular IoT Applications Team, CN=Device Simulator', + ], + }) + } + return certificates +} + +export const createDeviceCertificate = async ({ + dest, + caCertificates, + deviceId, +}: { + dest: string + caCertificates: { + privateKey: string + certificate: string + } + deviceId: string +}): Promise<{ + privateKey: string + certificate: string + CSR: string + signedCert: string +}> => { + const deviceCertificates = deviceCertificateLocations(dest, deviceId) + + // Device private key + await run({ + command: 'openssl', + args: [ + 'ecparam', + '-out', + deviceCertificates.privateKey, + '-name', + 'prime256v1', + '-genkey', + ], + }) + + // Device certificate + await run({ + command: 'openssl', + args: [ + 'req', + '-x509', + '-new', + '-nodes', + '-key', + deviceCertificates.privateKey, + '-sha256', + '-days', + '10957', + '-out', + deviceCertificates.certificate, + '-subj', + `/CN=${deviceId}`, + ], + }) + + // Create CSR + await run({ + command: 'openssl', + args: [ + 'req', + '-key', + deviceCertificates.privateKey, + '-new', + '-out', + deviceCertificates.CSR, + '-subj', + `/CN=${deviceId}`, + ], + }) + + // Sign device cert + await signDeviceCertificate({ + dir: dest, + deviceId, + caCertificateLocation: caCertificates.certificate, + caPrivateKeyLocation: caCertificates.privateKey, + }) + + return deviceCertificates +} diff --git a/lambda/healthCheck.ts b/lambda/healthCheck.ts new file mode 100644 index 000000000..59188c4ee --- /dev/null +++ b/lambda/healthCheck.ts @@ -0,0 +1,219 @@ +import { + MetricUnits, + Metrics, + logMetrics, +} from '@aws-lambda-powertools/metrics' +import { DynamoDBClient } from '@aws-sdk/client-dynamodb' +import { SSMClient } from '@aws-sdk/client-ssm' +import middy from '@middy/core' +import { fromEnv } from '@nordicsemiconductor/from-env' +import mqtt from 'mqtt' +import assert from 'node:assert/strict' +import { WebSocket, type RawData } from 'ws' +import { registerDevice } from '../devices/registerDevice.js' +import { getSettings as getHealthCheckSettings } from '../nrfcloud/healthCheckSettings.js' +import { + getSettings as getNrfCloudSettings, + type Settings, +} from '../nrfcloud/settings.js' +import { defer } from '../util/defer.js' +import { logger } from './util/logger.js' + +const { DevicesTableName, stackName, websocketUrl } = fromEnv({ + DevicesTableName: 'DEVICES_TABLE_NAME', + stackName: 'STACK_NAME', + websocketUrl: 'WEBSOCKET_URL', +})(process.env) + +const log = logger('healthCheck') +const db = new DynamoDBClient({}) +const ssm = new SSMClient({}) + +const metrics = new Metrics({ + namespace: 'hello-nrfcloud-backend', + serviceName: 'healthCheck', +}) + +const amazonRootCA1 = + '-----BEGIN CERTIFICATE-----\n' + + 'MIIDQTCCAimgAwIBAgITBmyfz5m/jAo54vB4ikPmljZbyjANBgkqhkiG9w0BAQsF\n' + + 'ADA5MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6\n' + + 'b24gUm9vdCBDQSAxMB4XDTE1MDUyNjAwMDAwMFoXDTM4MDExNzAwMDAwMFowOTEL\n' + + 'MAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJv\n' + + 'b3QgQ0EgMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALJ4gHHKeNXj\n' + + 'ca9HgFB0fW7Y14h29Jlo91ghYPl0hAEvrAIthtOgQ3pOsqTQNroBvo3bSMgHFzZM\n' + + '9O6II8c+6zf1tRn4SWiw3te5djgdYZ6k/oI2peVKVuRF4fn9tBb6dNqcmzU5L/qw\n' + + 'IFAGbHrQgLKm+a/sRxmPUDgH3KKHOVj4utWp+UhnMJbulHheb4mjUcAwhmahRWa6\n' + + 'VOujw5H5SNz/0egwLX0tdHA114gk957EWW67c4cX8jJGKLhD+rcdqsq08p8kDi1L\n' + + '93FcXmn/6pUCyziKrlA4b9v7LWIbxcceVOF34GfID5yHI9Y/QCB/IIDEgEw+OyQm\n' + + 'jgSubJrIqg0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC\n' + + 'AYYwHQYDVR0OBBYEFIQYzIU07LwMlJQuCFmcx7IQTgoIMA0GCSqGSIb3DQEBCwUA\n' + + 'A4IBAQCY8jdaQZChGsV2USggNiMOruYou6r4lK5IpDB/G/wkjUu0yKGX9rbxenDI\n' + + 'U5PMCCjjmCXPI6T53iHTfIUJrU6adTrCC2qJeHZERxhlbI1Bjjt/msv0tadQ1wUs\n' + + 'N+gDS63pYaACbvXy8MWy7Vu33PqUXHeeE6V/Uq2V8viTO96LXFvKWlJbYK8U90vv\n' + + 'o/ufQJVtMVT8QtPHRh8jrdkPSHCa2XV4cdFyQzR1bldZwgJcJmApzyMZFo6IQ6XU\n' + + '5MsI+yMRQ+hDKXJioaldXgjUkK642M4UwtBV8ob2xJNDd2ZhwLnoQdeXeGADbkpy\n' + + 'rqXRfboQnoZsG4q5WTP468SQvvG5\n' + + '-----END CERTIFICATE-----\n' + +const { + healthCheckClientCert: deviceCert, + healthCheckPrivateKey: devicePrivateKey, + healthCheckClientId: deviceId, + healthCheckModel: model, + healthCheckFingerPrint: fingerprint, +} = await getHealthCheckSettings({ ssm, stackName })() + +const nrfCloudSettings = await getNrfCloudSettings({ ssm, stackName })() + +const publishDeviceMessage = + ({ + nrfCloudSettings, + deviceId, + deviceCert, + devicePrivateKey, + }: { + nrfCloudSettings: Settings + deviceId: string + deviceCert: string + devicePrivateKey: string + }) => + async (message: Record): Promise => { + const { promise, resolve, reject } = defer(10000) + + const mqttClient = mqtt.connect({ + host: nrfCloudSettings.mqttEndpoint, + port: 8883, + protocol: 'mqtts', + protocolVersion: 4, + clean: true, + clientId: deviceId, + key: devicePrivateKey, + cert: deviceCert, + ca: amazonRootCA1, + }) + + mqttClient.on('connect', () => { + const topic = `${nrfCloudSettings.mqttTopicPrefix}m/d/${deviceId}/d2c` + log.debug('mqtt publish', { mqttMessage: message, topic }) + mqttClient.publish(topic, JSON.stringify(message), (error) => { + if (error) return reject(error) + mqttClient.end() + return resolve() + }) + }) + + mqttClient.on('error', (error) => { + log.error(`mqtt error`, { error }) + reject(error) + }) + + await promise + } + +enum ValidateResponse { + skip, + valid, + invalid, +} + +const checkMessageFromWebsocket = async ({ + endpoint, + timeoutMS, + onConnect, + validate, +}: { + endpoint: string + timeoutMS: number + onConnect: () => Promise + validate: (message: string) => Promise +}) => { + const { promise, resolve, reject } = defer(timeoutMS) + const client = new WebSocket(endpoint) + client + .on('open', async () => { + await onConnect() + }) + .on('close', () => { + log.debug(`ws is closed`) + }) + .on('error', reject) + .on('message', async (data: RawData) => { + const result = await validate(data.toString()) + if (result !== ValidateResponse.skip) { + client.terminate() + if (result === ValidateResponse.valid) { + resolve(true) + } else { + reject(false) + } + } + }) + + return promise +} + +await registerDevice({ + db, + devicesTableName: DevicesTableName, +})({ id: deviceId, model, fingerprint }) + +const h = async (): Promise => { + let ts: number + let gain: number + metrics.addMetric('checkMessageFromWebsocket', MetricUnits.Count, 1) + try { + await checkMessageFromWebsocket({ + endpoint: `${websocketUrl}?fingerprint=${fingerprint}`, + timeoutMS: 10000, + onConnect: async () => { + ts = Date.now() + gain = 3 + Number(Math.random().toFixed(5)) + await publishDeviceMessage({ + nrfCloudSettings, + deviceId, + deviceCert, + devicePrivateKey, + })({ + appId: 'SOLAR', + messageType: 'DATA', + ts, + data: `${gain}`, + }) + }, + validate: async (message) => { + try { + const messageObj = JSON.parse(message) + log.debug(`ws incoming message`, { messageObj }) + const expectedMessage = { + '@context': + 'https://github.com/hello-nrfcloud/proto/transformed/PCA20035%2Bsolar/gain', + ts, + mA: gain, + } + + if (messageObj['@context'] !== expectedMessage['@context']) + return ValidateResponse.skip + + metrics.addMetric( + `receivingMessageDuration`, + MetricUnits.Seconds, + (Date.now() - ts) / 1000, + ) + assert.deepEqual(messageObj, expectedMessage) + return ValidateResponse.valid + } catch (error) { + log.error(`validate error`, { error }) + + return ValidateResponse.invalid + } + }, + }) + metrics.addMetric('success', MetricUnits.Count, 1) + } catch (error) { + log.error(`health check error`, { error }) + metrics.addMetric('fail', MetricUnits.Count, 1) + } +} + +export const handler = middy(h).use(logMetrics(metrics)) diff --git a/nrfcloud/apiClient.ts b/nrfcloud/apiClient.ts index feaaa560b..84ecde612 100644 --- a/nrfcloud/apiClient.ts +++ b/nrfcloud/apiClient.ts @@ -158,6 +158,14 @@ export const apiClient = ({ }, ) + if (registrationResult.ok !== true) { + return { + error: new Error( + `${registrationResult.statusText} (${registrationResult.status})`, + ), + } + } + const res = await registrationResult.json() if ('bulkOpsRequestId' in res) diff --git a/nrfcloud/healthCheckSettings.ts b/nrfcloud/healthCheckSettings.ts new file mode 100644 index 000000000..630596701 --- /dev/null +++ b/nrfcloud/healthCheckSettings.ts @@ -0,0 +1,78 @@ +import type { SSMClient } from '@aws-sdk/client-ssm' +import { getSettings as getSSMSettings, putSettings } from '../util/settings.js' + +export type Settings = { + healthCheckClientCert: string + healthCheckPrivateKey: string + healthCheckClientId: string + healthCheckModel: string + healthCheckFingerPrint: string +} + +export const updateSettings = ({ + ssm, + stackName, +}: { + ssm: SSMClient + stackName: string +}): ((settings: Partial) => Promise) => { + const settingsWriter = putSettings({ + ssm, + stackName, + scope: 'thirdParty', + system: 'nrfcloud', + }) + return async (settings): Promise => { + await Promise.all( + Object.entries(settings).map(async ([k, v]) => + settingsWriter({ + property: k, + value: v.toString(), + }), + ), + ) + } +} + +export const getSettings = ({ + ssm, + stackName, +}: { + ssm: SSMClient + stackName: string +}): (() => Promise) => { + const settingsReader = getSSMSettings({ + ssm, + stackName, + scope: 'thirdParty', + system: 'nrfcloud', + }) + return async (): Promise => { + const p = await settingsReader() + const { + healthCheckClientCert, + healthCheckPrivateKey, + healthCheckClientId, + healthCheckModel, + healthCheckFingerPrint, + } = p + if (healthCheckClientCert === undefined) + throw new Error(`No health check client certificate configured`) + if (healthCheckPrivateKey === undefined) + throw new Error(`No health check client private key configured`) + if (healthCheckClientId === undefined) + throw new Error(`No health check client id configured`) + if (healthCheckModel === undefined) + throw new Error(`No health check device model configured`) + if (healthCheckFingerPrint === undefined) + throw new Error(`No health check device fingerprint configured`) + + return { + healthCheckClientCert, + healthCheckPrivateKey, + healthCheckClientId, + healthCheckModel, + healthCheckFingerPrint, + } + } +} diff --git a/util/defer.spec.ts b/util/defer.spec.ts new file mode 100644 index 000000000..0a0a38808 --- /dev/null +++ b/util/defer.spec.ts @@ -0,0 +1,24 @@ +import { defer, DeferTimeoutError } from './defer.js' + +describe('defer', () => { + it('should resolve the promise with the provided value', async () => { + const { promise, resolve } = defer(100) + const value = 'Hello, world!' + setTimeout(() => resolve(value), 50) + const result = await promise + expect(result).toBe(value) + }) + + it('should reject the promise with the provided reason', async () => { + const { promise, reject } = defer(100) + const reason = new Error('Rejected!') + setTimeout(() => reject(reason), 50) + await expect(promise).rejects.toBe(reason) + }) + + it('should reject the promise with DeferTimeoutError when timeout occurs', async () => { + const timeoutMS = 100 + const { promise } = defer(timeoutMS) + await expect(promise).rejects.toThrow(DeferTimeoutError) + }) +}) diff --git a/util/defer.ts b/util/defer.ts new file mode 100644 index 000000000..21448a2ea --- /dev/null +++ b/util/defer.ts @@ -0,0 +1,34 @@ +type ReturnDefer = { + promise: Promise + resolve: (value: T) => void + reject: (reason: any) => void +} + +export class DeferTimeoutError extends Error { + constructor(message = 'Timeout') { + super(message) + Object.setPrototypeOf(this, DeferTimeoutError.prototype) + } +} + +export const defer = (timeoutMS: number): ReturnDefer => { + const ret = {} as ReturnDefer + const timer = setTimeout(() => { + ret.reject(new DeferTimeoutError()) + }, timeoutMS) + + const promise = new Promise((_resolve, _reject) => { + ret.resolve = (v) => { + clearTimeout(timer) + _resolve(v) + } + ret.reject = (reason) => { + clearTimeout(timer) + _reject(reason) + } + }) + + ret.promise = promise + + return ret +}