node-opcua-server
Version:
pure nodejs OPCUA SDK - module server
633 lines (546 loc) • 25.3 kB
text/typescript
/**
* @module node-opcua-server
*/
// tslint:disable:no-console
import fs from "fs";
import path from "path";
import os from "os";
import { types } from "util";
import async from "async";
import chalk from "chalk";
import { assert } from "node-opcua-assert";
import { withLock } from "@ster5/global-mutex";
import {
getDefaultCertificateManager,
ICertificateManager,
makeSubject,
OPCUACertificateManager
} from "node-opcua-certificate-manager";
import { IOPCUASecureObjectOptions, makeApplicationUrn, OPCUASecureObject } from "node-opcua-common";
import { coerceLocalizedText, LocalizedText } from "node-opcua-data-model";
import { installPeriodicClockAdjustment, uninstallPeriodicClockAdjustment } from "node-opcua-date-time";
import { checkDebugFlag, make_debugLog, make_errorLog } from "node-opcua-debug";
import { displayTraceFromThisProjectOnly } from "node-opcua-debug";
import {
extractFullyQualifiedDomainName,
getFullyQualifiedDomainName,
getHostname,
resolveFullyQualifiedDomainName
} from "node-opcua-hostname";
import { Message, Response, ServerSecureChannelLayer, ServerSecureChannelParent } from "node-opcua-secure-channel";
import { FindServersRequest, FindServersResponse } from "node-opcua-service-discovery";
import { ApplicationType, GetEndpointsResponse } from "node-opcua-service-endpoints";
import { ApplicationDescription } from "node-opcua-service-endpoints";
import { ServiceFault } from "node-opcua-service-secure-channel";
import { StatusCode, StatusCodes } from "node-opcua-status-code";
import { ApplicationDescriptionOptions } from "node-opcua-types";
import { EndpointDescription, GetEndpointsRequest } from "node-opcua-types";
import { matchUri } from "node-opcua-utils";
import { performCertificateSanityCheck } from "node-opcua-client";
import { OPCUAServerEndPoint } from "./server_end_point";
import { IChannelData } from "./i_channel_data";
import { ISocketData } from "./i_socket_data";
const doDebug = checkDebugFlag(__filename);
const debugLog = make_debugLog(__filename);
const errorLog = make_errorLog(__filename);
const warningLog = errorLog;
const default_server_info = {
// The globally unique identifier for the application instance. This URI is used as
// ServerUri in Services if the application is a Server.
applicationUri: makeApplicationUrn(os.hostname(), "NodeOPCUA-Server"),
// The globally unique identifier for the product.
productUri: "NodeOPCUA-Server",
// A localized descriptive name for the application.
applicationName: { text: "NodeOPCUA", locale: "en" },
applicationType: ApplicationType.Server,
gatewayServerUri: "",
discoveryProfileUri: "",
discoveryUrls: []
};
function cleanupEndpoint(endpoint: OPCUAServerEndPoint) {
if (endpoint._on_new_channel) {
assert(typeof endpoint._on_new_channel === "function");
endpoint.removeListener("newChannel", endpoint._on_new_channel);
endpoint._on_new_channel = undefined;
}
if (endpoint._on_close_channel) {
assert(typeof endpoint._on_close_channel === "function");
endpoint.removeListener("closeChannel", endpoint._on_close_channel);
endpoint._on_close_channel = undefined;
}
if (endpoint._on_connectionRefused) {
assert(typeof endpoint._on_connectionRefused === "function");
endpoint.removeListener("connectionRefused", endpoint._on_connectionRefused);
endpoint._on_connectionRefused = undefined;
}
if (endpoint._on_openSecureChannelFailure) {
assert(typeof endpoint._on_openSecureChannelFailure === "function");
endpoint.removeListener("openSecureChannelFailure", endpoint._on_openSecureChannelFailure);
endpoint._on_openSecureChannelFailure = undefined;
}
}
/**
*
*/
export interface OPCUABaseServerOptions extends IOPCUASecureObjectOptions {
/**
* the information used in the end point description
*/
serverInfo?: ApplicationDescriptionOptions;
/**
* the server Certificate Manager
*/
serverCertificateManager?: OPCUACertificateManager;
}
const emptyCallback = () => {
/* empty */
};
export class OPCUABaseServer extends OPCUASecureObject {
public static makeServiceFault = makeServiceFault;
/**
* The type of server
*/
get serverType(): ApplicationType {
return this.serverInfo.applicationType;
}
public serverInfo: ApplicationDescription;
public endpoints: OPCUAServerEndPoint[];
public readonly serverCertificateManager: OPCUACertificateManager;
public capabilitiesForMDNS: string[];
protected _preInitTask: any[];
protected options: OPCUABaseServerOptions;
constructor(options?: OPCUABaseServerOptions) {
options = options || ({} as OPCUABaseServerOptions);
if (!options.serverCertificateManager) {
options.serverCertificateManager = getDefaultCertificateManager("PKI");
}
options.privateKeyFile = options.privateKeyFile || options.serverCertificateManager.privateKey;
options.certificateFile =
options.certificateFile || path.join(options.serverCertificateManager.rootDir, "own/certs/certificate.pem");
super(options);
this.serverCertificateManager = options.serverCertificateManager;
this.capabilitiesForMDNS = [];
this.endpoints = [];
this.options = options;
this._preInitTask = [];
const serverInfo: ApplicationDescriptionOptions = {
...default_server_info,
...options.serverInfo
};
serverInfo.applicationName = coerceLocalizedText(serverInfo.applicationName);
this.serverInfo = new ApplicationDescription(serverInfo);
if (this.serverInfo.applicationName.toString().match(/urn:/)) {
errorLog("[NODE-OPCUA-E06] application name cannot be a urn", this.serverInfo.applicationName.toString());
}
this.serverInfo.applicationName!.locale = this.serverInfo.applicationName?.locale || "en";
if (!this.serverInfo.applicationName?.locale) {
warningLog(
"[NODE-OPCUA-W24] the server applicationName must have a valid locale : ",
this.serverInfo.applicationName.toString()
);
}
const __applicationUri = serverInfo.applicationUri || "";
(this.serverInfo as any).__defineGetter__("applicationUri", () => resolveFullyQualifiedDomainName(__applicationUri));
this._preInitTask.push(async () => {
const fqdn = await extractFullyQualifiedDomainName();
});
this._preInitTask.push(async () => {
await this.initializeCM();
});
}
protected async createDefaultCertificate(): Promise<void> {
if (fs.existsSync(this.certificateFile)) {
return;
}
// collect all hostnames
const hostnames = [];
for (const e of this.endpoints) {
for (const ee of e.endpointDescriptions()) {
/* to do */
}
}
if (!fs.existsSync(this.certificateFile)) {
await withLock({ fileToLock: this.certificateFile + ".mutex" }, async () => {
if (fs.existsSync(this.certificateFile)) {
return;
}
const applicationUri = this.serverInfo.applicationUri!;
const fqdn = getFullyQualifiedDomainName();
const hostname = getHostname();
const dns = [...new Set([fqdn, hostname])];
await this.serverCertificateManager.createSelfSignedCertificate({
applicationUri,
dns,
// ip: await getIpAddresses(),
outputFile: this.certificateFile,
subject: makeSubject(this.serverInfo.applicationName.text!, hostname),
startDate: new Date(),
validity: 365 * 10 // 10 years
});
});
}
}
public async initializeCM(): Promise<void> {
await this.serverCertificateManager.initialize();
await this.createDefaultCertificate();
debugLog("privateKey = ", this.privateKeyFile, this.serverCertificateManager.privateKey);
debugLog("certificateFile = ", this.certificateFile);
await performCertificateSanityCheck(this, "server", this.serverCertificateManager, this.serverInfo.applicationUri!);
}
/**
* start all registered endPoint, in parallel, and call done when all endPoints are listening.
*/
public start(done: (err?: Error | null) => void): void {
assert(typeof done === "function");
this.startAsync()
.then(() => done(null))
.catch((err) => done(err));
}
protected async performPreInitialization(): Promise<void> {
const tasks = this._preInitTask;
this._preInitTask = [];
for (const task of tasks) {
await task();
}
}
protected async startAsync(): Promise<void> {
await this.performPreInitialization();
assert(Array.isArray(this.endpoints));
assert(this.endpoints.length > 0, "We need at least one end point");
installPeriodicClockAdjustment();
// eslint-disable-next-line @typescript-eslint/no-this-alias
const server = this;
const _on_new_channel = function (this: OPCUAServerEndPoint, channel: ServerSecureChannelLayer) {
server.emit("newChannel", channel, this);
};
const _on_close_channel = function (this: OPCUAServerEndPoint, channel: ServerSecureChannelLayer) {
server.emit("closeChannel", channel, this);
};
const _on_connectionRefused = function (this: OPCUAServerEndPoint, socketData: ISocketData) {
server.emit("connectionRefused", socketData, this);
};
const _on_openSecureChannelFailure = function (
this: OPCUAServerEndPoint,
socketData: ISocketData,
channelData: IChannelData
) {
server.emit("openSecureChannelFailure", socketData, channelData, this);
};
const promises: Promise<void>[] = [];
for (const endpoint of this.endpoints) {
assert(!endpoint._on_close_channel);
endpoint._on_new_channel = _on_new_channel;
endpoint.on("newChannel", endpoint._on_new_channel);
endpoint._on_close_channel = _on_close_channel;
endpoint.on("closeChannel", endpoint._on_close_channel);
endpoint._on_connectionRefused = _on_connectionRefused;
endpoint.on("connectionRefused", endpoint._on_connectionRefused);
endpoint._on_openSecureChannelFailure = _on_openSecureChannelFailure;
endpoint.on("openSecureChannelFailure", endpoint._on_openSecureChannelFailure);
promises.push(new Promise<void>((resolve, reject) => endpoint.start((err) => (err ? reject(err) : resolve()))));
}
await Promise.all(promises);
}
/**
* shutdown all server endPoints
*/
public shutdown(done: (err?: Error) => void): void {
assert(typeof done === "function");
uninstallPeriodicClockAdjustment();
this.serverCertificateManager.dispose().then(() => {
debugLog("OPCUABaseServer#shutdown starting");
async.forEach(
this.endpoints,
(endpoint: OPCUAServerEndPoint, callback: (err?: Error) => void) => {
cleanupEndpoint(endpoint);
endpoint.shutdown(callback);
},
(err?: Error | null) => {
debugLog("shutdown completed");
done(err!);
}
);
});
}
public async shutdownChannels(): Promise<void>;
public shutdownChannels(callback: (err?: Error | null) => void): void;
public shutdownChannels(callback?: (err?: Error | null) => void): Promise<void> | void {
assert(typeof callback === "function");
debugLog("OPCUABaseServer#shutdownChannels");
async.forEach(
this.endpoints,
(endpoint: OPCUAServerEndPoint, inner_callback: (err?: Error | null) => void) => {
debugLog(" shutting down endpoint ", endpoint.endpointDescriptions()[0].endpointUrl);
async.series(
[
// xx (callback2: (err?: Error| null) => void) => {
// xx endpoint.suspendConnection(callback2);
// xx },
(callback2: (err?: Error | null) => void) => {
endpoint.abruptlyInterruptChannels();
endpoint.shutdown(callback2);
}
// xx (callback2: (err?: Error| null) => void) => {
// xx endpoint.restoreConnection(callback2);
// xx }
],
inner_callback
);
},
callback!
);
}
/**
* @private
*/
public on_request(message: Message, channel: ServerSecureChannelLayer): void {
assert(message.request);
assert(message.requestId !== 0);
const request = message.request;
// install channel._on_response so we can intercept its call and emit the "response" event.
if (!channel._on_response) {
channel._on_response = (msg: string, response1: Response /*, inner_message: Message*/) => {
this.emit("response", response1, channel);
};
}
// prepare request
this.prepare(message, channel);
if (doDebug) {
debugLog(
chalk.green.bold("--------------------------------------------------------"),
channel.channelId,
request.schema.name
);
}
let errMessage: string;
let response: Response;
this.emit("request", request, channel);
try {
// handler must be named _on_ActionRequest()
const handler = (this as any)["_on_" + request.schema.name];
if (typeof handler === "function") {
// eslint-disable-next-line prefer-rest-params
handler.apply(this, arguments);
} else {
errMessage = "[NODE-OPCUA-W07] Unsupported Service : " + request.schema.name;
warningLog(errMessage);
debugLog(chalk.red.bold(errMessage));
response = makeServiceFault(StatusCodes.BadServiceUnsupported, [errMessage]);
channel.send_response("MSG", response, message, emptyCallback);
}
} catch (err) {
/* istanbul ignore if */
const errMessage1 = "[NODE-OPCUA-W08] EXCEPTION CAUGHT WHILE PROCESSING REQUEST !! " + request.schema.name;
warningLog(chalk.red.bold(errMessage1));
warningLog(request.toString());
displayTraceFromThisProjectOnly(err as Error);
let additional_messages = [];
additional_messages.push("EXCEPTION CAUGHT WHILE PROCESSING REQUEST !!! " + request.schema.name);
if (types.isNativeError(err)) {
additional_messages.push(err.message);
if (err.stack) {
additional_messages = additional_messages.concat(err.stack.split("\n"));
}
}
response = makeServiceFault(StatusCodes.BadInternalError, additional_messages);
channel.send_response("MSG", response, message, emptyCallback);
}
}
/**
* @private
*/
public _get_endpoints(endpointUrl?: string | null): EndpointDescription[] {
let endpoints: EndpointDescription[] = [];
for (const endPoint of this.endpoints) {
const ep = endPoint.endpointDescriptions();
const epFiltered = endpointUrl ? ep.filter((e) => matchUri(e.endpointUrl, endpointUrl)) : ep;
endpoints = endpoints.concat(epFiltered);
}
return endpoints;
}
/**
* get one of the possible endpointUrl
*/
public getEndpointUrl(): string {
return this._get_endpoints()[0].endpointUrl!;
}
public getDiscoveryUrls(): string[] {
const discoveryUrls = this.endpoints.map((e: OPCUAServerEndPoint) => {
return e.endpointDescriptions()[0].endpointUrl!;
});
return discoveryUrls;
}
public getServers(channel: ServerSecureChannelLayer): ApplicationDescription[] {
this.serverInfo.discoveryUrls = this.getDiscoveryUrls();
const servers = [this.serverInfo];
return servers;
}
/**
* set all the end point into a state where they do not accept further connections
*
* note:
* this method is useful for testing purpose
*
*/
public async suspendEndPoints(): Promise<void>;
public suspendEndPoints(callback: (err?: Error) => void): void;
public suspendEndPoints(callback?: (err?: Error) => void): void | Promise<void> {
/* istanbul ignore next */
if (!callback) {
throw new Error("Internal Error");
}
async.forEach(
this.endpoints,
(ep: OPCUAServerEndPoint, _inner_callback) => {
/* istanbul ignore next */
if (doDebug) {
debugLog("Suspending ", ep.endpointDescriptions()[0].endpointUrl);
}
ep.suspendConnection((err?: Error | null) => {
/* istanbul ignore next */
if (doDebug) {
debugLog("Suspended ", ep.endpointDescriptions()[0].endpointUrl);
}
_inner_callback(err);
});
},
(err?: Error | null) => callback(err!)
);
}
/**
* set all the end point into a state where they do accept connections
* note:
* this method is useful for testing purpose
*/
public async resumeEndPoints(): Promise<void>;
public resumeEndPoints(callback: (err?: Error) => void): void;
public resumeEndPoints(callback?: (err?: Error) => void): void | Promise<void> {
async.forEach(
this.endpoints,
(ep: OPCUAServerEndPoint, _inner_callback) => {
ep.restoreConnection(_inner_callback);
},
(err?: Error | null) => callback!(err!)
);
}
protected prepare(message: Message, channel: ServerSecureChannelLayer): void {
/* empty */
}
/**
* @private
*/
protected _on_GetEndpointsRequest(message: Message, channel: ServerSecureChannelLayer): void {
const request = message.request as GetEndpointsRequest;
assert(request.schema.name === "GetEndpointsRequest");
const response = new GetEndpointsResponse({});
/**
* endpointUrl String The network address that the Client used to access the DiscoveryEndpoint.
* The Server uses this information for diagnostics and to determine what URLs to return in the response.
* The Server should return a suitable default URL if it does not recognize the HostName in the URL
* localeIds []LocaleId List of locales to use.
* Specifies the locale to use when returning human readable strings.
* profileUris [] String List of Transport Profile that the returned Endpoints shall support.
* OPC 10000-7 defines URIs for the Transport Profiles.
* All Endpoints are returned if the list is empty.
* If the URI is a URL, this URL may have a query string appended.
* The Transport Profiles that support query strings are defined in OPC 10000-7.
*/
response.endpoints = this._get_endpoints(null);
const e = response.endpoints.map((e) => e.endpointUrl);
if (request.endpointUrl) {
const filtered = response.endpoints.filter(
(endpoint: EndpointDescription) => endpoint.endpointUrl === request.endpointUrl
);
if (filtered.length > 0) {
response.endpoints = filtered;
}
}
response.endpoints = response.endpoints.filter((endpoint: EndpointDescription) => !(endpoint as any).restricted);
// apply filters
if (request.profileUris && request.profileUris.length > 0) {
response.endpoints = response.endpoints.filter((endpoint: any) => {
return request.profileUris!.indexOf(endpoint.transportProfileUri) >= 0;
});
}
// adjust locale on ApplicationName to match requested local or provide
// a string with neutral locale (locale === null)
// TODO: find a better way to handle this
response.endpoints.forEach((endpoint: EndpointDescription) => {
endpoint.server.applicationName.locale = "en-US";
});
channel.send_response("MSG", response, message, emptyCallback);
}
/**
* @private
*/
protected _on_FindServersRequest(message: Message, channel: ServerSecureChannelLayer): void {
// Release 1.02 13 OPC Unified Architecture, Part 4 :
// This Service can be used without security and it is therefore vulnerable to Denial Of Service (DOS)
// attacks. A Server should minimize the amount of processing required to send the response for this
// Service. This can be achieved by preparing the result in advance. The Server should also add a
// short delay before starting processing of a request during high traffic conditions.
const shortDelay = 100; // milliseconds
setTimeout(() => {
const request = message.request;
assert(request.schema.name === "FindServersRequest");
if (!(request instanceof FindServersRequest)) {
throw new Error("Invalid request type");
}
let servers = this.getServers(channel);
// apply filters
// TODO /
if (request.serverUris && request.serverUris.length > 0) {
// A serverUri matches the applicationUri from the ApplicationDescription define
servers = servers.filter((inner_Server: ApplicationDescription) => {
return request.serverUris!.indexOf(inner_Server.applicationUri) >= 0;
});
}
function adapt(applicationDescription: ApplicationDescription): ApplicationDescription {
return new ApplicationDescription({
applicationName: applicationDescription.applicationName,
applicationType: applicationDescription.applicationType,
applicationUri: applicationDescription.applicationUri,
discoveryProfileUri: applicationDescription.discoveryProfileUri,
discoveryUrls: applicationDescription.discoveryUrls,
gatewayServerUri: applicationDescription.gatewayServerUri,
productUri: applicationDescription.productUri
});
}
const response = new FindServersResponse({
servers: servers.map(adapt)
});
channel.send_response("MSG", response, message, emptyCallback);
}, shortDelay);
}
/**
* returns a array of currently active channels
*/
protected getChannels(): ServerSecureChannelLayer[] {
let channels: ServerSecureChannelLayer[] = [];
for (const endpoint of this.endpoints) {
const c = endpoint.getChannels();
channels = channels.concat(c);
}
return channels;
}
}
/**
* construct a service Fault response
*/
function makeServiceFault(statusCode: StatusCode, messages: string[]): ServiceFault {
const response = new ServiceFault();
response.responseHeader.serviceResult = statusCode;
// xx response.serviceDiagnostics.push( new DiagnosticInfo({ additionalInfo: messages.join("\n")}));
assert(Array.isArray(messages));
assert(typeof messages[0] === "string");
response.responseHeader.stringTable = messages;
// tslint:disable:no-console
warningLog(chalk.cyan(" messages "), messages.join("\n"));
return response;
}
// tslint:disable:no-var-requires
import { withCallback } from "thenify-ex";
const opts = { multiArgs: false };
OPCUABaseServer.prototype.resumeEndPoints = withCallback(OPCUABaseServer.prototype.resumeEndPoints, opts);
OPCUABaseServer.prototype.suspendEndPoints = withCallback(OPCUABaseServer.prototype.suspendEndPoints, opts);
OPCUABaseServer.prototype.shutdownChannels = withCallback(OPCUABaseServer.prototype.shutdownChannels, opts);