diff --git a/00_Base/src/index.ts b/00_Base/src/index.ts index ef4f7158..9a5188e1 100644 --- a/00_Base/src/index.ts +++ b/00_Base/src/index.ts @@ -247,3 +247,4 @@ export { assert, notNull, deepDirectionalEqual } from './assertion/assertion'; export { UnauthorizedError } from './interfaces/api/exception/UnauthorizedError'; export { AuthorizationSecurity } from './interfaces/api/AuthorizationSecurity'; export { Ajv }; +export declare type Constructable = new (...args: any[]) => T; diff --git a/00_Base/src/interfaces/api/AbstractModuleApi.ts b/00_Base/src/interfaces/api/AbstractModuleApi.ts index 42fbcc3a..5bcd12c8 100644 --- a/00_Base/src/interfaces/api/AbstractModuleApi.ts +++ b/00_Base/src/interfaces/api/AbstractModuleApi.ts @@ -13,13 +13,12 @@ import { METADATA_DATA_ENDPOINTS, METADATA_MESSAGE_ENDPOINTS, } from '.'; -import { OcppRequest, OcppResponse, SystemConfig } from '../..'; +import { OcppRequest, SystemConfig } from '../..'; import { Namespace } from '../../ocpp/persistence'; import { CallAction } from '../../ocpp/rpc/message'; import { IMessageConfirmation } from '../messages'; import { IModule } from '../modules'; import { - IMessageQuerystring, IMessageQuerystringSchema, } from './MessageQuerystring'; import { IModuleApi } from './ModuleApi'; @@ -29,8 +28,7 @@ import { AuthorizationSecurity } from './AuthorizationSecurity'; * Abstract module api class implementation. */ export abstract class AbstractModuleApi - implements IModuleApi -{ + implements IModuleApi { protected readonly _server: FastifyInstance; protected readonly _module: T; protected readonly _logger: Logger; @@ -62,6 +60,7 @@ export abstract class AbstractModuleApi expose.action, expose.method, expose.bodySchema, + expose.optionalQuerystrings, ); }); ( @@ -111,12 +110,14 @@ export abstract class AbstractModuleApi * @param {CallAction} action - The action to be called. * @param {Function} method - The method to be executed. * @param {object} bodySchema - The schema for the route. + * @param {Record} optionalQuerystrings - Optional querystrings for the route. * @return {void} */ protected _addMessageRoute( action: CallAction, method: (...args: any[]) => any, bodySchema: object, + optionalQuerystrings?: Record ): void { this._logger.debug( `Adding message route for ${action}`, @@ -126,28 +127,39 @@ export abstract class AbstractModuleApi /** * Executes the handler function for the given request. * - * @param {FastifyRequest<{ Body: OcppRequest | OcppResponse, Querystring: IMessageQuerystring }>} request - The request object containing the body and querystring. + * @param {FastifyRequest<{ Body: OcppRequest, Querystring: IMessageQuerystring }>} request - The request object containing the body and querystring. * @return {Promise} The promise that resolves to the message confirmation. */ const _handler = async ( request: FastifyRequest<{ - Body: OcppRequest | OcppResponse; - Querystring: IMessageQuerystring; + Body: OcppRequest; + Querystring: Record; }>, - ): Promise => - method.call( + ): Promise => { + const { identifier, tenantId, callbackUrl, ...extraQueries } = request.query; + return method.call( this, - request.query.identifier, - request.query.tenantId, + identifier, + tenantId, request.body, - request.query.callbackUrl, + callbackUrl, + Object.keys(extraQueries).length > 0 ? extraQueries : undefined, ); + } + + const mergedQuerySchema = { + ...IMessageQuerystringSchema, + properties: { + ...IMessageQuerystringSchema.properties, + ...(optionalQuerystrings || {}), + }, + }; const _opts = { schema: { body: bodySchema, - querystring: IMessageQuerystringSchema, - }, + querystring: mergedQuerySchema, + } as const, }; if (this._module.config.util.swagger?.exposeMessage) { diff --git a/00_Base/src/interfaces/api/AsMessageEndpoint.ts b/00_Base/src/interfaces/api/AsMessageEndpoint.ts index 45ef1b13..03bf23a1 100644 --- a/00_Base/src/interfaces/api/AsMessageEndpoint.ts +++ b/00_Base/src/interfaces/api/AsMessageEndpoint.ts @@ -16,6 +16,7 @@ import { CallAction } from '../../ocpp/rpc/message'; export const AsMessageEndpoint = function ( action: CallAction, bodySchema: object, + optionalQuerystrings?: Record ) { return ( target: any, @@ -38,6 +39,7 @@ export const AsMessageEndpoint = function ( method: descriptor.value, methodName: propertyKey, bodySchema: bodySchema, + optionalQuerystrings: optionalQuerystrings }); Reflect.defineMetadata( METADATA_MESSAGE_ENDPOINTS, diff --git a/00_Base/src/interfaces/api/MessageEndpointDefinition.ts b/00_Base/src/interfaces/api/MessageEndpointDefinition.ts index 196d5c04..3ece143f 100644 --- a/00_Base/src/interfaces/api/MessageEndpointDefinition.ts +++ b/00_Base/src/interfaces/api/MessageEndpointDefinition.ts @@ -15,4 +15,5 @@ export interface IMessageEndpointDefinition { method: (...args: any[]) => any; methodName: string; bodySchema: object; + optionalQuerystrings?: Record; } diff --git a/00_Base/src/interfaces/repository.ts b/00_Base/src/interfaces/repository.ts index c7daa439..7dc2edbc 100644 --- a/00_Base/src/interfaces/repository.ts +++ b/00_Base/src/interfaces/repository.ts @@ -63,6 +63,15 @@ export abstract class CrudRepository extends EventEmitter { return result; } + public async bulkCreate( + values: T[], + clazz: any, // todo: should Model be in base so it can be used here? + ): Promise { + const result = await this._bulkCreate(values, clazz); + this.emit('created', result); + return result; + } + /** * Creates a new entry in the database with the specified value and key. * If a namespace is provided, the entry will be created within that namespace. @@ -250,6 +259,7 @@ export abstract class CrudRepository extends EventEmitter { abstract existByQuery(query: object, namespace?: string): Promise; protected abstract _create(value: T, namespace?: string): Promise; + protected abstract _bulkCreate(value: T[], namespace?: string): Promise; protected abstract _createByKey( value: T, diff --git a/00_Base/src/ocpp/persistence/index.ts b/00_Base/src/ocpp/persistence/index.ts index 1e2c087f..ea354ff2 100644 --- a/00_Base/src/ocpp/persistence/index.ts +++ b/00_Base/src/ocpp/persistence/index.ts @@ -11,6 +11,12 @@ export { default as ReportDataTypeSchema } from './schemas/ReportDataTypeSchema. export { default as SetVariableResultTypeSchema } from './schemas/SetVariableResultTypeSchema.json'; export { default as UpdateChargingStationPasswordSchema } from './schemas/UpdateChargingStationPasswordRequestSchema.json'; +/** + * Utility function for creating querystring schemas for fastify route definitions + * @param properties An array of key-type pairs. Types ending in '[]' will be treated as arrays of that type. + * @param required An array of required keys. + * @returns + */ export function QuerySchema( name: string, properties: [string, string][], @@ -22,9 +28,20 @@ export function QuerySchema( properties: {}, }; properties.forEach((property: [string, string]) => { - (schema['properties'] as Record)[property[0]] = { - type: property[1], - }; + const [key, type] = property; + + // '[]' denotes an array + if (type.endsWith('[]')) { + (schema['properties'] as Record)[key] = { + type: 'array', + items: { type: type.slice(0, -2) }, // Remove '[]' to get the base type + }; + } else { + // non-array types + (schema['properties'] as Record)[key] = { + type, + }; + } }); if (required) { schema['required'] = required; diff --git a/00_Base/src/ocpp/persistence/namespace.ts b/00_Base/src/ocpp/persistence/namespace.ts index d9d6ad71..8b1d8abf 100644 --- a/00_Base/src/ocpp/persistence/namespace.ts +++ b/00_Base/src/ocpp/persistence/namespace.ts @@ -12,6 +12,7 @@ export enum Namespace { AuthorizationRestrictions = 'AuthorizationRestrictions', BootConfig = 'Boot', Certificate = 'Certificate', + InstalledCertificate = 'InstalledCertificate', CertificateChain = 'CertificateChain', ChargingNeeds = 'ChargingNeeds', ChargingProfile = 'ChargingProfile', @@ -24,18 +25,20 @@ export enum Namespace { EventDataType = 'EventData', IdTokenInfoType = 'IdTokenInfo', IdTokenType = 'IdToken', + LatestStatusNotification = 'LatestStatusNotification', LocalListAuthorization = 'LocalListAuthorization', LocalListVersion = 'LocalListVersion', Location = 'Location', MeterValueType = 'MeterValue', MessageInfoType = 'MessageInfo', + PasswordType = 'Password', ReserveNowRequest = 'Reservation', RootCertificate = 'RootCertificate', SalesTariff = 'SalesTariff', SecurityEventNotificationRequest = 'SecurityEvent', SendLocalListRequest = 'SendLocalList', + ServerNetworkProfile = 'ServerNetworkProfile', StatusNotificationRequest = 'StatusNotification', - LatestStatusNotification = 'LatestStatusNotification', Subscription = 'Subscription', SystemConfig = 'SystemConfig', TlsCertificates = 'TlsCertificates', @@ -47,6 +50,5 @@ export enum Namespace { VariableMonitoringType = 'VariableMonitoring', VariableMonitoringStatus = 'VariableMonitoringStatus', VariableStatus = 'VariableStatus', - VariableType = 'Variable', - PasswordType = 'Password', + VariableType = 'Variable' } diff --git a/01_Data/src/index.ts b/01_Data/src/index.ts index c2a64fd5..9ae68da4 100644 --- a/01_Data/src/index.ts +++ b/01_Data/src/index.ts @@ -4,6 +4,7 @@ // SPDX-License-Identifier: Apache 2.0 import { Transaction as SequelizeTransaction } from 'sequelize'; + export { SequelizeTransaction }; export { IdTokenAdditionalInfo } from './layers/sequelize/model/Authorization/IdTokenAdditionalInfo'; export * as sequelize from './layers/sequelize'; @@ -31,6 +32,7 @@ export { VariableCharacteristics, VariableStatus, Certificate, + InstalledCertificate, CountryNameEnumType, TransactionEvent, IdToken, @@ -39,13 +41,17 @@ export { LocalListAuthorization, LocalListVersion, SendLocalList, + ServerNetworkProfile, + SetNetworkProfile, StatusNotification, ChargingStationSecurityInfo, + ChargingStationNetworkProfile, SignatureAlgorithmEnumType, SequelizeAuthorizationRepository, SequelizeBootRepository, SequelizeCallMessageRepository, SequelizeCertificateRepository, + SequelizeInstalledCertificateRepository, SequelizeChargingProfileRepository, SequelizeChargingStationSecurityInfoRepository, SequelizeDeviceModelRepository, diff --git a/01_Data/src/interfaces/index.ts b/01_Data/src/interfaces/index.ts index f8c9a607..b94db7d3 100644 --- a/01_Data/src/interfaces/index.ts +++ b/01_Data/src/interfaces/index.ts @@ -13,6 +13,7 @@ export { AuthorizationQuerystring, AuthorizationQuerySchema } from './queries/Au export { TransactionEventQuerystring, TransactionEventQuerySchema } from './queries/TransactionEvent'; export { TariffQueryString, TariffQuerySchema } from './queries/Tariff'; export { ModelKeyQuerystring, ModelKeyQuerystringSchema } from './queries/Model'; +export { NetworkProfileQuerystring, NetworkProfileQuerySchema, NetworkProfileDeleteQuerystring, NetworkProfileDeleteQuerySchema } from './queries/NetworkProfile'; export { UpdateTlsCertificateQueryString, TlsCertificateSchema, UpdateTlsCertificateQuerySchema } from './queries/TlsCertificate'; export { GenerateCertificateChainSchema, InstallRootCertificateSchema } from './queries/RootCertificate'; export { CreateSubscriptionSchema } from './queries/Subscription'; diff --git a/01_Data/src/interfaces/queries/NetworkProfile.ts b/01_Data/src/interfaces/queries/NetworkProfile.ts new file mode 100644 index 00000000..0be32baf --- /dev/null +++ b/01_Data/src/interfaces/queries/NetworkProfile.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2023 S44, LLC +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +import { QuerySchema } from '@citrineos/base'; + +export interface NetworkProfileQuerystring { + stationId: string; +} + +export const NetworkProfileQuerySchema = QuerySchema([['stationId', 'string']], ['stationId']); + +export interface NetworkProfileDeleteQuerystring { + stationId: string; + configurationSlot: number[]; +} + +export const NetworkProfileDeleteQuerySchema = QuerySchema( + [ + ['stationId', 'string'], + ['configurationSlot', 'number[]'] + ], + ['stationId', 'configurationSlot'] +); diff --git a/01_Data/src/interfaces/repositories.ts b/01_Data/src/interfaces/repositories.ts index f42f06ab..8c33ccdb 100644 --- a/01_Data/src/interfaces/repositories.ts +++ b/01_Data/src/interfaces/repositories.ts @@ -4,54 +4,75 @@ // SPDX-License-Identifier: Apache 2.0 import { - MessageInfoType, type AuthorizationData, type BootConfig, type CallAction, + ChargingLimitSourceEnumType, + ChargingProfilePurposeEnumType, + ChargingProfileType, type ChargingStateEnumType, + ChargingStationSequenceType, type ComponentType, + CompositeScheduleType, + type CrudRepository, type EventDataType, type EVSEType, type GetVariableResultType, - type CrudRepository, type IdTokenType, + MessageInfoType, + MeterValueType, type MonitoringDataType, + NotifyEVChargingNeedsRequest, type RegistrationStatusEnumType, type ReportDataType, + ReserveNowRequest, type SecurityEventNotificationRequest, type SetMonitoringDataType, type SetMonitoringResultType, type SetVariableDataType, type SetVariableResultType, type StatusInfoType, + StatusNotificationRequest, type TransactionEventRequest, + UpdateEnumType, type VariableAttributeType, type VariableMonitoringType, type VariableType, - ChargingProfileType, - ChargingProfilePurposeEnumType, - NotifyEVChargingNeedsRequest, - ChargingLimitSourceEnumType, - CompositeScheduleType, - StatusNotificationRequest, - ReserveNowRequest, - MeterValueType, - UpdateEnumType, - ChargingStationSequenceType, } from '@citrineos/base'; import { type AuthorizationQuerystring } from './queries/Authorization'; -import { CallMessage, ChargingStationSecurityInfo, ChargingStationSequence, CompositeSchedule, MeterValue, type Transaction, VariableCharacteristics } from '../layers/sequelize'; -import { type VariableAttribute } from '../layers/sequelize'; +import { + type Authorization, + type Boot, + CallMessage, + type Certificate, + ChargingNeeds, + ChargingProfile, + type ChargingStation, + ChargingStationSecurityInfo, + ChargingStationSequence, + type Component, + CompositeSchedule, + type EventData, + Evse, + type Location, + MessageInfo, + MeterValue, + Reservation, + type SecurityEvent, + ServerNetworkProfile, + Subscription, + Tariff, + type Transaction, + type Variable, + type VariableAttribute, + VariableCharacteristics, + type VariableMonitoring, +} from '../layers/sequelize'; import { type AuthorizationRestrictions, type VariableAttributeQuerystring } from '.'; -import { type Authorization, type Boot, type Certificate, ChargingNeeds, type ChargingStation, type Component, type EventData, Evse, type Location, type SecurityEvent, type Variable, type VariableMonitoring } from '../layers/sequelize'; -import { MessageInfo } from '../layers/sequelize'; -import { Subscription } from '../layers/sequelize'; -import { Tariff } from '../layers/sequelize'; import { TariffQueryString } from './queries/Tariff'; -import { ChargingProfile } from '../layers/sequelize'; -import { Reservation } from '../layers/sequelize'; import { LocalListVersion } from '../layers/sequelize/model/Authorization/LocalListVersion'; import { SendLocalList } from '../layers/sequelize/model/Authorization/SendLocalList'; +import { InstalledCertificate } from '../layers/sequelize/model/Certificate/InstalledCertificate'; export interface IAuthorizationRepository extends CrudRepository { createOrUpdateByQuerystring: (value: AuthorizationData, query: AuthorizationQuerystring) => Promise; @@ -171,6 +192,8 @@ export interface ICertificateRepository extends CrudRepository { createOrUpdateCertificate(certificate: Certificate): Promise; } +export interface IInstalledCertificateRepository extends CrudRepository { } + export interface IChargingProfileRepository extends CrudRepository { createOrUpdateChargingProfile(chargingProfile: ChargingProfileType, stationId: string, evseId?: number | null, chargingLimitSource?: ChargingLimitSourceEnumType, isActive?: boolean): Promise; createChargingNeeds(chargingNeeds: NotifyEVChargingNeedsRequest, stationId: string): Promise; @@ -185,7 +208,7 @@ export interface IReservationRepository extends CrudRepository { createOrUpdateReservation(reserveNowRequest: ReserveNowRequest, stationId: string, isActive?: boolean): Promise; } -export interface ICallMessageRepository extends CrudRepository {} +export interface ICallMessageRepository extends CrudRepository { } export interface IChargingStationSecurityInfoRepository extends CrudRepository { readChargingStationPublicKeyFileId(stationId: string): Promise; @@ -195,3 +218,7 @@ export interface IChargingStationSecurityInfoRepository extends CrudRepository { getNextSequenceValue(stationId: string, type: ChargingStationSequenceType): Promise; } + +export interface IServerNetworkProfileRepository extends CrudRepository { + +} diff --git a/01_Data/src/layers/sequelize/index.ts b/01_Data/src/layers/sequelize/index.ts index a8292ecd..cd78b679 100644 --- a/01_Data/src/layers/sequelize/index.ts +++ b/01_Data/src/layers/sequelize/index.ts @@ -9,12 +9,12 @@ export { Authorization, IdToken, IdTokenInfo, AdditionalInfo, LocalListAuthoriza export { Transaction, TransactionEvent, MeterValue } from './model/TransactionEvent'; export { SecurityEvent } from './model/SecurityEvent'; export { VariableMonitoring, EventData, VariableMonitoringStatus } from './model/VariableMonitoring'; -export { ChargingStation, Location, StatusNotification } from './model/Location'; +export { ChargingStation, ChargingStationNetworkProfile, Location, ServerNetworkProfile, SetNetworkProfile, StatusNotification } from './model/Location'; export { ChargingStationSequence } from './model/ChargingStationSequence'; export { MessageInfo } from './model/MessageInfo'; export { Tariff } from './model/Tariff'; export { Subscription } from './model/Subscription'; -export { Certificate, SignatureAlgorithmEnumType, CountryNameEnumType } from './model/Certificate'; +export { Certificate, SignatureAlgorithmEnumType, CountryNameEnumType, InstalledCertificate } from './model/Certificate'; export { ChargingProfile, ChargingNeeds, ChargingSchedule, CompositeSchedule, SalesTariff } from './model/ChargingProfile'; export { CallMessage } from './model/CallMessage'; export { Reservation } from './model/Reservation'; @@ -34,6 +34,7 @@ export { SequelizeMessageInfoRepository } from './repository/MessageInfo'; export { SequelizeTariffRepository } from './repository/Tariff'; export { SequelizeSubscriptionRepository } from './repository/Subscription'; export { SequelizeCertificateRepository } from './repository/Certificate'; +export { SequelizeInstalledCertificateRepository } from './repository/InstalledCertificate'; export { SequelizeChargingProfileRepository } from './repository/ChargingProfile'; export { SequelizeCallMessageRepository } from './repository/CallMessage'; export { SequelizeReservationRepository } from './repository/Reservation'; diff --git a/01_Data/src/layers/sequelize/model/Certificate/InstalledCertificate.ts b/01_Data/src/layers/sequelize/model/Certificate/InstalledCertificate.ts new file mode 100644 index 00000000..ea2cefab --- /dev/null +++ b/01_Data/src/layers/sequelize/model/Certificate/InstalledCertificate.ts @@ -0,0 +1,45 @@ +import { CertificateHashDataType, GetCertificateIdUseEnumType, HashAlgorithmEnumType, Namespace } from '@citrineos/base'; +import { Column, DataType, ForeignKey, Model, Table } from 'sequelize-typescript'; +import { ChargingStation } from '../Location'; + +@Table +export class InstalledCertificate extends Model implements CertificateHashDataType { + static readonly MODEL_NAME: string = Namespace.InstalledCertificate; + + @ForeignKey(() => ChargingStation) + @Column({ + type: DataType.STRING(36), + allowNull: false, + }) + declare stationId: string; + + @Column({ + type: DataType.STRING, + allowNull: false, + }) + declare hashAlgorithm: HashAlgorithmEnumType; + + @Column({ + type: DataType.STRING, + allowNull: false, + }) + declare issuerNameHash: string; + + @Column({ + type: DataType.STRING, + allowNull: false, + }) + declare issuerKeyHash: string; + + @Column({ + type: DataType.STRING, + allowNull: false, + }) + declare serialNumber: string; + + @Column({ + type: DataType.ENUM('V2GRootCertificate', 'MORootCertificate', 'CSMSRootCertificate', 'V2GCertificateChain', 'ManufacturerRootCertificate'), + allowNull: false, + }) + declare certificateType: GetCertificateIdUseEnumType; +} diff --git a/01_Data/src/layers/sequelize/model/Certificate/index.ts b/01_Data/src/layers/sequelize/model/Certificate/index.ts index 9a6c4c78..ff4d9ced 100644 --- a/01_Data/src/layers/sequelize/model/Certificate/index.ts +++ b/01_Data/src/layers/sequelize/model/Certificate/index.ts @@ -3,6 +3,7 @@ // SPDX-License-Identifier: Apache 2.0 export { Certificate } from './Certificate'; +export { InstalledCertificate } from './InstalledCertificate'; export const enum SignatureAlgorithmEnumType { RSA = 'SHA256withRSA', diff --git a/01_Data/src/layers/sequelize/model/Location/ChargingStation.ts b/01_Data/src/layers/sequelize/model/Location/ChargingStation.ts index 22106072..f5796edf 100644 --- a/01_Data/src/layers/sequelize/model/Location/ChargingStation.ts +++ b/01_Data/src/layers/sequelize/model/Location/ChargingStation.ts @@ -3,9 +3,12 @@ // SPDX-License-Identifier: Apache 2.0 import { Namespace, StatusNotificationRequest } from '@citrineos/base'; -import { BelongsTo, Column, DataType, ForeignKey, HasMany, Model, PrimaryKey, Table } from 'sequelize-typescript'; +import { BelongsTo, BelongsToMany, Column, DataType, ForeignKey, HasMany, Model, PrimaryKey, Table } from 'sequelize-typescript'; import { Location } from './Location'; import { StatusNotification } from './StatusNotification'; +import { ServerNetworkProfile } from './ServerNetworkProfile'; +import { ChargingStationNetworkProfile } from './ChargingStationNetworkProfile'; +import { SetNetworkProfile } from './SetNetworkProfile'; /** * Represents a charging station. @@ -34,4 +37,7 @@ export class ChargingStation extends Model { */ @BelongsTo(() => Location) declare location?: Location; + + @BelongsToMany(() => SetNetworkProfile, () => ChargingStationNetworkProfile) + declare networkProfiles?: SetNetworkProfile[] | null; } diff --git a/01_Data/src/layers/sequelize/model/Location/ChargingStationNetworkProfile.ts b/01_Data/src/layers/sequelize/model/Location/ChargingStationNetworkProfile.ts new file mode 100644 index 00000000..991a989b --- /dev/null +++ b/01_Data/src/layers/sequelize/model/Location/ChargingStationNetworkProfile.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2023 S44, LLC +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +import { BelongsTo, Column, DataType, ForeignKey, Model, Table } from 'sequelize-typescript'; +import { ChargingStation } from './ChargingStation' +import { ServerNetworkProfile } from './ServerNetworkProfile' +import { SetNetworkProfile } from './SetNetworkProfile' + +@Table +export class ChargingStationNetworkProfile extends Model { + // Namespace enum not used as this is not a model required by CitrineOS + static readonly MODEL_NAME: string = 'ChargingStationNetworkProfile'; + + @ForeignKey(() => ChargingStation) + @Column({ + type: DataType.STRING, + unique: 'stationId_configurationSlot' + }) + declare stationId: string; + + /** + * Possible values for a particular station found in device model: + * OCPPCommCtrlr.NetworkConfigurationPriority.VariableCharacteristics.valuesList + */ + @Column({ + type: DataType.INTEGER, + unique: 'stationId_configurationSlot' + }) + declare configurationSlot: number; + + @ForeignKey(() => SetNetworkProfile) + @Column(DataType.INTEGER) + declare setNetworkProfileId: number; + + @BelongsTo(() => SetNetworkProfile) + declare setNetworkProfile: SetNetworkProfile + + /** + * If present, the websocket server that correlates to this configuration slot. + * The ws url in the network profile may not match the configured host, for example in the cloud the + * configured host will likely be behind a load balancer and a custom DNS name. + * + */ + @ForeignKey(() => ServerNetworkProfile) + @Column(DataType.STRING) + declare websocketServerConfigId?: string; + + @BelongsTo(() => ServerNetworkProfile) + declare websocketServerConfig?: ServerNetworkProfile +} diff --git a/01_Data/src/layers/sequelize/model/Location/ServerNetworkProfile.ts b/01_Data/src/layers/sequelize/model/Location/ServerNetworkProfile.ts new file mode 100644 index 00000000..9c5b0634 --- /dev/null +++ b/01_Data/src/layers/sequelize/model/Location/ServerNetworkProfile.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2023 S44, LLC +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +import { Namespace, WebsocketServerConfig } from '@citrineos/base'; +import { BelongsToMany, Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript'; +import { ChargingStation } from './ChargingStation'; +import { ChargingStationNetworkProfile } from './ChargingStationNetworkProfile'; + +@Table +export class ServerNetworkProfile extends Model implements WebsocketServerConfig { + static readonly MODEL_NAME: string = Namespace.ServerNetworkProfile; + + @PrimaryKey + @Column(DataType.STRING) + declare id: string; + + @Column(DataType.STRING) + declare host: string; + + @Column(DataType.INTEGER) + declare port: number; + + @Column(DataType.INTEGER) + declare pingInterval: number; + + @Column(DataType.STRING) + declare protocol: string; + + @Column(DataType.INTEGER) + declare messageTimeout: number; + + @Column(DataType.INTEGER) + declare securityProfile: number; + + @Column(DataType.BOOLEAN) + declare allowUnknownChargingStations: boolean; + + @Column(DataType.STRING) + declare tlsKeyFilePath?: string; + + @Column(DataType.STRING) + declare tlsCertificateChainFilePath?: string; + + @Column(DataType.STRING) + declare mtlsCertificateAuthorityKeyFilePath?: string; + + @Column(DataType.STRING) + declare rootCACertificateFilePath?: string; + + @BelongsToMany(() => ChargingStation, () => ChargingStationNetworkProfile) + declare chargingStations?: ChargingStation[] | null; +} diff --git a/01_Data/src/layers/sequelize/model/Location/SetNetworkProfile.ts b/01_Data/src/layers/sequelize/model/Location/SetNetworkProfile.ts new file mode 100644 index 00000000..26e84047 --- /dev/null +++ b/01_Data/src/layers/sequelize/model/Location/SetNetworkProfile.ts @@ -0,0 +1,87 @@ +// Copyright (c) 2023 S44, LLC +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +import { OCPPInterfaceEnumType, OCPPTransportEnumType, OCPPVersionEnumType, APNType, VPNType, } from '@citrineos/base'; +import { BelongsTo, Column, DataType, ForeignKey, Index, Model, Table } from 'sequelize-typescript'; +import { ChargingStation } from './ChargingStation'; +import { ServerNetworkProfile } from './ServerNetworkProfile'; + +/** + * The CallMessage model can be extended with new optional fields, + * e.g. chargingProfileId, for other correlationId related lookups. + */ +@Table +export class SetNetworkProfile extends Model { + static readonly MODEL_NAME: string = 'SetNetworkProfile'; + + @ForeignKey(() => ChargingStation) + @Column(DataType.STRING) + declare stationId: string; + + @Index + @Column({ + type: DataType.STRING, + unique: true, + }) + declare correlationId: string; + + @ForeignKey(() => ServerNetworkProfile) + @Column(DataType.STRING) + declare websocketServerConfigId?: string; + + @BelongsTo(() => ServerNetworkProfile) + declare websocketServerConfig?: ServerNetworkProfile; + + @Column(DataType.INTEGER) + declare configurationSlot: number; + + @Column(DataType.STRING) + declare ocppVersion: OCPPVersionEnumType; + + @Column(DataType.STRING) + declare ocppTransport: OCPPTransportEnumType; + + /** + * Communication_ Function. OCPP_ Central_ System_ URL. URI + * urn:x-oca:ocpp:uid:1:569357 + * URL of the CSMS(s) that this Charging Station communicates with. + * + */ + @Column(DataType.STRING) + declare ocppCsmsUrl: string; + + /** + * Duration in seconds before a message send by the Charging Station via this network connection times-out. + * The best setting depends on the underlying network and response times of the CSMS. + * If you are looking for a some guideline: use 30 seconds as a starting point. + * + */ + @Column(DataType.INTEGER) + declare messageTimeout: number; + + /** + * This field specifies the security profile used when connecting to the CSMS with this NetworkConnectionProfile. + * + */ + @Column(DataType.INTEGER) + declare securityProfile: number; + + @Column(DataType.STRING) + declare ocppInterface: OCPPInterfaceEnumType; + + /** + * Stringified JSON of {@link APNType} for display purposes only + * + */ + @Column(DataType.STRING) + declare apn?: string; + + /** + * Stringified JSON of {@link VPNType} for display purposes only + * + */ + @Column(DataType.STRING) + declare vpn?: string; +} diff --git a/01_Data/src/layers/sequelize/model/Location/index.ts b/01_Data/src/layers/sequelize/model/Location/index.ts index 4cb9c6cb..2c823ba9 100644 --- a/01_Data/src/layers/sequelize/model/Location/index.ts +++ b/01_Data/src/layers/sequelize/model/Location/index.ts @@ -4,4 +4,7 @@ export { Location } from './Location'; export { ChargingStation } from './ChargingStation'; +export { ChargingStationNetworkProfile } from './ChargingStationNetworkProfile'; export { StatusNotification } from './StatusNotification'; +export { ServerNetworkProfile } from './ServerNetworkProfile' +export { SetNetworkProfile } from './SetNetworkProfile' \ No newline at end of file diff --git a/01_Data/src/layers/sequelize/repository/Base.ts b/01_Data/src/layers/sequelize/repository/Base.ts index 8e0ad280..0a777232 100644 --- a/01_Data/src/layers/sequelize/repository/Base.ts +++ b/01_Data/src/layers/sequelize/repository/Base.ts @@ -62,6 +62,10 @@ export class SequelizeRepository> extends CrudReposito return await value.save(); } + protected async _bulkCreate(values: T[]): Promise { + return await (this.s.models[this.namespace] as ModelStatic).bulkCreate(values as any); + } + protected async _createByKey(value: T, key: string): Promise { const primaryKey = this.s.models[this.namespace].primaryKeyAttribute; value.setDataValue(primaryKey, key); diff --git a/01_Data/src/layers/sequelize/repository/DeviceModel.ts b/01_Data/src/layers/sequelize/repository/DeviceModel.ts index 4f1817a7..ef8f68f8 100644 --- a/01_Data/src/layers/sequelize/repository/DeviceModel.ts +++ b/01_Data/src/layers/sequelize/repository/DeviceModel.ts @@ -330,7 +330,7 @@ export class SequelizeDeviceModelRepository extends SequelizeRepository implements IInstalledCertificateRepository { + constructor(config: SystemConfig, logger?: Logger, sequelizeInstance?: Sequelize) { + super(config, InstalledCertificate.MODEL_NAME, logger, sequelizeInstance); + } +} diff --git a/01_Data/src/layers/sequelize/repository/ServerNetworkProfile.ts b/01_Data/src/layers/sequelize/repository/ServerNetworkProfile.ts new file mode 100644 index 00000000..c768799c --- /dev/null +++ b/01_Data/src/layers/sequelize/repository/ServerNetworkProfile.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2023 S44, LLC +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +import { SystemConfig } from '@citrineos/base'; +import { SequelizeRepository } from './Base'; +import { Sequelize } from 'sequelize-typescript'; +import { ILogObj, Logger } from 'tslog'; +import { ServerNetworkProfile } from '../model/Location'; +import { IServerNetworkProfileRepository } from '../../../interfaces'; + +export class SequelizeServerNetworkProfileRepository extends SequelizeRepository implements IServerNetworkProfileRepository { + constructor(config: SystemConfig, logger?: Logger, sequelizeInstance?: Sequelize) { + super(config, ServerNetworkProfile.MODEL_NAME, logger, sequelizeInstance); + } +} diff --git a/01_Data/src/layers/sequelize/util.ts b/01_Data/src/layers/sequelize/util.ts index ce8f3c29..e9febf69 100644 --- a/01_Data/src/layers/sequelize/util.ts +++ b/01_Data/src/layers/sequelize/util.ts @@ -18,6 +18,7 @@ import { ChargingProfile, ChargingSchedule, ChargingStation, + ChargingStationNetworkProfile, ChargingStationSecurityInfo, ChargingStationSequence, Component, @@ -26,11 +27,13 @@ import { Evse, IdToken, IdTokenInfo, + InstalledCertificate, Location, MeterValue, Reservation, SalesTariff, SecurityEvent, + ServerNetworkProfile, Transaction, TransactionEvent, Variable, @@ -44,7 +47,7 @@ import { MessageInfo } from './model/MessageInfo'; import { Subscription } from './model/Subscription'; import { Tariff } from './model/Tariff'; import { IdTokenAdditionalInfo } from './model/Authorization/IdTokenAdditionalInfo'; -import { StatusNotification } from './model/Location'; +import { SetNetworkProfile, StatusNotification } from './model/Location'; import { LatestStatusNotification } from './model/Location/LatestStatusNotification'; export class DefaultSequelizeInstance { @@ -77,7 +80,7 @@ export class DefaultSequelizeInstance { try { await this.instance!.authenticate(); this.logger.info('Database connection has been established successfully'); - this.syncDb(); + await this.syncDb(); break; } catch (error) { @@ -118,10 +121,12 @@ export class DefaultSequelizeInstance { Boot, CallMessage, Certificate, + InstalledCertificate, ChargingNeeds, ChargingProfile, ChargingSchedule, ChargingStation, + ChargingStationNetworkProfile, ChargingStationSecurityInfo, ChargingStationSequence, Component, @@ -138,6 +143,8 @@ export class DefaultSequelizeInstance { Reservation, SalesTariff, SecurityEvent, + SetNetworkProfile, + ServerNetworkProfile, StatusNotification, LatestStatusNotification, Subscription, diff --git a/02_Util/src/index.ts b/02_Util/src/index.ts index 213d5f9c..e1acd002 100644 --- a/02_Util/src/index.ts +++ b/02_Util/src/index.ts @@ -5,6 +5,7 @@ export { UnknownStationFilter } from './networkconnection/authenticator/UnknownStationFilter'; export { ConnectedStationFilter } from './networkconnection/authenticator/ConnectedStationFilter'; +export { NetworkProfileFilter } from './networkconnection/authenticator/NetworkProfileFilter'; export { BasicAuthenticationFilter } from './networkconnection/authenticator/BasicAuthenticationFilter'; export { IAuthorizer } from './authorization'; diff --git a/02_Util/src/networkconnection/WebsocketNetworkConnection.ts b/02_Util/src/networkconnection/WebsocketNetworkConnection.ts index af1da022..b12eed99 100644 --- a/02_Util/src/networkconnection/WebsocketNetworkConnection.ts +++ b/02_Util/src/networkconnection/WebsocketNetworkConnection.ts @@ -525,6 +525,8 @@ export class WebsocketNetworkConnection { if (config.securityProfile > 2) { serverOptions.requestCert = true; serverOptions.rejectUnauthorized = true; + } else { + serverOptions.rejectUnauthorized = false; } return serverOptions; diff --git a/02_Util/src/networkconnection/authenticator/Authenticator.ts b/02_Util/src/networkconnection/authenticator/Authenticator.ts index cfa0fdaf..559d5217 100644 --- a/02_Util/src/networkconnection/authenticator/Authenticator.ts +++ b/02_Util/src/networkconnection/authenticator/Authenticator.ts @@ -8,21 +8,25 @@ import { IncomingMessage } from 'http'; import { UnknownStationFilter } from './UnknownStationFilter'; import { BasicAuthenticationFilter } from './BasicAuthenticationFilter'; import { ConnectedStationFilter } from './ConnectedStationFilter'; +import { NetworkProfileFilter } from './NetworkProfileFilter'; export class Authenticator implements IAuthenticator { protected _logger: Logger; private _unknownStationFilter: UnknownStationFilter; private _connectedStationFilter: ConnectedStationFilter; + private _networkProfileFilter: NetworkProfileFilter; private _basicAuthenticationFilter: BasicAuthenticationFilter; constructor( unknownStationFilter: UnknownStationFilter, connectedStationFilter: ConnectedStationFilter, + networkProfileFilter: NetworkProfileFilter, basicAuthenticationFilter: BasicAuthenticationFilter, logger?: Logger, ) { this._unknownStationFilter = unknownStationFilter; this._connectedStationFilter = connectedStationFilter; + this._networkProfileFilter = networkProfileFilter; this._basicAuthenticationFilter = basicAuthenticationFilter; this._logger = logger ? logger.getSubLogger({ name: this.constructor.name }) @@ -42,6 +46,7 @@ export class Authenticator implements IAuthenticator { request, options, ); + await this._networkProfileFilter.authenticate(identifier, request, options); await this._basicAuthenticationFilter.authenticate( identifier, request, diff --git a/02_Util/src/networkconnection/authenticator/AuthenticatorFilter.ts b/02_Util/src/networkconnection/authenticator/AuthenticatorFilter.ts index 85d886fd..69297952 100644 --- a/02_Util/src/networkconnection/authenticator/AuthenticatorFilter.ts +++ b/02_Util/src/networkconnection/authenticator/AuthenticatorFilter.ts @@ -15,6 +15,7 @@ export abstract class AuthenticatorFilter { protected abstract filter( identifier: string, request: IncomingMessage, + options?: AuthenticationOptions ): Promise; async authenticate( @@ -25,7 +26,7 @@ export abstract class AuthenticatorFilter { if (this.shouldFilter(options)) { this._logger.debug(`Applying filter for: ${identifier}`); try { - await this.filter(identifier, request); + await this.filter(identifier, request, options); this._logger.debug(`Filter passed for: ${identifier}`); } catch (error) { this._logger.warn(`Filter failed for: ${identifier}`); diff --git a/02_Util/src/networkconnection/authenticator/NetworkProfileFilter.ts b/02_Util/src/networkconnection/authenticator/NetworkProfileFilter.ts new file mode 100644 index 00000000..e5b85900 --- /dev/null +++ b/02_Util/src/networkconnection/authenticator/NetworkProfileFilter.ts @@ -0,0 +1,91 @@ + +import { AttributeEnumType } from '@citrineos/base'; +import { ILogObj, Logger } from 'tslog'; +import { ChargingStationNetworkProfile, IDeviceModelRepository, ServerNetworkProfile } from '@citrineos/data'; +import { IncomingMessage } from 'http'; +import { AuthenticatorFilter } from './AuthenticatorFilter'; +import { AuthenticationOptions } from '@citrineos/base'; + + +/** + * Filter used to block connections when charging stations attempt to connect to disallowed security profiles + */ +export class NetworkProfileFilter extends AuthenticatorFilter { + private _deviceModelRepository: IDeviceModelRepository; + + constructor( + deviceModelRepository: IDeviceModelRepository, + logger?: Logger, + ) { + super(logger); + this._deviceModelRepository = deviceModelRepository; + } + + protected shouldFilter(options: AuthenticationOptions): boolean { + return true; + } + + protected async filter( + identifier: string, + request: IncomingMessage, + options: AuthenticationOptions + ): Promise { + const isConfigurationSlotAllowed = await this._isConfigurationSlotAllowed(identifier, options.securityProfile); + if (!isConfigurationSlotAllowed) { + throw Error(`SecurityProfile not allowed ${options.securityProfile}`); + } + } + + private async _isConfigurationSlotAllowed(identifier: string, securityProfile: number) { + const r = await this._deviceModelRepository + .readAllByQuerystring({ + stationId: identifier, + component_name: 'OCPPCommCtrlr', + variable_name: 'NetworkConfigurationPriority', + type: AttributeEnumType.Actual, + }) + if (r && r[0]) { + const configurationSlotsString = r[0].value; + if (configurationSlotsString && configurationSlotsString.trim() !== '') { + // Split the string by commas to get an array of string numbers + const configurationSlotStringsArray = configurationSlotsString.split(','); + // Parse the array into numbers and filter out the rest + const configurationSlotsArray = configurationSlotStringsArray.map(configurationSlotString => parseInt(configurationSlotString, 10)) + .filter(configurationSlotId => { + if (isNaN(configurationSlotId)) { + this._logger.error("NetworkConfigurationPriority elements must be integers: " + configurationSlotsString); + return false; + } else { + return true; + } + }); + + if (configurationSlotsArray.length == 0) { + this._logger.debug("No valid configuration slots to check: " + configurationSlotsString); + return true; + } else { + let securityProfileAllowed = false; + for (const configurationSlot of configurationSlotsArray) { + const chargingStationNetworkProfile = await ChargingStationNetworkProfile.findOne({ where: { stationId: identifier, configurationSlot: configurationSlot } }); + if (chargingStationNetworkProfile) { + const serverNetworkProfile = await ServerNetworkProfile.findByPk(chargingStationNetworkProfile.websocketServerConfigId); + if (serverNetworkProfile && securityProfile >= serverNetworkProfile.securityProfile) { + this._logger.debug("Security profile allowed"); + securityProfileAllowed = true; + } + } else { + this._logger.warn("Unknown configuration slot; skipping security profile network profile check."); + securityProfileAllowed = true; + } + } + if (!securityProfileAllowed) { + this._logger.warn(`Station ${identifier} unable to connect with security profile ${securityProfile}`); + } + return securityProfileAllowed; + } + } + } + this._logger.warn("Has no configuration slots configured"); + return true; + } +} diff --git a/02_Util/test/networkconnection/authenticator/Authenticator.test.ts b/02_Util/test/networkconnection/authenticator/Authenticator.test.ts index ef2dfdc5..00572b55 100644 --- a/02_Util/test/networkconnection/authenticator/Authenticator.test.ts +++ b/02_Util/test/networkconnection/authenticator/Authenticator.test.ts @@ -2,6 +2,7 @@ import { jest } from '@jest/globals'; import { Authenticator } from '../../../src'; import { faker } from '@faker-js/faker'; import { ConnectedStationFilter } from '../../../src/networkconnection/authenticator/ConnectedStationFilter'; +import { NetworkProfileFilter } from '../../../src/networkconnection/authenticator/NetworkProfileFilter'; import { BasicAuthenticationFilter } from '../../../src/networkconnection/authenticator/BasicAuthenticationFilter'; import { UnknownStationFilter } from '../../../src/networkconnection/authenticator/UnknownStationFilter'; import { aRequest } from '../../providers/IncomingMessageProvider'; @@ -10,6 +11,7 @@ import { anAuthenticationOptions } from '../../providers/AuthenticationOptionsPr describe('Authenticator', () => { let unknownStationFilter: jest.Mocked; let connectedStationFilter: jest.Mocked; + let networkProfileFilter: jest.Mocked; let basicAuthenticationFilter: jest.Mocked; let authenticator: Authenticator; @@ -22,6 +24,10 @@ describe('Authenticator', () => { authenticate: jest.fn(), } as unknown as jest.Mocked; + networkProfileFilter = { + authenticate: jest.fn(), + } as unknown as jest.Mocked; + basicAuthenticationFilter = { authenticate: jest.fn(), } as unknown as jest.Mocked; @@ -29,6 +35,7 @@ describe('Authenticator', () => { authenticator = new Authenticator( unknownStationFilter, connectedStationFilter, + networkProfileFilter, basicAuthenticationFilter, ); }); @@ -36,6 +43,7 @@ describe('Authenticator', () => { afterEach(() => { unknownStationFilter.authenticate.mockReset(); connectedStationFilter.authenticate.mockReset(); + networkProfileFilter.authenticate.mockReset(); basicAuthenticationFilter.authenticate.mockReset(); }); @@ -53,6 +61,7 @@ describe('Authenticator', () => { ).rejects.toThrow(); expect(connectedStationFilter.authenticate).not.toHaveBeenCalled(); + expect(networkProfileFilter.authenticate).not.toHaveBeenCalled(); expect(basicAuthenticationFilter.authenticate).not.toHaveBeenCalled(); }); @@ -70,6 +79,25 @@ describe('Authenticator', () => { ), ).rejects.toThrow(); + expect(networkProfileFilter.authenticate).not.toHaveBeenCalled(); + expect(basicAuthenticationFilter.authenticate).not.toHaveBeenCalled(); + }); + + it('should reject when network profile filter rejects', async () => { + const stationId = faker.string.uuid().toString(); + unknownStationFilter.authenticate.mockResolvedValue(undefined); + connectedStationFilter.authenticate.mockResolvedValue(undefined); + networkProfileFilter.authenticate.mockRejectedValue( + new Error('Unauthorized'), + ); + + await expect( + authenticator.authenticate( + aRequest({ url: `wss://citrineos.io/${stationId}` }), + anAuthenticationOptions(), + ), + ).rejects.toThrow(); + expect(basicAuthenticationFilter.authenticate).not.toHaveBeenCalled(); }); @@ -77,6 +105,7 @@ describe('Authenticator', () => { const stationId = faker.string.uuid().toString(); unknownStationFilter.authenticate.mockResolvedValue(undefined); connectedStationFilter.authenticate.mockResolvedValue(undefined); + networkProfileFilter.authenticate.mockResolvedValue(undefined); basicAuthenticationFilter.authenticate.mockRejectedValue( new Error('Unauthorized'), ); @@ -93,6 +122,7 @@ describe('Authenticator', () => { const stationId = faker.string.uuid().toString(); unknownStationFilter.authenticate.mockResolvedValue(undefined); connectedStationFilter.authenticate.mockResolvedValue(undefined); + networkProfileFilter.authenticate.mockResolvedValue(undefined); basicAuthenticationFilter.authenticate.mockResolvedValue(undefined); const identifier = await authenticator.authenticate( diff --git a/03_Modules/Certificates/src/module/api.ts b/03_Modules/Certificates/src/module/api.ts index e52f6faf..a8639b8c 100644 --- a/03_Modules/Certificates/src/module/api.ts +++ b/03_Modules/Certificates/src/module/api.ts @@ -22,6 +22,7 @@ import { Namespace, WebsocketServerConfig, } from '@citrineos/base'; +import jsrsasign from 'jsrsasign'; import { FastifyInstance, FastifyRequest } from 'fastify'; import { ILogObj, Logger } from 'tslog'; import { ICertificatesModuleApi } from './interface'; @@ -46,7 +47,6 @@ import { } from '@citrineos/data'; import fs from 'fs'; import moment from 'moment'; -import jsrsasign from 'jsrsasign'; const enum PemType { Root = 'Root', diff --git a/03_Modules/Certificates/src/module/module.ts b/03_Modules/Certificates/src/module/module.ts index c846032d..50580b0c 100644 --- a/03_Modules/Certificates/src/module/module.ts +++ b/03_Modules/Certificates/src/module/module.ts @@ -8,6 +8,7 @@ import { AsHandler, AttributeEnumType, CallAction, + CertificateHashDataChainType, CertificateSignedRequest, CertificateSignedResponse, CertificateSigningUseEnumType, @@ -28,14 +29,18 @@ import { IMessageSender, InstallCertificateResponse, Iso15118EVCertificateStatusEnumType, + Namespace, SignCertificateRequest, SignCertificateResponse, SystemConfig, } from '@citrineos/base'; +import { Op } from 'sequelize'; import { ICertificateRepository, IDeviceModelRepository, + IInstalledCertificateRepository, ILocationRepository, + InstalledCertificate, sequelize, } from '@citrineos/data'; import { @@ -81,6 +86,7 @@ export class CertificatesModule extends AbstractModule { protected _deviceModelRepository: IDeviceModelRepository; protected _certificateRepository: ICertificateRepository; + protected _installedCertificateRepository: IInstalledCertificateRepository; protected _locationRepository: ILocationRepository; protected _certificateAuthorityService: CertificateAuthorityService; @@ -153,6 +159,8 @@ export class CertificatesModule extends AbstractModule { this._certificateRepository = certificateRepository || new sequelize.SequelizeCertificateRepository(config, logger); + this._installedCertificateRepository = + new sequelize.SequelizeInstalledCertificateRepository(config, logger); this._locationRepository = locationRepository || new sequelize.SequelizeLocationRepository(config, logger); @@ -312,26 +320,63 @@ export class CertificatesModule extends AbstractModule { } @AsHandler(CallAction.DeleteCertificate) - protected _handleDeleteCertificate( + protected async _handleDeleteCertificate( message: IMessage, props?: HandlerProperties, - ): void { + ): Promise { this._logger.debug('DeleteCertificate received:', message, props); } @AsHandler(CallAction.GetInstalledCertificateIds) - protected _handleGetInstalledCertificateIds( + protected async _handleGetInstalledCertificateIds( message: IMessage, props?: HandlerProperties, - ): void { + ): Promise { this._logger.debug('GetInstalledCertificateIds received:', message, props); + const certificateHashDataList: CertificateHashDataChainType[] = + message.payload.certificateHashDataChain!; + // persist installed certificate information + if (certificateHashDataList && certificateHashDataList.length > 0) { + // delete previous hashes for station + await this.deleteExistingMatchingCertificateHashes( + message.context.stationId, + certificateHashDataList, + ); + // save new hashes + const records = certificateHashDataList.map( + (certificateHashDataWrap: CertificateHashDataChainType) => { + const certificateHashData = + certificateHashDataWrap.certificateHashData; + const certificateType = certificateHashDataWrap.certificateType; + return { + stationId: message.context.stationId, + hashAlgorithm: certificateHashData.hashAlgorithm, + issuerNameHash: certificateHashData.issuerNameHash, + issuerKeyHash: certificateHashData.issuerKeyHash, + serialNumber: certificateHashData.serialNumber, + certificateType: certificateType, + } as InstalledCertificate; + }, + ); + this._logger.info('Attempting to save', records); + const response = await this._installedCertificateRepository.bulkCreate( + records, + Namespace.InstalledCertificate, + ); + if (response.length === records.length) { + this._logger.info( + 'Successfully updated installed certificate information for station', + message.context.stationId, + ); + } + } } @AsHandler(CallAction.InstallCertificate) - protected _handleInstallCertificate( + protected async _handleInstallCertificate( message: IMessage, props?: HandlerProperties, - ): void { + ): Promise { this._logger.debug('InstallCertificate received:', message, props); } @@ -396,4 +441,32 @@ export class CertificatesModule extends AbstractModule { `Verified SignCertRequest for station ${stationId} successfully.`, ); } + + private async deleteExistingMatchingCertificateHashes( + stationId: string, + certificateHashDataList: CertificateHashDataChainType[], + ) { + try { + const certificateTypes = certificateHashDataList.map( + (certificateHashData) => { + return certificateHashData.certificateType; + }, + ); + if (certificateTypes && certificateTypes.length > 0) { + await this._installedCertificateRepository.deleteAllByQuery({ + where: { + stationId, + certificateType: { + [Op.in]: certificateTypes, + }, + }, + }); + } + } catch (error: any) { + this._logger.error( + 'GetInstalledCertificateIds failed to delete previous certificates', + error, + ); + } + } } diff --git a/03_Modules/Configuration/src/module/api.ts b/03_Modules/Configuration/src/module/api.ts index 6ed56bf7..ad827b13 100644 --- a/03_Modules/Configuration/src/module/api.ts +++ b/03_Modules/Configuration/src/module/api.ts @@ -51,14 +51,23 @@ import { } from '@citrineos/base'; import { Boot, + CallMessage, ChargingStationKeyQuerySchema, ChargingStationKeyQuerystring, + ChargingStationNetworkProfile, Component, + NetworkProfileDeleteQuerySchema, + NetworkProfileDeleteQuerystring, + NetworkProfileQuerySchema, + NetworkProfileQuerystring, + ServerNetworkProfile, + SetNetworkProfile, UpdateChargingStationPasswordQuerySchema, UpdateChargingStationPasswordQueryString, Variable, VariableAttribute, } from '@citrineos/data'; +import { Op } from 'sequelize'; import { generatePassword, isValidPassword, @@ -66,7 +75,11 @@ import { } from '@citrineos/util'; import { v4 as uuidv4 } from 'uuid'; -/** +enum SetNetworkProfileExtraQuerystrings { + websocketServerConfigId = 'websocketServerConfigId' +} + +/**websocketServerConfigId * Server API for the Configuration component. */ export class ConfigurationModuleApi @@ -88,6 +101,7 @@ export class ConfigurationModuleApi super(ConfigurationComponent, server, logger); } + /** * Message Endpoint Methods */ @@ -95,19 +109,35 @@ export class ConfigurationModuleApi @AsMessageEndpoint( CallAction.SetNetworkProfile, SetNetworkProfileRequestSchema, + { websocketServerConfigId: { type: 'string' } } ) - setNetworkProfile( + async setNetworkProfile( identifier: string, tenantId: string, request: SetNetworkProfileRequest, callbackUrl?: string, + extraQueries?: Record ): Promise { + const correlationId = uuidv4(); + if (extraQueries) { + const websocketServerConfigId = extraQueries[SetNetworkProfileExtraQuerystrings.websocketServerConfigId]; + await SetNetworkProfile.build({ + stationId: identifier, + correlationId, + configurationSlot: request.configurationSlot, + websocketServerConfigId, + apn: JSON.stringify(request.connectionData.apn), + vpn: JSON.stringify(request.connectionData.vpn), + ...request.connectionData + }).save(); + } return this._module.sendCall( identifier, tenantId, CallAction.SetNetworkProfile, request, callbackUrl, + correlationId, ); } @@ -386,6 +416,36 @@ export class ConfigurationModuleApi }; } + @AsDataEndpoint( + Namespace.ServerNetworkProfile, + HttpMethod.Get, + NetworkProfileQuerySchema, + ) + async getNetworkProfiles( + request: FastifyRequest<{ Querystring: NetworkProfileQuerystring }>, + ): Promise { + return ChargingStationNetworkProfile.findAll({ where: { stationId: request.query.stationId }, include: [SetNetworkProfile, ServerNetworkProfile] }); + } + + @AsDataEndpoint( + Namespace.ServerNetworkProfile, + HttpMethod.Delete, + NetworkProfileDeleteQuerySchema, + ) + async deleteNetworkProfiles( + request: FastifyRequest<{ Querystring: NetworkProfileDeleteQuerystring }>, + ): Promise { + const destroyedRows = await ChargingStationNetworkProfile.destroy({ + where: { + stationId: request.query.stationId, + configurationSlot: { + [Op.in]: request.query.configurationSlot + } + } + }); + return { success: true, payload: `${destroyedRows} rows successfully destroyed` }; + } + /** * Overrides superclass method to generate the URL path based on the input {@link CallAction} and the module's endpoint prefix configuration. * diff --git a/03_Modules/Configuration/src/module/module.ts b/03_Modules/Configuration/src/module/module.ts index 16c43a55..e0fa112d 100644 --- a/03_Modules/Configuration/src/module/module.ts +++ b/03_Modules/Configuration/src/module/module.ts @@ -42,6 +42,7 @@ import { ResetResponse, SetDisplayMessageResponse, SetNetworkProfileResponse, + SetNetworkProfileStatusEnumType, SetVariableDataType, SetVariablesRequest, SetVariablesResponse, @@ -53,11 +54,15 @@ import { } from '@citrineos/base'; import { Boot, + ChargingStation, + ChargingStationNetworkProfile, Component, IBootRepository, IDeviceModelRepository, IMessageInfoRepository, sequelize, + ServerNetworkProfile, + SetNetworkProfile, } from '@citrineos/data'; import { IdGenerator, @@ -254,7 +259,7 @@ export class ConfigurationModule extends AbstractModule { if (!bootNotificationResponseMessageConfirmation.success) { throw new Error( 'BootNotification failed: ' + - bootNotificationResponseMessageConfirmation, + bootNotificationResponseMessageConfirmation, ); } @@ -529,11 +534,27 @@ export class ConfigurationModule extends AbstractModule { } @AsHandler(CallAction.SetNetworkProfile) - protected _handleSetNetworkProfile( + protected async _handleSetNetworkProfile( message: IMessage, props?: HandlerProperties, - ): void { + ): Promise { this._logger.debug('SetNetworkProfile response received:', message, props); + + if (message.payload.status == SetNetworkProfileStatusEnumType.Accepted) { + const setNetworkProfile = await SetNetworkProfile.findOne({ where: { correlationId: message.context.correlationId } }); + if (setNetworkProfile) { + const serverNetworkProfile = await ServerNetworkProfile.findByPk(setNetworkProfile.websocketServerConfigId!); + if (serverNetworkProfile) { + const chargingStation = await ChargingStation.findByPk(message.context.stationId); + if (chargingStation) { + const [chargingStationNetworkProfile] = await ChargingStationNetworkProfile.findOrBuild({ where: { stationId: chargingStation.id, configurationSlot: setNetworkProfile.configurationSlot! } }); + chargingStationNetworkProfile.websocketServerConfigId = setNetworkProfile.websocketServerConfigId!; + chargingStationNetworkProfile.setNetworkProfileId = setNetworkProfile.id; + await chargingStationNetworkProfile.save(); + } + } + } + } } @AsHandler(CallAction.GetDisplayMessages) diff --git a/Server/src/index.ts b/Server/src/index.ts index 18ad8910..cf79de7b 100644 --- a/Server/src/index.ts +++ b/Server/src/index.ts @@ -33,6 +33,7 @@ import { RedisCache, UnknownStationFilter, WebsocketNetworkConnection, + NetworkProfileFilter, } from '@citrineos/util'; import { type JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts'; import addFormats from 'ajv-formats'; @@ -57,7 +58,7 @@ import { SmartChargingModule, SmartChargingModuleApi, } from '@citrineos/smartcharging'; -import { RepositoryStore, sequelize, Sequelize } from '@citrineos/data'; +import { RepositoryStore, sequelize, Sequelize, ServerNetworkProfile } from '@citrineos/data'; import { type FastifyRouteSchemaDef, type FastifySchemaCompiler, @@ -221,6 +222,7 @@ export class CitrineOSServer { async run(): Promise { try { await this.initialize(); + this._syncWebsocketConfig(); await this._server .listen({ host: this.host, @@ -239,6 +241,29 @@ export class CitrineOSServer { } } + protected async _syncWebsocketConfig() { + this._config.util.networkConnection.websocketServers.forEach(async websocketServerConfig => { + const [serverNetworkProfile] = await ServerNetworkProfile.findOrBuild({ + where: { + id: websocketServerConfig.id + } + }); + serverNetworkProfile.host = websocketServerConfig.host; + serverNetworkProfile.port = websocketServerConfig.port; + serverNetworkProfile.pingInterval = websocketServerConfig.pingInterval; + serverNetworkProfile.protocol = websocketServerConfig.protocol; + serverNetworkProfile.messageTimeout = this._config.maxCallLengthSeconds; + serverNetworkProfile.securityProfile = websocketServerConfig.securityProfile; + serverNetworkProfile.allowUnknownChargingStations = websocketServerConfig.allowUnknownChargingStations; + serverNetworkProfile.tlsKeyFilePath = websocketServerConfig.tlsKeyFilePath; + serverNetworkProfile.tlsCertificateChainFilePath = websocketServerConfig.tlsCertificateChainFilePath; + serverNetworkProfile.mtlsCertificateAuthorityKeyFilePath = websocketServerConfig.mtlsCertificateAuthorityKeyFilePath; + serverNetworkProfile.rootCACertificateFilePath = websocketServerConfig.rootCACertificateFilePath; + + serverNetworkProfile.save(); + }) + } + protected _createSender(): IMessageSender { return new RabbitMqSender(this._config, this._logger); } @@ -319,6 +344,13 @@ export class CitrineOSServer { this._logger, ), new ConnectedStationFilter(this._cache, this._logger), + new NetworkProfileFilter( + new sequelize.SequelizeDeviceModelRepository( + this._config, + this._logger, + ), + this._logger, + ), new BasicAuthenticationFilter( new sequelize.SequelizeDeviceModelRepository( this._config,