UNPKG

wechaty-puppet-service

Version:
353 lines 14.9 kB
/** * 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