From 967d089b07c302d5bf2c03621b74a7d637f830f8 Mon Sep 17 00:00:00 2001 From: Pisut Sritrakulchai Date: Wed, 21 Jun 2023 14:57:43 +0200 Subject: [PATCH 1/6] feat: end-2-end mqttt bridge health check --- cdk/BackendLambdas.d.ts | 1 + cdk/backend.ts | 4 + cdk/packBackendLambdas.ts | 1 + cdk/resources/Integration.ts | 61 +++++++++++ cdk/stacks/BackendStack.ts | 7 ++ lambda/healthCheck.ts | 190 +++++++++++++++++++++++++++++++++++ 6 files changed, 264 insertions(+) create mode 100644 lambda/healthCheck.ts 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 8c49f05d1..729f7f74f 100644 --- a/cdk/backend.ts +++ b/cdk/backend.ts @@ -40,6 +40,8 @@ const packagesInLayer: string[] = [ '@aws-lambda-powertools/metrics', 'lodash-es', '@middy/core', + 'mqtt', + 'ws', ] const certsDir = path.join(process.cwd(), 'certificates', accountEnv.account) const mqttBridgeCertificate = await ensureMQTTBridgeCredentials({ @@ -52,6 +54,7 @@ const caCertificate = await ensureCA({ iot, debug: debug('CA certificate'), })() +const amazonRootCA1 = path.join(process.cwd(), 'data', 'AmazonRootCA1.pem') // Prebuild / reuse docker image // NOTE: It is intention that release image tag can be undefined during the development, @@ -83,6 +86,7 @@ new BackendApp({ iotEndpoint: await getIoTEndpoint({ iot })(), mqttBridgeCertificate, caCertificate, + amazonRootCA1, bridgeImageSettings: { imageTag, repositoryUri, 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/Integration.ts b/cdk/resources/Integration.ts index 54ad4ffbd..e5e4f7ef0 100644 --- a/cdk/resources/Integration.ts +++ b/cdk/resources/Integration.ts @@ -1,8 +1,13 @@ import { + Duration, aws_ec2 as EC2, aws_ecr as ECR, aws_ecs as ECS, + aws_events_targets as EventTargets, + aws_events as Events, + aws_iam as IAM, aws_iot as IoT, + aws_lambda as Lambda, Stack, } from 'aws-cdk-lib' import type { IRepository } from 'aws-cdk-lib/aws-ecr' @@ -20,6 +25,10 @@ import { type Settings as nRFCloudSettings, } from '../../nrfcloud/settings.js' import { settingsPath } from '../../util/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 @@ -29,15 +38,27 @@ export class Integration extends Construct { public constructor( parent: Construct, { + websocketAPI, + deviceStorage, iotEndpoint, mqttBridgeCertificate, caCertificate, + amazonRootCA1, bridgeImageSettings, + layers, + lambdaSources, }: { + websocketAPI: WebsocketAPI + deviceStorage: DeviceStorage iotEndpoint: string mqttBridgeCertificate: CertificateFiles caCertificate: CAFiles + amazonRootCA1: string bridgeImageSettings: BridgeImageSettings + layers: Lambda.ILayerVersion[] + lambdaSources: { + healthCheck: PackedLambda + } }, ) { super(parent, 'Integration') @@ -277,5 +298,45 @@ export class Integration extends Construct { EC2.Port.tcp(1883), 'inbound-mqtt', ) + + 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(5), + 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, + AMAZON_ROOT_CA1: readFileSync(amazonRootCA1, { encoding: 'utf8' }), + }, + 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..6b5cbbe14 100644 --- a/cdk/stacks/BackendStack.ts +++ b/cdk/stacks/BackendStack.ts @@ -31,6 +31,7 @@ export class BackendStack extends Stack { iotEndpoint, mqttBridgeCertificate, caCertificate, + amazonRootCA1, bridgeImageSettings, repository, gitHubOICDProviderArn, @@ -41,6 +42,7 @@ export class BackendStack extends Stack { iotEndpoint: string mqttBridgeCertificate: CertificateFiles caCertificate: CAFiles + amazonRootCA1: string bridgeImageSettings: BridgeImageSettings gitHubOICDProviderArn: string repository: { @@ -94,10 +96,15 @@ export class BackendStack extends Stack { }) new Integration(this, { + websocketAPI, + deviceStorage, iotEndpoint, mqttBridgeCertificate, caCertificate, + amazonRootCA1, bridgeImageSettings, + layers: lambdaLayers, + lambdaSources, }) new ConvertDeviceMessages(this, { diff --git a/lambda/healthCheck.ts b/lambda/healthCheck.ts new file mode 100644 index 000000000..30bcdce48 --- /dev/null +++ b/lambda/healthCheck.ts @@ -0,0 +1,190 @@ +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, type Settings } from '../nrfcloud/settings.js' +import { logger } from './util/logger.js' + +const { DevicesTableName, stackName, amazonRootCA1, websocketUrl } = fromEnv({ + DevicesTableName: 'DEVICES_TABLE_NAME', + stackName: 'STACK_NAME', + amazonRootCA1: 'AMAZON_ROOT_CA1', + 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 deviceId = 'health-check' +const model = 'PCA20035+solar' +const fingerprint = '29a.ch3ckr' +const ts = Date.now() +const gain = 3.12345 +const expectedMessage = { + '@context': + 'https://github.com/hello-nrfcloud/proto/transformed/PCA20035%2Bsolar/gain', + ts, + mA: gain, +} + +const accountDeviceSettings = await getSettings({ + ssm, + stackName, +})() + +const publishDeviceMessage = + (bridgeInfo: Settings) => + async (deviceId: string, message: Record) => { + await new Promise((resolve, reject) => { + const mqttClient = mqtt.connect({ + host: bridgeInfo.mqttEndpoint, + port: 8883, + protocol: 'mqtts', + protocolVersion: 4, + clean: true, + clientId: deviceId, + key: bridgeInfo.accountDevicePrivateKey, + cert: bridgeInfo.accountDeviceClientCert, + ca: amazonRootCA1, + }) + + mqttClient.on('connect', () => { + const topic = `${bridgeInfo.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(void 0) + }) + }) + + mqttClient.on('error', (error) => { + log.error(`mqtt error`, { error }) + reject(error) + }) + }) + } + +type ReturnDefer = { + promise: Promise + resolve: (value: T) => void + reject: (reason: any) => void +} + +const defer = (timeout: number): ReturnDefer => { + const ret = {} as ReturnDefer + const timer = setTimeout(() => { + ret.reject('timeout') + }, timeout) + + 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 +} + +enum ValidateResponse { + skip, + valid, + invalid, +} + +const checkMessageFromWebsocket = async ({ + endpoint, + timeoutMS, + onConnect, + validate, +}: { + endpoint: string + timeoutMS: number + onConnect: () => Promise + validate: (message: string) => Promise +}) => { + const promise = defer(timeoutMS) + const client = new WebSocket(endpoint) + client + .on('open', onConnect) + .on('error', (error) => promise.reject(error)) + .on('message', async (data: RawData) => { + const result = await validate(data.toString()) + if (result !== ValidateResponse.skip) { + if (result === ValidateResponse.valid) { + promise.resolve(true) + } else { + promise.reject(false) + } + } + }) + + return promise.promise +} + +const h = async (): Promise => { + await registerDevice({ + db, + devicesTableName: DevicesTableName, + })({ id: deviceId, model, fingerprint }) + + metrics.addMetric('checkMessageFromWebsocket', MetricUnits.Count, 1) + try { + await checkMessageFromWebsocket({ + endpoint: `${websocketUrl}?fingerprint=${fingerprint}`, + timeoutMS: 10000, + onConnect: async () => { + await publishDeviceMessage(accountDeviceSettings)(deviceId, { + appId: 'SOLAR', + messageType: 'DATA', + ts, + data: `${gain}`, + }) + }, + validate: async (message) => { + try { + const messageObj = JSON.parse(message) + log.debug(`ws incoming message`, { messageObj }) + + if (messageObj['@context'] !== expectedMessage['@context']) + return ValidateResponse.skip + + 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)) From c69a9a2e6c845046ec6994cf8473ea76cc5613e2 Mon Sep 17 00:00:00 2001 From: Pisut Sritrakulchai Date: Wed, 21 Jun 2023 19:03:10 +0200 Subject: [PATCH 2/6] refactor: move health check into its own stack --- cdk/backend.ts | 11 ++- cdk/resources/HealthCheckMqttBridge.ts | 97 ++++++++++++++++++ cdk/resources/Integration.ts | 61 ------------ cdk/stacks/BackendStack.ts | 24 +++-- lambda/healthCheck.ts | 131 +++++++++++-------------- util/defer.spec.ts | 24 +++++ util/defer.ts | 34 +++++++ 7 files changed, 239 insertions(+), 143 deletions(-) create mode 100644 cdk/resources/HealthCheckMqttBridge.ts create mode 100644 util/defer.spec.ts create mode 100644 util/defer.ts diff --git a/cdk/backend.ts b/cdk/backend.ts index 729f7f74f..90ae08a2f 100644 --- a/cdk/backend.ts +++ b/cdk/backend.ts @@ -40,9 +40,10 @@ const packagesInLayer: string[] = [ '@aws-lambda-powertools/metrics', 'lodash-es', '@middy/core', - 'mqtt', - 'ws', ] + +const healthCheckPackagesInLayer: string[] = ['mqtt', 'ws'] + const certsDir = path.join(process.cwd(), 'certificates', accountEnv.account) const mqttBridgeCertificate = await ensureMQTTBridgeCredentials({ iot, @@ -54,7 +55,6 @@ const caCertificate = await ensureCA({ iot, debug: debug('CA certificate'), })() -const amazonRootCA1 = path.join(process.cwd(), 'data', 'AmazonRootCA1.pem') // Prebuild / reuse docker image // NOTE: It is intention that release image tag can be undefined during the development, @@ -83,10 +83,13 @@ new BackendApp({ id: 'baseLayer', dependencies: packagesInLayer, }), + healthCheckLayer: await packLayer({ + id: 'healthCheckLayer', + dependencies: healthCheckPackagesInLayer, + }), iotEndpoint: await getIoTEndpoint({ iot })(), mqttBridgeCertificate, caCertificate, - amazonRootCA1, bridgeImageSettings: { imageTag, repositoryUri, diff --git a/cdk/resources/HealthCheckMqttBridge.ts b/cdk/resources/HealthCheckMqttBridge.ts new file mode 100644 index 000000000..6bd4dde9a --- /dev/null +++ b/cdk/resources/HealthCheckMqttBridge.ts @@ -0,0 +1,97 @@ +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(5), + 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, + AMAZON_ROOT_CA1: + '-----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', + }, + 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/resources/Integration.ts b/cdk/resources/Integration.ts index e5e4f7ef0..54ad4ffbd 100644 --- a/cdk/resources/Integration.ts +++ b/cdk/resources/Integration.ts @@ -1,13 +1,8 @@ import { - Duration, aws_ec2 as EC2, aws_ecr as ECR, aws_ecs as ECS, - aws_events_targets as EventTargets, - aws_events as Events, - aws_iam as IAM, aws_iot as IoT, - aws_lambda as Lambda, Stack, } from 'aws-cdk-lib' import type { IRepository } from 'aws-cdk-lib/aws-ecr' @@ -25,10 +20,6 @@ import { type Settings as nRFCloudSettings, } from '../../nrfcloud/settings.js' import { settingsPath } from '../../util/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 @@ -38,27 +29,15 @@ export class Integration extends Construct { public constructor( parent: Construct, { - websocketAPI, - deviceStorage, iotEndpoint, mqttBridgeCertificate, caCertificate, - amazonRootCA1, bridgeImageSettings, - layers, - lambdaSources, }: { - websocketAPI: WebsocketAPI - deviceStorage: DeviceStorage iotEndpoint: string mqttBridgeCertificate: CertificateFiles caCertificate: CAFiles - amazonRootCA1: string bridgeImageSettings: BridgeImageSettings - layers: Lambda.ILayerVersion[] - lambdaSources: { - healthCheck: PackedLambda - } }, ) { super(parent, 'Integration') @@ -298,45 +277,5 @@ export class Integration extends Construct { EC2.Port.tcp(1883), 'inbound-mqtt', ) - - 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(5), - 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, - AMAZON_ROOT_CA1: readFileSync(amazonRootCA1, { encoding: 'utf8' }), - }, - 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 6b5cbbe14..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,10 +29,10 @@ export class BackendStack extends Stack { { lambdaSources, layer, + healthCheckLayer, iotEndpoint, mqttBridgeCertificate, caCertificate, - amazonRootCA1, bridgeImageSettings, repository, gitHubOICDProviderArn, @@ -39,10 +40,10 @@ export class BackendStack extends Stack { }: { lambdaSources: BackendLambdas layer: PackedLayer + healthCheckLayer: PackedLayer iotEndpoint: string mqttBridgeCertificate: CertificateFiles caCertificate: CAFiles - amazonRootCA1: string bridgeImageSettings: BridgeImageSettings gitHubOICDProviderArn: string repository: { @@ -74,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, @@ -96,14 +106,16 @@ export class BackendStack extends Stack { }) new Integration(this, { - websocketAPI, - deviceStorage, iotEndpoint, mqttBridgeCertificate, caCertificate, - amazonRootCA1, bridgeImageSettings, - layers: lambdaLayers, + }) + + new HealthCheckMqttBridge(this, { + websocketAPI, + deviceStorage, + layers: [...lambdaLayers, healthCheckLayerVersion], lambdaSources, }) diff --git a/lambda/healthCheck.ts b/lambda/healthCheck.ts index 30bcdce48..078f54d82 100644 --- a/lambda/healthCheck.ts +++ b/lambda/healthCheck.ts @@ -12,6 +12,7 @@ import assert from 'node:assert/strict' import { WebSocket, type RawData } from 'ws' import { registerDevice } from '../devices/registerDevice.js' import { getSettings, type Settings } from '../nrfcloud/settings.js' +import { defer } from '../util/defer.js' import { logger } from './util/logger.js' const { DevicesTableName, stackName, amazonRootCA1, websocketUrl } = fromEnv({ @@ -33,14 +34,7 @@ const metrics = new Metrics({ const deviceId = 'health-check' const model = 'PCA20035+solar' const fingerprint = '29a.ch3ckr' -const ts = Date.now() const gain = 3.12345 -const expectedMessage = { - '@context': - 'https://github.com/hello-nrfcloud/proto/transformed/PCA20035%2Bsolar/gain', - ts, - mA: gain, -} const accountDeviceSettings = await getSettings({ ssm, @@ -48,65 +42,39 @@ const accountDeviceSettings = await getSettings({ })() const publishDeviceMessage = - (bridgeInfo: Settings) => - async (deviceId: string, message: Record) => { - await new Promise((resolve, reject) => { - const mqttClient = mqtt.connect({ - host: bridgeInfo.mqttEndpoint, - port: 8883, - protocol: 'mqtts', - protocolVersion: 4, - clean: true, - clientId: deviceId, - key: bridgeInfo.accountDevicePrivateKey, - cert: bridgeInfo.accountDeviceClientCert, - ca: amazonRootCA1, - }) - - mqttClient.on('connect', () => { - const topic = `${bridgeInfo.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(void 0) - }) - }) + (nrfCloudInfo: Settings) => + async (deviceId: string, message: Record): Promise => { + const { promise, resolve, reject } = defer(10000) + + const mqttClient = mqtt.connect({ + host: nrfCloudInfo.mqttEndpoint, + port: 8883, + protocol: 'mqtts', + protocolVersion: 4, + clean: true, + clientId: deviceId, + key: nrfCloudInfo.accountDevicePrivateKey, + cert: nrfCloudInfo.accountDeviceClientCert, + ca: amazonRootCA1, + }) - mqttClient.on('error', (error) => { - log.error(`mqtt error`, { error }) - reject(error) + mqttClient.on('connect', () => { + const topic = `${nrfCloudInfo.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() }) }) - } -type ReturnDefer = { - promise: Promise - resolve: (value: T) => void - reject: (reason: any) => void -} + mqttClient.on('error', (error) => { + log.error(`mqtt error`, { error }) + reject(error) + }) -const defer = (timeout: number): ReturnDefer => { - const ret = {} as ReturnDefer - const timer = setTimeout(() => { - ret.reject('timeout') - }, timeout) - - 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 -} + await promise + } enum ValidateResponse { skip, @@ -125,37 +93,45 @@ const checkMessageFromWebsocket = async ({ onConnect: () => Promise validate: (message: string) => Promise }) => { - const promise = defer(timeoutMS) + const { promise, resolve, reject } = defer(timeoutMS) const client = new WebSocket(endpoint) client - .on('open', onConnect) - .on('error', (error) => promise.reject(error)) + .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) { - promise.resolve(true) + resolve(true) } else { - promise.reject(false) + reject(false) } } }) - return promise.promise + return promise } -const h = async (): Promise => { - await registerDevice({ - db, - devicesTableName: DevicesTableName, - })({ id: deviceId, model, fingerprint }) +await registerDevice({ + db, + devicesTableName: DevicesTableName, +})({ id: deviceId, model, fingerprint }) +const h = async (): Promise => { + let ts: number metrics.addMetric('checkMessageFromWebsocket', MetricUnits.Count, 1) try { await checkMessageFromWebsocket({ endpoint: `${websocketUrl}?fingerprint=${fingerprint}`, timeoutMS: 10000, onConnect: async () => { + ts = Date.now() await publishDeviceMessage(accountDeviceSettings)(deviceId, { appId: 'SOLAR', messageType: 'DATA', @@ -167,10 +143,21 @@ const h = async (): Promise => { 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) { 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 +} From 13340e1750f8827777f2a37f5bc0736eeb7fe782 Mon Sep 17 00:00:00 2001 From: Pisut Sritrakulchai Date: Thu, 22 Jun 2023 11:56:18 +0200 Subject: [PATCH 3/6] refactor: hard code amazon root cert into source code --- cdk/resources/HealthCheckMqttBridge.ts | 21 --------------------- lambda/healthCheck.ts | 25 +++++++++++++++++++++++-- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/cdk/resources/HealthCheckMqttBridge.ts b/cdk/resources/HealthCheckMqttBridge.ts index 6bd4dde9a..b88838c41 100644 --- a/cdk/resources/HealthCheckMqttBridge.ts +++ b/cdk/resources/HealthCheckMqttBridge.ts @@ -55,27 +55,6 @@ export class HealthCheckMqttBridge extends Construct { STACK_NAME: Stack.of(this).stackName, DEVICES_TABLE_NAME: deviceStorage.devicesTable.tableName, WEBSOCKET_URL: websocketAPI.websocketURI, - AMAZON_ROOT_CA1: - '-----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', }, initialPolicy: [], layers, diff --git a/lambda/healthCheck.ts b/lambda/healthCheck.ts index 078f54d82..35969da53 100644 --- a/lambda/healthCheck.ts +++ b/lambda/healthCheck.ts @@ -15,10 +15,9 @@ import { getSettings, type Settings } from '../nrfcloud/settings.js' import { defer } from '../util/defer.js' import { logger } from './util/logger.js' -const { DevicesTableName, stackName, amazonRootCA1, websocketUrl } = fromEnv({ +const { DevicesTableName, stackName, websocketUrl } = fromEnv({ DevicesTableName: 'DEVICES_TABLE_NAME', stackName: 'STACK_NAME', - amazonRootCA1: 'AMAZON_ROOT_CA1', websocketUrl: 'WEBSOCKET_URL', })(process.env) @@ -31,6 +30,28 @@ const metrics = new Metrics({ 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 deviceId = 'health-check' const model = 'PCA20035+solar' const fingerprint = '29a.ch3ckr' From 36fb0f81afe12398aecc3fadfe434a02e0961bb3 Mon Sep 17 00:00:00 2001 From: Pisut Sritrakulchai Date: Thu, 22 Jun 2023 22:49:49 +0200 Subject: [PATCH 4/6] feat: create fake health check device --- .github/workflows/test-and-release.yaml | 1 + cli/cli.ts | 9 ++- ...ke-nrfcloud-account-device-credentials.ts} | 2 +- ...reate-fake-nrfcloud-health-check-device.ts | 77 ++++++++++++++++++ lambda/healthCheck.ts | 53 +++++++++---- nrfcloud/healthCheckSettings.ts | 78 +++++++++++++++++++ 6 files changed, 202 insertions(+), 18 deletions(-) rename cli/commands/{createFakeNrfCloudAccountDeviceCredentials.ts => create-fake-nrfcloud-account-device-credentials.ts} (98%) create mode 100644 cli/commands/create-fake-nrfcloud-health-check-device.ts create mode 100644 nrfcloud/healthCheckSettings.ts diff --git a/.github/workflows/test-and-release.yaml b/.github/workflows/test-and-release.yaml index 5306e7c1d..464c4cf7d 100644 --- a/.github/workflows/test-and-release.yaml +++ b/.github/workflows/test-and-release.yaml @@ -64,6 +64,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/cli/cli.ts b/cli/cli.ts index 16d1d3776..8401ee240 100644 --- a/cli/cli.ts +++ b/cli/cli.ts @@ -12,7 +12,8 @@ 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 { initializeNRFCloudAccountCommand } from './commands/initialize-nrfcloud-account.js' import { logsCommand } from './commands/logs.js' import { registerDeviceCommand } from './commands/register-device.js' @@ -58,6 +59,12 @@ const CLI = async ({ isCI }: { isCI: boolean }) => { ssm, }), ) + commands.push( + createFakeNrfCloudHealthCheckDevice({ + iot, + ssm, + }), + ) } else { commands.push( initializeNRFCloudAccountCommand({ diff --git a/cli/commands/createFakeNrfCloudAccountDeviceCredentials.ts b/cli/commands/create-fake-nrfcloud-account-device-credentials.ts similarity index 98% rename from cli/commands/createFakeNrfCloudAccountDeviceCredentials.ts rename to cli/commands/create-fake-nrfcloud-account-device-credentials.ts index 98bcec18f..ccd3e5f5b 100644 --- a/cli/commands/createFakeNrfCloudAccountDeviceCredentials.ts +++ b/cli/commands/create-fake-nrfcloud-account-device-credentials.ts @@ -24,7 +24,7 @@ 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, 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..9618ea005 --- /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(`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/lambda/healthCheck.ts b/lambda/healthCheck.ts index 35969da53..59188c4ee 100644 --- a/lambda/healthCheck.ts +++ b/lambda/healthCheck.ts @@ -11,7 +11,11 @@ import mqtt from 'mqtt' import assert from 'node:assert/strict' import { WebSocket, type RawData } from 'ws' import { registerDevice } from '../devices/registerDevice.js' -import { getSettings, type Settings } from '../nrfcloud/settings.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' @@ -52,35 +56,45 @@ const amazonRootCA1 = 'rqXRfboQnoZsG4q5WTP468SQvvG5\n' + '-----END CERTIFICATE-----\n' -const deviceId = 'health-check' -const model = 'PCA20035+solar' -const fingerprint = '29a.ch3ckr' -const gain = 3.12345 +const { + healthCheckClientCert: deviceCert, + healthCheckPrivateKey: devicePrivateKey, + healthCheckClientId: deviceId, + healthCheckModel: model, + healthCheckFingerPrint: fingerprint, +} = await getHealthCheckSettings({ ssm, stackName })() -const accountDeviceSettings = await getSettings({ - ssm, - stackName, -})() +const nrfCloudSettings = await getNrfCloudSettings({ ssm, stackName })() const publishDeviceMessage = - (nrfCloudInfo: Settings) => - async (deviceId: string, message: Record): Promise => { + ({ + 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: nrfCloudInfo.mqttEndpoint, + host: nrfCloudSettings.mqttEndpoint, port: 8883, protocol: 'mqtts', protocolVersion: 4, clean: true, clientId: deviceId, - key: nrfCloudInfo.accountDevicePrivateKey, - cert: nrfCloudInfo.accountDeviceClientCert, + key: devicePrivateKey, + cert: deviceCert, ca: amazonRootCA1, }) mqttClient.on('connect', () => { - const topic = `${nrfCloudInfo.mqttTopicPrefix}m/d/${deviceId}/d2c` + 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) @@ -146,6 +160,7 @@ await registerDevice({ const h = async (): Promise => { let ts: number + let gain: number metrics.addMetric('checkMessageFromWebsocket', MetricUnits.Count, 1) try { await checkMessageFromWebsocket({ @@ -153,7 +168,13 @@ const h = async (): Promise => { timeoutMS: 10000, onConnect: async () => { ts = Date.now() - await publishDeviceMessage(accountDeviceSettings)(deviceId, { + gain = 3 + Number(Math.random().toFixed(5)) + await publishDeviceMessage({ + nrfCloudSettings, + deviceId, + deviceCert, + devicePrivateKey, + })({ appId: 'SOLAR', messageType: 'DATA', ts, 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, + } + } +} From ae286ef6f5c811328f3d9b7e60d8d0b80204a09e Mon Sep 17 00:00:00 2001 From: Pisut Sritrakulchai Date: Thu, 22 Jun 2023 23:20:38 +0200 Subject: [PATCH 5/6] fix: delete parameter store When using DeleteParametersCommand to delete parameters more than 10, it will error as Member must have length less than or equal to 10 --- ...ate-fake-nrfcloud-account-device-credentials.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/cli/commands/create-fake-nrfcloud-account-device-credentials.ts b/cli/commands/create-fake-nrfcloud-account-device-credentials.ts index ccd3e5f5b..4bb281286 100644 --- a/cli/commands/create-fake-nrfcloud-account-device-credentials.ts +++ b/cli/commands/create-fake-nrfcloud-account-device-credentials.ts @@ -18,6 +18,7 @@ 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' @@ -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() From 56da9d81b22c61700e79f5bf6c2115084cf27b05 Mon Sep 17 00:00:00 2001 From: Pisut Sritrakulchai Date: Sat, 24 Jun 2023 00:54:27 +0200 Subject: [PATCH 6/6] feat: add create health check device cli --- README.md | 1 + cdk/resources/HealthCheckMqttBridge.ts | 2 +- cli/cli.ts | 7 + ...reate-fake-nrfcloud-health-check-device.ts | 4 +- cli/commands/create-health-check-device.ts | 126 +++++++++++++++++ cli/commands/register-simulator-device.ts | 130 +++-------------- cli/createCertificate.ts | 132 ++++++++++++++++++ nrfcloud/apiClient.ts | 8 ++ 8 files changed, 297 insertions(+), 113 deletions(-) create mode 100644 cli/commands/create-health-check-device.ts create mode 100644 cli/createCertificate.ts 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/resources/HealthCheckMqttBridge.ts b/cdk/resources/HealthCheckMqttBridge.ts index b88838c41..d54f4c0cc 100644 --- a/cdk/resources/HealthCheckMqttBridge.ts +++ b/cdk/resources/HealthCheckMqttBridge.ts @@ -44,7 +44,7 @@ export class HealthCheckMqttBridge extends Construct { handler: lambdaSources.healthCheck.handler, architecture: Lambda.Architecture.ARM_64, runtime: Lambda.Runtime.NODEJS_18_X, - timeout: Duration.seconds(5), + timeout: Duration.seconds(15), memorySize: 1792, code: Lambda.Code.fromAsset(lambdaSources.healthCheck.zipFile), description: 'End to end test for mqtt bridge', diff --git a/cli/cli.ts b/cli/cli.ts index 8401ee240..4286e1550 100644 --- a/cli/cli.ts +++ b/cli/cli.ts @@ -14,6 +14,7 @@ import { configureDeviceCommand } from './commands/configure-device.js' import { configureCommand } from './commands/configure.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 { initializeNRFCloudAccountCommand } from './commands/initialize-nrfcloud-account.js' import { logsCommand } from './commands/logs.js' import { registerDeviceCommand } from './commands/register-device.js' @@ -73,6 +74,12 @@ const CLI = async ({ isCI }: { isCI: boolean }) => { stackName: STACK_NAME, }), ) + commands.push( + createHealthCheckDevice({ + ssm, + stackName: STACK_NAME, + }), + ) try { const outputs = await stackOutput( new CloudFormationClient({}), diff --git a/cli/commands/create-fake-nrfcloud-health-check-device.ts b/cli/commands/create-fake-nrfcloud-health-check-device.ts index 9618ea005..7e466f3ba 100644 --- a/cli/commands/create-fake-nrfcloud-health-check-device.ts +++ b/cli/commands/create-fake-nrfcloud-health-check-device.ts @@ -68,10 +68,10 @@ export const createFakeNrfCloudHealthCheckDevice = ({ } await updateSettings({ ssm, stackName: STACK_NAME })(settings) - console.debug(chalk.white(`nRF Cloud health check device 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 nRF Cloud health check device used by the stack to end-to-end health check', + 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..2a83b9d6f --- /dev/null +++ b/cli/commands/create-health-check-device.ts @@ -0,0 +1,126 @@ +import { SSMClient } from '@aws-sdk/client-ssm' +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, +}: { + ssm: SSMClient + stackName: string +}): CommandDefinition => ({ + command: 'create-health-check-device', + action: async () => { + const { apiKey, apiEndpoint } = await getAPISettings({ + ssm, + stackName, + })() + + const client = apiClient({ + endpoint: apiEndpoint, + apiKey, + }) + + const dir = ensureCertificateDir() + + // 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 efb2f51d7..c543a3aa4 100644 --- a/cli/commands/register-simulator-device.ts +++ b/cli/commands/register-simulator-device.ts @@ -1,18 +1,15 @@ import type { DynamoDBClient } from '@aws-sdk/client-dynamodb' import { SSMClient } from '@aws-sdk/client-ssm' 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 type { CommandDefinition } from './CommandDefinition.js' export const registerSimulatorDeviceCommand = ({ @@ -41,133 +38,43 @@ export const registerSimulatorDeviceCommand = ({ const dir = ensureCertificateDir() // 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 private key - const { - privateKey: devicePrivateKeyLocation, - certificate: deviceCertificateLocation, - CSR: deviceCSRLocation, - signedCert: deviceSignedCertLocation, - } = deviceCertificateLocations(dir, deviceId) - - 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), + ), ) - // Sign device cert - await run({ - command: 'openssl', - args: [ - 'req', - '-key', - devicePrivateKeyLocation, - '-new', - '-out', - deviceCSRLocation, - '-subj', - `/CN=${deviceId}`, - ], - }) - await run({ - command: 'openssl', - args: [ - 'x509', - '-req', - '-CA', - caCertificateLocation, - '-CAkey', - caPrivateKeyLocation, - '-in', - deviceCSRLocation, - '-out', - deviceSignedCertLocation, - '-days', - '10957', - ], - }) console.log( chalk.yellow( 'Signed device certificate', - chalk.blue(deviceSignedCertLocation), + chalk.blue(deviceCertificates.signedCert), ), ) console.log( await run({ command: 'openssl', - args: ['x509', '-text', '-noout', '-in', deviceSignedCertLocation], + args: ['x509', '-text', '-noout', '-in', deviceCertificates.signedCert], }), ) @@ -176,7 +83,10 @@ export const registerSimulatorDeviceCommand = ({ 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..78ba3dc47 --- /dev/null +++ b/cli/createCertificate.ts @@ -0,0 +1,132 @@ +import { stat } from 'node:fs/promises' +import { run } from '../util/run.js' +import { + deviceCertificateLocations, + simulatorCALocations, +} from './certificates.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}`, + ], + }) + + // Sign device cert + await run({ + command: 'openssl', + args: [ + 'req', + '-key', + deviceCertificates.privateKey, + '-new', + '-out', + deviceCertificates.CSR, + '-subj', + `/CN=${deviceId}`, + ], + }) + await run({ + command: 'openssl', + args: [ + 'x509', + '-req', + '-CA', + caCertificates.certificate, + '-CAkey', + caCertificates.privateKey, + '-in', + deviceCertificates.CSR, + '-out', + deviceCertificates.signedCert, + '-days', + '10957', + ], + }) + + return deviceCertificates +} diff --git a/nrfcloud/apiClient.ts b/nrfcloud/apiClient.ts index 25063846c..08ce777ac 100644 --- a/nrfcloud/apiClient.ts +++ b/nrfcloud/apiClient.ts @@ -154,6 +154,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) return { success: true }