wechaty-puppet-service
Version:
Puppet Service for Wechaty
353 lines • 14.9 kB
JavaScript
/**
* Wechaty Open Source Software - https://github.com/wechaty
*
* @copyright 2016 Huan LI (李卓桓) <https://github.com/huan>, and
* Wechaty Contributors <https://github.com/wechaty>.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
import util from 'util';
import EventEmitter from 'events';
import crypto from 'crypto';
import { log } from 'wechaty-puppet';
import { grpc, puppet, } from 'wechaty-grpc';
import { timeoutPromise, } from 'gerror';
import { WechatyToken, WechatyResolver, } from 'wechaty-token';
import { GRPC_OPTIONS, envVars, } from '../config.js';
import { callCredToken } from '../auth/mod.js';
import { GrpcStatus } from '../auth/grpc-js.js';
import { TLS_CA_CERT, TLS_INSECURE_SERVER_CERT_COMMON_NAME, } from '../auth/ca.js';
/**
* Huan(202108): register `wechaty` schema for gRPC service discovery
* so that we can use `wechaty:///SNI_UUIDv4` for gRPC address
*
* See: https://github.com/wechaty/wechaty-puppet-service/issues/155
*/
WechatyResolver.setup();
class GrpcManager extends EventEmitter {
options;
_client;
get client() {
if (!this._client) {
throw new Error('NO CLIENT');
}
return this._client;
}
eventStream;
/**
* gRPC settings
*/
caCert;
disableTls;
endpoint;
serverName;
token;
constructor(options) {
super();
this.options = options;
log.verbose('GrpcManager', 'constructor(%s)', JSON.stringify(options));
this.caCert = Buffer.from(envVars.WECHATY_PUPPET_SERVICE_TLS_CA_CERT(this.options.tls?.caCert) || TLS_CA_CERT);
log.verbose('GrpcManager', 'constructor() tlsRootCert(hash): "%s"', crypto.createHash('sha256')
.update(this.caCert)
.digest('hex'));
/**
* Token will be used in the gRPC resolver (in endpoint)
*/
this.token = new WechatyToken(envVars.WECHATY_PUPPET_SERVICE_TOKEN(this.options.token));
log.verbose('GrpcManager', 'constructor() token: "%s"', this.token);
this.endpoint = envVars.WECHATY_PUPPET_SERVICE_ENDPOINT(this.options.endpoint)
/**
* Wechaty Token Discovery-able URL
* See: wechaty-token / https://github.com/wechaty/wechaty-puppet-service/issues/155
*/
|| [
'wechaty://',
envVars.WECHATY_PUPPET_SERVICE_AUTHORITY(this.options.authority),
'/',
this.token,
].join('');
log.verbose('GrpcManager', 'constructor() endpoint: "%s"', this.endpoint);
/**
* Disable TLS
*/
this.disableTls = envVars.WECHATY_PUPPET_SERVICE_NO_TLS_INSECURE_CLIENT(this.options.tls?.disable);
log.verbose('GrpcManager', 'constructor() disableTls: "%s"', this.disableTls);
/**
* for Node.js TLS SNI
* https://en.wikipedia.org/wiki/Server_Name_Indication
*/
const serverNameIndication = envVars.WECHATY_PUPPET_SERVICE_TLS_SERVER_NAME(this.options.tls?.serverName)
/**
* Huan(202108): we use SNI from token
* if there is
* neither override from environment variable: `WECHATY_PUPPET_SERVICE_TLS_SERVER_NAME`
* nor set from: `options.tls.serverName`
*/
|| this.token.sni;
if (!serverNameIndication) {
throw new Error([
'Wechaty Puppet Service requires a SNI as prefix of the token from version 0.30 and later.',
`You can add the "${TLS_INSECURE_SERVER_CERT_COMMON_NAME}_" prefix to your token`,
`like: "${TLS_INSECURE_SERVER_CERT_COMMON_NAME}_${this.token}"`,
'and try again.',
].join('\n'));
}
this.serverName = serverNameIndication;
log.verbose('GrpcManager', 'constructor() serverName(SNI): "%s"', this.serverName);
}
async start() {
log.verbose('GrpcManager', 'start()');
/**
* 1. Init grpc client
*/
log.verbose('GrpcManager', 'start() initializing client ...');
await this.initClient();
log.verbose('GrpcManager', 'start() initializing client ... done');
/**
* 2. Connect to stream
*/
log.verbose('GrpcManager', 'start() starting stream ...');
await this.startStream();
log.verbose('GrpcManager', 'start() starting stream ... done');
/**
* 3. Start the puppet
*/
log.verbose('GrpcManager', 'start() calling grpc server: start() ...');
await util.promisify(this.client.start
.bind(this.client))(new puppet.StartRequest());
log.verbose('GrpcManager', 'start() calling grpc server: start() ... done');
log.verbose('GrpcManager', 'start() ... done');
}
async stop() {
log.verbose('GrpcManager', 'stop()');
/**
* 1. Disconnect from stream
*/
log.verbose('GrpcManager', 'stop() stop stream ...');
this.stopStream();
log.verbose('GrpcManager', 'stop() stop stream ... done');
/**
* 2. Stop the puppet
*/
try {
log.verbose('GrpcManager', 'stop() stop client ...');
await util.promisify(this.client.stop
.bind(this.client))(new puppet.StopRequest());
log.verbose('GrpcManager', 'stop() stop client ... done');
}
catch (e) {
this.emit('error', e);
}
/**
* 3. Destroy grpc client
*/
try {
log.verbose('GrpcManager', 'stop() destroy client ...');
this.destroyClient();
log.verbose('GrpcManager', 'stop() destroy client ... done');
}
catch (e) {
this.emit('error', e);
}
log.verbose('GrpcManager', 'stop() ... done');
}
async initClient() {
log.verbose('GrpcManager', 'initClient()');
/**
* Huan(202108): for maximum compatible with the non-tls community servers/clients,
* we introduced the WECHATY_PUPPET_SERVICE_NO_TLS_INSECURE_{SERVER,CLIENT} environment variables.
* if it has been set, then we will run under HTTP instead of HTTPS
*/
let credential;
if (this.disableTls) {
log.warn('GrpcManager', 'initClient() TLS: disabled (INSECURE)');
credential = grpc.credentials.createInsecure();
}
else {
log.verbose('GrpcManager', 'initClient() TLS: enabled');
const callCred = callCredToken(this.token.token);
const channelCred = grpc.credentials.createSsl(this.caCert);
const combCreds = grpc.credentials.combineChannelCredentials(channelCred, callCred);
credential = combCreds;
}
const clientOptions = {
...GRPC_OPTIONS,
'grpc.ssl_target_name_override': this.serverName,
};
{
// Deprecated: this block will be removed after Dec 21, 2022.
/**
* Huan(202108): `grpc.default_authority` is a workaround
* for compatiblity with the non-tls community servers/clients.
*
* See: https://github.com/wechaty/wechaty-puppet-service/pull/78
*/
const grpcDefaultAuthority = this.token.token;
clientOptions['grpc.default_authority'] = grpcDefaultAuthority;
}
if (this._client) {
log.warn('GrpcManager', 'initClient() this.#client exists? Old client has been dropped.');
this._client = undefined;
}
this._client = new puppet.PuppetClient(this.endpoint, credential, clientOptions);
log.verbose('GrpcManager', 'initClient() ... done');
}
destroyClient() {
log.verbose('GrpcManager', 'destroyClient()');
if (!this._client) {
log.warn('GrpcManager', 'destroyClient() this.#client not exist');
return;
}
const client = this._client;
/**
* Huan(202108): we should set `this.client` to `undefined` at the current event loop
* to prevent the future usage of the old client.
*/
this._client = undefined;
try {
client.close();
}
catch (e) {
log.error('GrpcManager', 'destroyClient() client.close() rejection: %s\n%s', e && e.message, e.stack);
}
}
async startStream() {
log.verbose('GrpcManager', 'startStream()');
if (this.eventStream) {
log.verbose('GrpcManager', 'startStream() this.eventStream exists, dropped.');
this.eventStream = undefined;
}
log.verbose('GrpcManager', 'startStream() grpc -> event() ...');
const eventStream = this.client.event(new puppet.EventRequest());
log.verbose('GrpcManager', 'startStream() grpc -> event() ... done');
/**
* Store the event data from the stream when we test connection,
* and re-emit the event data when we have finished testing the connection
*/
let peekedData;
/**
* Huan(202108): future must be placed before other listenser registration
* because the on('data') will start drain the stream
*/
const future = new Promise((resolve, reject) => eventStream
/**
* Huan(202108): we need a `heartbeat` event to confirm the connection is alive
* for our wechaty-puppet-service server code, when the gRPC event stream is opened,
* it will emit a `heartbeat` event as early as possible.
*
* However, the `heartbeat` event is not guaranteed to be emitted,
* if the puppet service provider is coming from the community, like:
* - paimon
*
* So we also need a timeout for compatible with those providers
* in case of they are not following this special protocol.
*/
.once('data', (resp) => {
peekedData = resp;
resolve();
})
/**
* Any of the following events will be emitted means that there's a problem.
*/
.once('cancel', reject)
.once('end', reject)
.once('error', reject)
/**
* The `status` event is import when we connect a gRPC stream.
*
* Huan(202108): according to the unit tests (tests/grpc-client.spec.ts)
* 1. If client TLS is not ok (client no-tls but server TLS is required)
* then status will be:
* { code: 14, details: 'Connection dropped' }
* 2. If client TLS is ok but the client token is invalid,
* then status will be:
* { code: 16, details: 'Invalid Wechaty TOKEN "0.XXX"' }
*/
.once('status', status => {
// console.info('once(status)', status)
status.code === GrpcStatus.OK
? resolve()
: reject(new Error('once(status)'));
}));
/**
* Huan(202108): the `heartbeat` event is not guaranteed to be emitted
* if a puppet service provider is coming from the community, it might not follow the protocol specification.
* So we need a timeout for compatible with those providers
*/
log.verbose('GrpcManager', 'startStream() grpc -> event peeking data or timeout ...');
try {
await timeoutPromise(future, 5 * 1000); // 5 seconds
}
catch (_) {
log.verbose('GrpcManager', 'startStream() grpc -> event peeking data or timeout ... timeout');
}
log.verbose('GrpcManager', 'startStream() grpc -> event peeking data or timeout ... data peeked');
/**
* Bridge the events
* Huan(202108): adding the below event listeners
* must be after the `await future` above,
* so that if there's any `error` event,
* it will be triggered already.
*/
log.verbose('GrpcManager', 'startStream() initializing event stream ...');
eventStream
.on('cancel', (...args) => this.emit('cancel', ...args))
.on('data', (...args) => this.emit('data', ...args))
.on('end', (...args) => this.emit('end', ...args))
.on('error', (...args) => this.emit('error', ...args))
.on('metadata', (...args) => this.emit('metadata', ...args))
.on('status', (...args) => this.emit('status', ...args));
this.eventStream = eventStream;
log.verbose('GrpcManager', 'startStream() initializing event stream ... done');
/**
* Re-emit the peeked data if there's any
*/
if (peekedData) {
log.verbose('GrpcManager', 'startStream() sending back the peeked data ...');
this.emit('data', peekedData);
peekedData = undefined;
log.verbose('GrpcManager', 'startStream() sending back the peeked data ... done');
}
log.verbose('GrpcManager', 'startStream() ... done');
}
stopStream() {
log.verbose('GrpcManager', 'stopStream()');
if (!this.eventStream) {
log.verbose('GrpcManager', 'stopStream() no eventStream when stop, skip destroy.');
return;
}
/**
* Huan(202108): we should set `this.eventStream` to `undefined` at the current event loop
* to prevent the future usage of the old eventStream.
*/
const eventStream = this.eventStream;
this.eventStream = undefined;
/**
* Huan(202003):
* destroy() will be enough to terminate a stream call.
* cancel() is not needed.
*/
// this.eventStream.cancel()
try {
log.verbose('GrpcManager', 'stopStream() destroying event stream ...');
eventStream.destroy();
log.verbose('GrpcManager', 'stopStream() destroying event stream ... done');
}
catch (e) {
this.emit('error', e);
}
}
}
export { GrpcManager, };
//# sourceMappingURL=grpc-manager.js.map