diff --git a/.github/workflows/npm.yml b/.github/workflows/npm.yml index 85d1e1b3..462cc122 100644 --- a/.github/workflows/npm.yml +++ b/.github/workflows/npm.yml @@ -49,7 +49,7 @@ jobs: WECHATY_PUPPET_SERVICE_TOKEN: ${{ secrets.WECHATY_PUPPET_SERVICE_TOKEN }} publish: - if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/heads/v')) + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/v')) name: Publish needs: [build, pack] runs-on: ubuntu-latest @@ -75,7 +75,7 @@ jobs: - name: Check Branch id: check-branch run: | - if [[ ${{ github.ref }} =~ ^refs/heads/(master|v[0-9]+\.[0-9]+.*)$ ]]; then + if [[ ${{ github.ref }} =~ ^refs/heads/(main|v[0-9]+\.[0-9]+.*)$ ]]; then echo ::set-output name=match::true fi # See: https://stackoverflow.com/a/58869470/1123955 - name: Is A Publish Branch diff --git a/README.md b/README.md index 2e2cdeee..e950e484 100644 --- a/README.md +++ b/README.md @@ -55,13 +55,41 @@ WECHATY_PUPPET_SERVICE_TOKEN=__WECHATY_PUPPET_SERVCIE_TOKEN__ node bot.js ## History -### master v0.25 +### master v0.27 + +Implemented SSL and server-side token authorization! + +#### New environment variables + +For Puppet Server: + +1. `WECHATY_PUPPET_SERVICE_SSL_SERVER_CERT`: + Server CA Cert (string data), + will be replaced if `options.sslServerCert` has been set. +1. `WECHATY_PUPPET_SERVICE_SSL_SERVER_KEY`: + Server CA Key (string data), + will be replaced if `optoins.sslServerKey` has been set. + +For Puppet Client: + +1. `WECHATY_PUPPET_SERVICE_AUTHORITY`: + `api.chatie.io` service discovery host name, + will be replaced if `options.authority` has been set. +1. `WECHATY_PUPPET_SERVICE_SSL_ROOT_CERT`: + Root CA Cert (string data), + will be replaced if `options.sslRootCert` has been set. +1. `WECHATY_PUPPET_SERVICE_GRPC_SSL_TARGET_NAME_OVERRIDE`: + Server Name (mast match for SNI), + will be replaced if `optoins.servername` has been set. + +#### Changelog 1. use [wechaty-token](https://github.com/wechaty/token) for gRPC service discovery with `wechaty` schema (xDS like) 1. deprecated `WECHATY_SERVICE_DISCOVERY_ENDPOINT`, replaced by `WECHATY_PUPPET_SERVICE_AUTHORITY`. (See [#156](https://github.com/wechaty/wechaty-puppet-service/issues/156)) +1. enable TLS & Token Auth (See [#124](https://github.com/wechaty/wechaty-puppet-service/issues/124)) ### v0.14 (Jan 2021) diff --git a/package.json b/package.json index 04373080..a490196c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wechaty-puppet-service", - "version": "0.25.2", + "version": "0.27.1", "description": "Puppet Service for Wechaty", "main": "dist/src/mod.js", "typings": "dist/src/mod.d.ts", diff --git a/src/auth/README.md b/src/auth/README.md new file mode 100644 index 00000000..66468540 --- /dev/null +++ b/src/auth/README.md @@ -0,0 +1,10 @@ +# Auth for Wechaty Puppet Service gRPC + +1. OpenSSL CA generation script: +1. Common Name (CN) is very important because it will be used as Server Name Indication (SNI) in TLS handshake. + +## Monkey Patch + +We need to copy http2 header `:authority` to metadata. + +See: diff --git a/src/auth/auth-impl-token.spec.ts b/src/auth/auth-impl-token.spec.ts new file mode 100755 index 00000000..0935c874 --- /dev/null +++ b/src/auth/auth-impl-token.spec.ts @@ -0,0 +1,81 @@ +#!/usr/bin/env ts-node + +import { + test, + sinon, +} from 'tstest' + +import { + GrpcStatus, + Metadata, + UntypedServiceImplementation, +} from './grpc-js' + +import { authImplToken } from './auth-impl-token' + +test('authImplToken()', async t => { + const sandbox = sinon.createSandbox() + const spy = sandbox.spy() + + const TOKEN = '__token__' + const IMPL: UntypedServiceImplementation = { + spy, + } + + const implWithAuth = authImplToken(TOKEN)(IMPL) + const validMetadata = new Metadata() + validMetadata.add('Authorization', 'Wechaty ' + TOKEN) + + const TEST_LIST = [ + { // Valid Token Request + call: { + metadata: validMetadata, + } as any, + callback: sandbox.spy(), + ok: true, + }, + { // Invalid request for Callback + call: { + metadata: new Metadata(), + } as any, + callback: sandbox.spy(), + ok: false, + }, + { // Invalid request for Stream + call: { + emit: sandbox.spy(), + metadata: new Metadata(), + } as any, + callback: undefined, + ok: false, + }, + ] as const + + const method = implWithAuth['spy']! + + for (const { call, callback, ok } of TEST_LIST) { + spy.resetHistory() + + method(call, callback as any) + + if (ok) { + t.true(spy.calledOnce, 'should call IMPL handler') + continue + } + + /** + * not ok + */ + t.true(spy.notCalled, 'should not call IMPL handler') + if (callback) { + t.equal(callback.args[0]![0].code, GrpcStatus.UNAUTHENTICATED, 'should return UNAUTHENTICATED') + } else { + t.equal(call.emit.args[0][0], 'error', 'should emit error') + t.equal(call.emit.args[0][1].code, GrpcStatus.UNAUTHENTICATED, 'should emit UNAUTHENTICATED') + } + + // console.info(spy.args) + // console.info(callback?.args) + } + sandbox.restore() +}) diff --git a/src/auth/auth-impl-token.ts b/src/auth/auth-impl-token.ts new file mode 100644 index 00000000..e1f64cc9 --- /dev/null +++ b/src/auth/auth-impl-token.ts @@ -0,0 +1,108 @@ +import { log } from '../config' + +import { + StatusBuilder, + UntypedHandleCall, + sendUnaryData, + Metadata, + ServerUnaryCall, + GrpcStatus, + UntypedServiceImplementation, +} from './grpc-js' + +import { monkeyPatchMetadataFromHttp2Headers } from './mokey-patch-header-authorization' + +/** + * Huan(202108): Monkey patch to support + * copy `:authority` from header to metadata + */ +monkeyPatchMetadataFromHttp2Headers(Metadata) + +/** + * Huan(202108): wrap handle calls with authorization + * + * See: + * https://grpc.io/docs/guides/auth/#with-server-authentication-ssltls-and-a-custom-header-with-token + */ +const authWrapHandlerToken = (validToken: string) => (handler: UntypedHandleCall) => { + log.verbose('wechaty-puppet-service', + 'auth/auth-impl-token.ts authWrapHandlerToken(%s)(%s)', + validToken, + handler.name, + ) + + return function ( + call: ServerUnaryCall, + cb?: sendUnaryData, + ) { + // console.info('wrapAuthHandler internal') + + const authorization = call.metadata.get('authorization')[0] + // console.info('authorization', authorization) + + let errMsg = '' + if (typeof authorization === 'string') { + if (authorization.startsWith('Wechaty ')) { + const token = authorization.substring(8 /* 'Wechaty '.length */) + if (token === validToken) { + + return handler( + call as any, + cb as any, + ) + + } else { + errMsg = `Invalid Wechaty TOKEN "${token}"` + } + } else { + const type = authorization.split(/\s+/)[0] + errMsg = `Invalid authorization type: "${type}"` + } + } else { + errMsg = 'No Authorization found.' + } + + /** + * Not authorized + */ + const error = new StatusBuilder() + .withCode(GrpcStatus.UNAUTHENTICATED) + .withDetails(errMsg) + .withMetadata(call.metadata) + .build() + + if (cb) { + /** + * Callback: + * handleUnaryCall + * handleClientStreamingCall + */ + cb(error) + } else if ('emit' in call) { + /** + * Stream: + * handleServerStreamingCall + * handleBidiStreamingCall + */ + call.emit('error', error) + } else { + throw new Error('no callback and call is not emit-able') + } + } +} + +const authImplToken = (validToken: string) => ( + serviceImpl: T, +) => { + log.verbose('wechaty-puppet-service', 'authImplToken()') + + for (const [key, val] of Object.entries(serviceImpl)) { + // any: https://stackoverflow.com/q/59572522/1123955 + (serviceImpl as any)[key] = authWrapHandlerToken(validToken)(val) + } + return serviceImpl +} + +export { + authImplToken, +} diff --git a/src/auth/ca.spec.ts b/src/auth/ca.spec.ts new file mode 100755 index 00000000..86d7de54 --- /dev/null +++ b/src/auth/ca.spec.ts @@ -0,0 +1,114 @@ +#!/usr/bin/env ts-node + +import { + test, +} from 'tstest' + +import https from 'https' + +import { + GET_WECHATY_PUPPET_SERVICE_SSL_ROOT_CERT, + GET_WECHATY_PUPPET_SERVICE_SSL_SERVER_CERT, + GET_WECHATY_PUPPET_SERVICE_SSL_SERVER_KEY, +} from './ca' +import { AddressInfo } from 'ws' + +test('CA smoke testing', async t => { + + const ca = GET_WECHATY_PUPPET_SERVICE_SSL_ROOT_CERT() + const cert = GET_WECHATY_PUPPET_SERVICE_SSL_SERVER_CERT() + const key = GET_WECHATY_PUPPET_SERVICE_SSL_SERVER_KEY() + + const server = https.createServer({ + cert, + key, + }) + + const ALIVE = 'Alive!\n' + const SNI = 'wechaty-puppet-service' + + server.on('request', (_req, res) => { + res.writeHead(200) + res.end(ALIVE) + }) + + server.listen() + const port = (server.address() as AddressInfo).port + + const reply = await new Promise((resolve, reject) => { + https.request({ + ca, + hostname: '127.0.0.1', + method: 'GET', + path: '/', + port, + servername: SNI, + }, res => { + res.on('data', chunk => resolve(chunk.toString())) + res.on('error', reject) + }).end() + }) + server.close() + + t.equal(reply, ALIVE, 'should get https server reply') +}) + +test('CA SNI tests', async t => { + + const ca = GET_WECHATY_PUPPET_SERVICE_SSL_ROOT_CERT() + const cert = GET_WECHATY_PUPPET_SERVICE_SSL_SERVER_CERT() + const key = GET_WECHATY_PUPPET_SERVICE_SSL_SERVER_KEY() + + const server = https.createServer({ + cert, + key, + }) + + server.on('request', (_req, res) => { + res.writeHead(200) + res.end(ALIVE) + }) + + server.listen() + const port = (server.address() as AddressInfo).port + + const ALIVE = 'Alive!\n' + const SNI_TEST_LIST = [ + [ + 'wechaty-puppet-service', + true, + ], + [ + 'invalid-sni', + false, + "Hostname/IP does not match certificate's altnames: Host: invalid-sni. is not cert's CN: wechaty-puppet-service", + ], + ] as const + + for (const [SNI, EXPECT, MSG] of SNI_TEST_LIST) { + const result = await new Promise((resolve, reject) => { + https.request({ + ca, + hostname: '127.0.0.1', + method: 'GET', + path: '/', + port, + servername: SNI, + }, res => { + res.on('data', chunk => resolve(chunk.toString() === ALIVE)) + res.on('error', reject) + }) + .on('error', e => { + // console.info(e.message) + t.equal(e.message, MSG, 'should get the error for invalid SNI: ' + SNI) + resolve(false) + }) + .end() + + }) + + t.equal(result, EXPECT, 'should check the SNI: ' + SNI) + } + + server.close() +}) diff --git a/src/auth/ca.ts b/src/auth/ca.ts new file mode 100644 index 00000000..e615c4c6 --- /dev/null +++ b/src/auth/ca.ts @@ -0,0 +1,120 @@ +/** + * Wechaty Certificate Authority Repo: + * https://github.com/wechaty/dotenv/tree/main/ca + * + * WARNING: This CA is not safe for production. + * **use environment variables to set your safe CA data** + * + * Update: + * - Huan(202108): init + */ + +/** + * Huan(202108): the CA generated by Wechaty Puppet Service + * default set to this value: + * + * grpc.ssl_target_name_override: 'wechaty-puppet-service' + */ +const GRPC_SSL_TARGET_NAME_OVERRIDE = 'wechaty-puppet-service' + +const SSL_ROOT_CERT = `-----BEGIN CERTIFICATE----- +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 +-----END CERTIFICATE-----` + +const SSL_SERVER_KEY = `-----BEGIN RSA PRIVATE KEY----- +MIICXQIBAAKBgQDs/JfIqLFFS0FnDR8JV4Xjqpg2McfL5OXEkqeETbJmGWDBvD3o +Xs62CGmtshIYH2K7HP7k5Jp+Pv5eoIe1zMQQ+4XkHsNt+jUPDk3wi2e5U3dLo/7r +IpQ23ykjiOjkgH8socL0dbsjuLzxIgFHSRHZnkVgh3kMz4oexJyNaPEq/wIDAQAB +AoGAUJwKtQJMahmTAY6BBBh1Pl+Ersp3/264iQipWmNjTz9Knht9o1C8V0a9S4vK +g9IJL0RJn2ny8wZSV7Wa88fW2Jhke/w2DtFXCHEBW0SVzl/xHBA0FwxF3FkLOJvR +vsN1y0I2QF+Fl8wFV4K0RZq4FklpA+/tzupjDrjjW7QhwOECQQD3rMDzrup4AZyT +DXYj477/s31HOhcVKNLGD05hBbPiN9eXtPnAs+FgZmGvtDWgzVnXQq8dbu6p+Xs2 +mVkNp1frAkEA9PPeHmyhMKTL03lMK7HhLiY/EttA6n91MuA10M/pxYBaaee7KFm4 +v7n4gESc/fbbLB+GdBRUKjpQFw+Zsl2oPQJAc8/3BbuT7fuq8GRKCuwy4rRWb1jt +dDp7nJuJpfqZq7069bhtVLuINqCJKzTUItYDHZIT+mpl9VswT06TgrvucwJBAKnI +EnACGWOvBfwpOgubOpoTNmqqf/9JowFFeOeoBL+5LHH1hbr9HVn+2+iEJlC9dsLJ +gxcYNBIk4vho/r4rvn0CQQDfd15r8LK46LLOShvIfulikxH/9RTMBMtduPqMvrPD +gi1SYSPnW5wLCmg0qR4vDRtywEMIi2TMJeYgeSeVcWRQ +-----END RSA PRIVATE KEY-----` + +const SSL_SERVER_CERT = `-----BEGIN CERTIFICATE----- +MIID3zCCAccCAQEwDQYJKoZIhvcNAQELBQAwcjELMAkGA1UEBhMCVVMxFjAUBgNV +BAgMDVNhbiBGcmFuY2lzY28xEjAQBgNVBAcMCVBhbG8gQWx0bzEQMA4GA1UECgwH +V2VjaGF0eTELMAkGA1UECwwCQ0ExGDAWBgNVBAMMD3dlY2hhdHktcm9vdC1jYTAe +Fw0yMTA4MDkxNTQ4NTJaFw0zMTA4MDcxNTQ4NTJaMH0xCzAJBgNVBAYTAlVTMRYw +FAYDVQQIDA1TYW4gRnJhbmNpc2NvMRIwEAYDVQQHDAlQYWxvIEFsdG8xEDAOBgNV +BAoMB1dlY2hhdHkxDzANBgNVBAsMBlB1cHBldDEfMB0GA1UEAwwWd2VjaGF0eS1w +dXBwZXQtc2VydmljZTCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEA7PyXyKix +RUtBZw0fCVeF46qYNjHHy+TlxJKnhE2yZhlgwbw96F7OtghprbISGB9iuxz+5OSa +fj7+XqCHtczEEPuF5B7Dbfo1Dw5N8ItnuVN3S6P+6yKUNt8pI4jo5IB/LKHC9HW7 +I7i88SIBR0kR2Z5FYId5DM+KHsScjWjxKv8CAwEAATANBgkqhkiG9w0BAQsFAAOC +AgEAC38F30LuynsRJdDZZZQ07M+MPyEVhSMyCMyqmGNkIDVtcAH9mPDPTps09S8Z +AA+peeJzhySMg/UGh3tjgimVaNGpbD+Y8Hg4N3UhZVRFpDoof6QQT1FpB+xV0Yxs +Q+0Fcj+d0rSsAXMeEdtk+nN3URJn2QrKn5uiueHrrmp5pW0XpIr0zNkccuFvS4V+ +tANSz5Lo18ffgePRPwp84zgNJ37Q9of2rcL7booUX3PUPFh4NquviHxKAwsv+nMe +DMzKvEMtQeY+5nEpIQxMYBAK5XzadSEZqVge3kXHKQNAEx3k3utIim1fEQDVZlXT +kN9/iFn/BpULTCMNyuY7eooltNiMhDDINwM0zK8DZuDJintx4RC5P5+IdvzeKcow +5eZFsGcLx6kBoO8u+H2Ihwgppts6RuJmi2JhkAiXO7ShgRzQjpWg22rXvWCBrUqn +2YW42wMLUmEpLWXtG1lxdktE/OPz5omSNqaExZv5dKsQreezk6MomCExntnQT3WT +9GGsQ6jNPE5jX+V4JprTSoRBCuc8Rf8vq1+5aUSje8lFgk8dYmO1VDyapzbCY+jV +gCsjnrxqENI6+kbgKlQhdC8T2vBVuVJ3AkMnzc5hw02gitSyOoPUs07zJtV7geqh +EPV0eUNvPeO94cdmX/ZUgVEQ0WzleGYMwkHMFoh/KToR3Bw= +-----END CERTIFICATE-----` + +/** + * Environment variables containing newlines in Node? + * `replace(/\\n/g, '\n')` + * https://stackoverflow.com/a/36439803/1123955 + */ +const GET_WECHATY_PUPPET_SERVICE_SSL_ROOT_CERT = (v?: string) => v + || process.env['WECHATY_PUPPET_SERVICE_SSL_ROOT_CERT']?.replace(/\\n/g, '\n') + || SSL_ROOT_CERT + +const GET_WECHATY_PUPPET_SERVICE_SSL_SERVER_CERT = (v?: string) => v + || process.env['WECHATY_PUPPET_SERVICE_SSL_SERVER_CERT']?.replace(/\\n/g, '\n') + || SSL_SERVER_CERT + +const GET_WECHATY_PUPPET_SERVICE_SSL_SERVER_KEY = (v?: string) => v + || process.env['WECHATY_PUPPET_SERVICE_SSL_SERVER_KEY']?.replace(/\\n/g, '\n') + || SSL_SERVER_KEY + +const GET_WECHATY_PUPPET_SERVICE_GRPC_SSL_TARGET_NAME_OVERRIDE = (v?: string) => v + || process.env['WECHATY_PUPPET_SERVICE_GRPC_SSL_TARGET_NAME_OVERRIDE']?.replace(/\\n/g, '\n') + || GRPC_SSL_TARGET_NAME_OVERRIDE + +export { + GET_WECHATY_PUPPET_SERVICE_GRPC_SSL_TARGET_NAME_OVERRIDE, + GET_WECHATY_PUPPET_SERVICE_SSL_ROOT_CERT, + GET_WECHATY_PUPPET_SERVICE_SSL_SERVER_CERT, + GET_WECHATY_PUPPET_SERVICE_SSL_SERVER_KEY, +} diff --git a/src/auth/call-cred.spec.ts b/src/auth/call-cred.spec.ts new file mode 100755 index 00000000..a0fae235 --- /dev/null +++ b/src/auth/call-cred.spec.ts @@ -0,0 +1,25 @@ +#!/usr/bin/env ts-node + +import { + test, + sinon, +} from 'tstest' + +import { metaGeneratorToken } from './call-cred' + +test('metaGeneratorToken()', async t => { + const TOKEN = '__token__' + const EXPECTED_AUTHORIZATION = `Wechaty ${TOKEN}` + + const sandbox = sinon.createSandbox() + const spy = sandbox.spy() + + const metaGenerator = metaGeneratorToken(TOKEN) + + metaGenerator({} as any, spy) + t.equal(spy.args[0]![0], null, 'should no error') + + const metadata = spy.args[0]![1] + const authorization = metadata.get('Authorization')[0] + t.equal(authorization, EXPECTED_AUTHORIZATION, 'should generate authorization in metadata') +}) diff --git a/src/auth/call-cred.ts b/src/auth/call-cred.ts new file mode 100644 index 00000000..40c9df91 --- /dev/null +++ b/src/auth/call-cred.ts @@ -0,0 +1,22 @@ +import { + credentials, + CallMetadataGenerator, + Metadata, +} from './grpc-js' + +/** + * With server authentication SSL/TLS and a custom header with token + * https://grpc.io/docs/guides/auth/#with-server-authentication-ssltls-and-a-custom-header-with-token-1 + */ +const metaGeneratorToken: (token: string) => CallMetadataGenerator = token => (_params, callback) => { + const meta = new Metadata() + meta.add('authorization', `Wechaty ${token}`) + callback(null, meta) +} + +const callCredToken = (token: string) => credentials.createFromMetadataGenerator(metaGeneratorToken(token)) + +export { + callCredToken, + metaGeneratorToken, +} diff --git a/src/auth/grpc-js.ts b/src/auth/grpc-js.ts new file mode 100644 index 00000000..a8ede85c --- /dev/null +++ b/src/auth/grpc-js.ts @@ -0,0 +1,27 @@ +import { + credentials, + StatusBuilder, + UntypedHandleCall, + Metadata, + UntypedServiceImplementation, +} from '@grpc/grpc-js' +import { + sendUnaryData, + ServerUnaryCall, +} from '@grpc/grpc-js/build/src/server-call' +import { + CallMetadataGenerator, +} from '@grpc/grpc-js/build/src/call-credentials' +import { Status as GrpcStatus } from '@grpc/grpc-js/build/src/constants' + +export { + credentials, + CallMetadataGenerator, + GrpcStatus, + Metadata, + sendUnaryData, + ServerUnaryCall, + StatusBuilder, + UntypedHandleCall, + UntypedServiceImplementation, +} diff --git a/src/auth/mod.ts b/src/auth/mod.ts new file mode 100644 index 00000000..1e64e055 --- /dev/null +++ b/src/auth/mod.ts @@ -0,0 +1,19 @@ +import { + GET_WECHATY_PUPPET_SERVICE_GRPC_SSL_TARGET_NAME_OVERRIDE, + GET_WECHATY_PUPPET_SERVICE_SSL_ROOT_CERT, + GET_WECHATY_PUPPET_SERVICE_SSL_SERVER_CERT, + GET_WECHATY_PUPPET_SERVICE_SSL_SERVER_KEY, +} from './ca' +import { authImplToken } from './auth-impl-token' +import { monkeyPatchMetadataFromHttp2Headers } from './mokey-patch-header-authorization' +import { callCredToken } from './call-cred' + +export { + GET_WECHATY_PUPPET_SERVICE_GRPC_SSL_TARGET_NAME_OVERRIDE, + GET_WECHATY_PUPPET_SERVICE_SSL_ROOT_CERT, + GET_WECHATY_PUPPET_SERVICE_SSL_SERVER_CERT, + GET_WECHATY_PUPPET_SERVICE_SSL_SERVER_KEY, + authImplToken, + callCredToken, + monkeyPatchMetadataFromHttp2Headers, +} diff --git a/src/auth/mokey-patch-header-authorization.ts b/src/auth/mokey-patch-header-authorization.ts new file mode 100644 index 00000000..ec12458b --- /dev/null +++ b/src/auth/mokey-patch-header-authorization.ts @@ -0,0 +1,38 @@ +import http2 from 'http2' + +import { log } from '../config' + +import { + Metadata, +} from './grpc-js' + +function monkeyPatchMetadataFromHttp2Headers ( + MetadataClass: typeof Metadata, +): () => void { + log.verbose('wechaty-puppet-service', 'monkeyPatchMetadataFromHttp2Headers()') + + const fromHttp2Headers = MetadataClass.fromHttp2Headers + MetadataClass.fromHttp2Headers = function ( + headers: http2.IncomingHttpHeaders + ): Metadata { + const metadata = fromHttp2Headers.call(MetadataClass, headers) + + if (metadata.get('authorization').length <= 0) { + const authority = headers[':authority'] + const authorization = `Wechaty ${authority}` + metadata.set('authorization', authorization) + } + return metadata + } + + /** + * un-monkey-patch + */ + return () => { + MetadataClass.fromHttp2Headers = fromHttp2Headers + } +} + +export { + monkeyPatchMetadataFromHttp2Headers, +} diff --git a/src/auth/monkey-patch-header-authorization.spec.ts b/src/auth/monkey-patch-header-authorization.spec.ts new file mode 100755 index 00000000..c2cdbcb6 --- /dev/null +++ b/src/auth/monkey-patch-header-authorization.spec.ts @@ -0,0 +1,26 @@ +#!/usr/bin/env ts-node + +import test from 'tstest' +import http2 from 'http2' + +import { + Metadata, +} from './grpc-js' + +import { monkeyPatchMetadataFromHttp2Headers } from './mokey-patch-header-authorization' + +test('monkeyPatchMetadataFromHttp2Headers', async t => { + const AUTHORITY = '__authority__' + const headers: http2.IncomingHttpHeaders = { + ':authority': AUTHORITY, + } + + const dispose = monkeyPatchMetadataFromHttp2Headers(Metadata) + const meta = Metadata.fromHttp2Headers(headers) + + const authorization = meta.get('authorization')[0] + const EXPECTED = `Wechaty ${AUTHORITY}` + t.equal(authorization, EXPECTED, 'should get authority from metadata') + + dispose() +}) diff --git a/src/client/puppet-service.ts b/src/client/puppet-service.ts index 6a02a1a4..25ff19e9 100644 --- a/src/client/puppet-service.ts +++ b/src/client/puppet-service.ts @@ -128,6 +128,11 @@ import { serializeFileBox } from '../server/serialize-file-box' import { recover$, } from './recover$' +import { + GET_WECHATY_PUPPET_SERVICE_GRPC_SSL_TARGET_NAME_OVERRIDE, + GET_WECHATY_PUPPET_SERVICE_SSL_ROOT_CERT, +} from '../auth/ca' +import { callCredToken } from '../auth/mod' // Huan(202108): FIXME: does wechaty:///__token__ need to be retried? // const MAX_SERVICE_IP_DISCOVERY_RETRIES = 10 @@ -141,6 +146,12 @@ const MAX_GRPC_CONNECTION_RETRIES = 5 */ WechatyResolver.setup() +export type PuppetServiceOptions = PuppetOptions & { + authority? : string + servername? : string + sslRootCert? : string +} + export class PuppetService extends Puppet { static override readonly VERSION = VERSION @@ -148,6 +159,14 @@ export class PuppetService extends Puppet { private grpcClient? : PuppetClient private eventStream? : grpc.ClientReadableStream + /** + * for Node.js TLS SNI + * https://en.wikipedia.org/wiki/Server_Name_Indication + */ + private servername: string + private token: string + private endpoint: string + // Emit the last heartbeat if there's no more coming after HEARTBEAT_DEBOUNCE_TIME seconds // private heartbeatDebounceQueue: DebounceQueue @@ -164,95 +183,19 @@ export class PuppetService extends Puppet { private reconnectTimer?: NodeJS.Timeout constructor ( - public override options: PuppetOptions = {}, + public override options: PuppetServiceOptions = {}, ) { super(options) - options.endpoint = GET_WECHATY_PUPPET_SERVICE_ENDPOINT(options.endpoint) - options.token = GET_WECHATY_PUPPET_SERVICE_TOKEN(options.token) - // this.heartbeatDebounceQueue = new DebounceQueue(HEARTBEAT_DEBOUNCE_TIME * 1000) - this.cleanCallbackList = [] - } + this.servername = GET_WECHATY_PUPPET_SERVICE_GRPC_SSL_TARGET_NAME_OVERRIDE(options.servername) - // Huan(202108): to be deleted - // remove the following comments - // after confirm that the `wechaty-token` module works as expected. - - // private async discoverServiceIp ( - // token: string, - // ): Promise<{ ip?: string, port?: number }> { - // log.verbose('PuppetService', 'discoverServiceIp(%s)', token) - - // const chatieEndpoint = GET_WECHATY_SERVICE_DISCOVERY_ENDPOINT() - - // try { - // return Promise.race< - // Promise<{ - // ip: string, - // port: number - // }> - // >([ - // this.getServiceIp(chatieEndpoint, token), - // // eslint-disable-next-line promise/param-names - // new Promise((_, reject) => setTimeout( - // () => reject(new Error('ETIMEOUT')), - // /** - // * Huan(202106): Better deal with the timeout error - // * Related to https://github.com/wechaty/wechaty/issues/2197 - // */ - // 5 * 1000, - // )), - // ]) - // } catch (e) { - // log.warn(`discoverServiceIp() failed to get any ip info from all service endpoints.\n${e.stack}`) - // return {} - // } - // } + this.token = GET_WECHATY_PUPPET_SERVICE_TOKEN(options.token) + this.endpoint = GET_WECHATY_PUPPET_SERVICE_ENDPOINT(options.endpoint) + || `wechaty://${GET_WECHATY_PUPPET_SERVICE_AUTHORITY(options.authority)}/${this.token}` - // private async getServiceIp ( - // endpoint : string, - // token : string, - // ): Promise<{ - // ip : string, - // port : number, - // }> { - // const url = `${endpoint}/v0/hosties/${token}` - - // const jsonStr = await new Promise((resolve, reject) => { - // const httpClient = /^https:\/\//.test(url) ? https : http - // httpClient.get(url, function (res) { - // let body = '' - // res.on('data', function (chunk) { - // body += chunk - // }) - // res.on('end', function () { - // resolve(body) - // }) - // }).on('error', function (e) { - // reject(new Error(`PuppetService discoverServiceIp() endpoint<${url}> rejection: ${e}`)) - // }) - // }) - - // try { - // const result = JSON.parse(jsonStr) as { port: number, ip: string } - // return result - - // } catch (e) { - // console.error([ - // `wechaty-puppet-service: PuppetService.getServiceIp(${endpoint}, ${token})`, - // 'failed: unable to parse JSON str to object:', - // '----- jsonStr START -----', - // jsonStr, - // '----- jsonStr END -----', - // ].join('\n')) - // } - - // return { - // ip: '0.0.0.0', - // port: 0, - // } - // } + this.cleanCallbackList = [] + } protected async startGrpcClient (): Promise { log.verbose('PuppetService', 'startGrpcClient()') @@ -261,40 +204,24 @@ export class PuppetService extends Puppet { throw new Error('puppetClient had already initialized') } - let endpoint = this.options.endpoint - if (!endpoint) { - // Huan(202108): to be deleted - // remove the following comments - // after confirm that the `wechaty-token` module works as expected. - - // let serviceIpResult = await this.discoverServiceIp(this.options.token!) + log.silly('PuppetService', 'startGrpcClient() endpoint="%s"', this.endpoint) - // let retries = MAX_SERVICE_IP_DISCOVERY_RETRIES - // while (retries > 0 && (!serviceIpResult.ip || serviceIpResult.ip === '0.0.0.0')) { - // log.warn(`No endpoint when starting grpc client, ${retries--} retry left. Reconnecting in 10 seconds...`) - // await new Promise(resolve => setTimeout(resolve, 10 * 1000)) - // serviceIpResult = await this.discoverServiceIp(this.options.token!) - // } + const rootCert = GET_WECHATY_PUPPET_SERVICE_SSL_ROOT_CERT(this.options.sslRootCert) - // if (!serviceIpResult.ip || serviceIpResult.ip === '0.0.0.0') { - // return - // } - - // endpoint = serviceIpResult.ip + ':' + serviceIpResult.port - const authority = GET_WECHATY_PUPPET_SERVICE_AUTHORITY() - endpoint = `wechaty://${authority}/${this.options.token}` - } - - log.silly('PuppetService', 'startGrpcClient() endpoint="%s"', endpoint) + const callCred = callCredToken(this.token) + const channelCred = grpc.credentials.createSsl(Buffer.from(rootCert)) + const combCreds = grpc.credentials.combineChannelCredentials(channelCred, callCred) const clientOptions = { ...GRPC_OPTIONS, - 'grpc.default_authority': this.options.token, + 'grpc.default_authority' : this.token, + 'grpc.ssl_target_name_override' : this.servername, } this.grpcClient = new PuppetClient( - endpoint, // 'localhost:50051', - grpc.credentials.createInsecure(), - clientOptions + this.endpoint, // 'localhost:50051', + // grpc.credentials.createInsecure(), + combCreds, + clientOptions, ) } @@ -313,23 +240,6 @@ export class PuppetService extends Puppet { await super.start() log.verbose('PuppetService', 'start()') - if (!this.options.token) { - const tokenNotFoundError = 'wechaty-puppet-service: WECHATY_PUPPET_SERVICE_TOKEN not found' - - console.error([ - '', - tokenNotFoundError, - '(save token to WECHATY_PUPPET_SERVICE_TOKEN env var or pass it to puppet options is required.).', - '', - 'To learn how to get Wechaty Puppet Service Token,', - 'please visit ', - 'to see our Wechaty Puppet Service Providers.', - '', - ].join('\n')) - - throw new Error(tokenNotFoundError) - } - if (this.state.on()) { log.warn('PuppetService', 'start() is called on a ON puppet. await ready(on) and return.') await this.state.ready('on') diff --git a/src/config.ts b/src/config.ts index 60e400c3..a1f53c9e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,7 +11,7 @@ const GRPC_OPTIONS = { } // Huan(202011): use a function to return the value in time. -const GET_WECHATY_PUPPET_SERVICE_TOKEN = (token?: string) => { +const GET_WECHATY_PUPPET_SERVICE_TOKEN: (token?: string) => string = token => { if (token) { return token } @@ -19,6 +19,7 @@ const GET_WECHATY_PUPPET_SERVICE_TOKEN = (token?: string) => { if (process.env['WECHATY_PUPPET_SERVICE_TOKEN']) { return process.env['WECHATY_PUPPET_SERVICE_TOKEN'] } + /** * Huan(202102): remove this deprecated warning after Dec 31, 2021 */ @@ -32,7 +33,21 @@ const GET_WECHATY_PUPPET_SERVICE_TOKEN = (token?: string) => { ].join(' ')) return process.env['WECHATY_PUPPET_HOSTIE_TOKEN'] } - return undefined + + const tokenNotFoundError = 'wechaty-puppet-service: WECHATY_PUPPET_SERVICE_TOKEN not found' + + console.error([ + '', + tokenNotFoundError, + '(save token to WECHATY_PUPPET_SERVICE_TOKEN env var or pass it to puppet options is required.).', + '', + 'To learn how to get Wechaty Puppet Service Token,', + 'please visit ', + 'to see our Wechaty Puppet Service Providers.', + '', + ].join('\n')) + + throw new Error(tokenNotFoundError) } const GET_WECHATY_PUPPET_SERVICE_ENDPOINT = (endpoint?: string) => { @@ -56,6 +71,7 @@ const GET_WECHATY_PUPPET_SERVICE_ENDPOINT = (endpoint?: string) => { ].join(' ')) return process.env['WECHATY_PUPPET_HOSTIE_ENDPOINT'] } + return undefined } @@ -84,19 +100,6 @@ const GET_WECHATY_PUPPET_SERVICE_AUTHORITY = (authority?: string) => { return 'api.chatie.io' } -/** - * Huan(202108): remove the below comments after confirm the above GET_WECHATY_PUPPET_SERVICE_AUTHORITY works as expected. - * See: https://github.com/wechaty/wechaty-puppet-service/issues/156 - */ -// const GET_WECHATY_SERVICE_DISCOVERY_ENDPOINT = (endpoint?: string) => { -// if (endpoint) { -// return endpoint -// } - -// return process.env['WECHATY_SERVICE_DISCOVERY_ENDPOINT'] -// || 'https://api.chatie.io' -// } - export { log, GRPC_OPTIONS, diff --git a/src/server/puppet-server.ts b/src/server/puppet-server.ts index 84001d47..35c808d8 100644 --- a/src/server/puppet-server.ts +++ b/src/server/puppet-server.ts @@ -18,11 +18,18 @@ import { import { puppetImplementation, } from './puppet-implementation' +import { + authImplToken, + GET_WECHATY_PUPPET_SERVICE_SSL_SERVER_CERT, + GET_WECHATY_PUPPET_SERVICE_SSL_SERVER_KEY, +} from '../auth/mod' export interface PuppetServerOptions { - endpoint : string, - token : string, - puppet : Puppet, + endpoint : string, + puppet : Puppet, + sslServerCert? : string, + sslServerKey? : string, + token : string, } export class PuppetServer { @@ -54,17 +61,24 @@ export class PuppetServer { const puppetImpl = puppetImplementation( this.options.puppet, ) + const puppetImplAuth = authImplToken(this.options.token)(puppetImpl) this.grpcServer = new grpc.Server(GRPC_OPTIONS) this.grpcServer.addService( PuppetService, - puppetImpl, + puppetImplAuth, ) + const keyCertPairs: grpc.KeyCertPair[] = [{ + cert_chain : Buffer.from(GET_WECHATY_PUPPET_SERVICE_SSL_SERVER_CERT(this.options.sslServerCert)), + private_key : Buffer.from(GET_WECHATY_PUPPET_SERVICE_SSL_SERVER_KEY(this.options.sslServerKey)), + }] + // 127.0.0.1:8788 const port = await util.promisify(this.grpcServer.bindAsync.bind(this.grpcServer))( this.options.endpoint, - grpc.ServerCredentials.createInsecure() + // grpc.ServerCredentials.createInsecure() + grpc.ServerCredentials.createSsl(null, keyCertPairs), ) if (port === 0) { diff --git a/src/version.spec.ts b/src/version.spec.ts index 4e3c93b5..e5b4d84f 100755 --- a/src/version.spec.ts +++ b/src/version.spec.ts @@ -5,6 +5,6 @@ import test from 'tstest' import { VERSION } from './version' -test('Make sure the VERSION is fresh in source code', async (t) => { +test('Make sure the VERSION is fresh in source code', async t => { t.equal(VERSION, '0.0.0', 'version should be 0.0.0 in source code, only updated before publish to NPM') })