@tdxvolt/volt-client-grpc 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.cjs +1645 -1463
- package/package.json +1 -1
- package/protobuf/tdx/volt_api/data/v1/sqlite_database_api.proto +8 -0
- package/protobuf/tdx/volt_api/data/v1/sqlite_server_api.proto +5 -0
- package/protobuf/tdx/volt_api/{tunnel → relay}/v1/proxy_api.proto +2 -2
- package/protobuf/tdx/volt_api/{tunnel/v1/tunnel_api.proto → relay/v1/relay_api.proto} +6 -5
- package/protobuf/tdx/volt_api/sync/v1/sync.proto +49 -6
- package/protobuf/tdx/volt_api/volt/v1/discovery_api.proto +3 -3
- package/protobuf/tdx/volt_api/volt/v1/file_api.proto +47 -1
- package/protobuf/tdx/volt_api/volt/v1/spark_api.proto +121 -0
- package/protobuf/tdx/volt_api/volt/v1/volt.proto +30 -25
- package/protobuf/tdx/volt_api/volt/v1/volt_api.proto +78 -45
- package/protobuf/tdx/volt_api/volt/v1/wire_api.proto +8 -6
- package/src/constants.js +72 -72
- package/src/grpc-call.js +31 -23
- package/src/grpc-utils.js +212 -176
- package/src/index.js +1 -1
- package/src/proto-utils.js +68 -51
- package/src/utils.js +66 -66
- package/src/volt-client-internal.js +281 -252
- package/src/volt-client.js +544 -479
- package/src/volt-connection.js +144 -135
- package/src/volt-credential.js +237 -0
- package/src/tunnel.pb.js +0 -0
- package/src/volt-crypto.js +0 -213
package/src/volt-connection.js
CHANGED
|
@@ -7,151 +7,160 @@ import GRPCCall from "./grpc-call.js";
|
|
|
7
7
|
const log = debug("volt-client-grpc:volt-connection");
|
|
8
8
|
|
|
9
9
|
function _cleanUp(immediateReconnect) {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
10
|
+
this._session = false;
|
|
11
|
+
|
|
12
|
+
if (this._pingTimer) {
|
|
13
|
+
clearTimeout(this._pingTimer);
|
|
14
|
+
this._pingTimer = 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (this._pingTimeoutTimer) {
|
|
18
|
+
clearTimeout(this._pingTimeoutTimer);
|
|
19
|
+
this._pingTimeoutTimer = 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (
|
|
23
|
+
!this._dying &&
|
|
24
|
+
(immediateReconnect || (this._autoRetry && !this._reconnectTimer))
|
|
25
|
+
) {
|
|
26
|
+
this._reconnectTimer = setTimeout(
|
|
27
|
+
() => {
|
|
28
|
+
this._reconnectTimer = 0;
|
|
29
|
+
_doConnect.call(this);
|
|
30
|
+
},
|
|
31
|
+
immediateReconnect ? 0 : this._reconnectInterval,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (this.call?.writable) {
|
|
36
|
+
this.call.end();
|
|
37
|
+
}
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
function _connectionTimeoutHandler() {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
log("server timed out");
|
|
42
|
+
this.emit("connected", false);
|
|
43
|
+
_cleanUp.call(this);
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
function _doConnect() {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
47
|
+
this.call = new GRPCCall(this._voltClient, "Connect", "METHOD_TYPE_BIDI");
|
|
48
|
+
|
|
49
|
+
const grpcClient = this._voltClient.getVoltAPIClient();
|
|
50
|
+
const helloPayload = {
|
|
51
|
+
hello: {},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
this.call.start(grpcClient, helloPayload, (err, response) => {
|
|
55
|
+
if (err) {
|
|
56
|
+
log("error on connection stream [%s]", err.message);
|
|
57
|
+
_cleanUp.call(this);
|
|
58
|
+
this.emit("error", err);
|
|
59
|
+
} else {
|
|
60
|
+
switch (response.payload) {
|
|
61
|
+
case "ping": {
|
|
62
|
+
log("pinged");
|
|
63
|
+
|
|
64
|
+
// Received ping response from server => clear timeout.
|
|
65
|
+
if (this._pingTimeoutTimer) {
|
|
66
|
+
clearTimeout(this._pingTimeoutTimer);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Schedule the next ping.
|
|
70
|
+
_startPingTimer.call(this);
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
case "hello": {
|
|
74
|
+
if (!this._session) {
|
|
75
|
+
this._session = true;
|
|
76
|
+
this.emit("connected", this._session);
|
|
77
|
+
}
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
case "evt": {
|
|
81
|
+
log("received event %s", response.evt.event_type);
|
|
82
|
+
this.emit("evt", response.evt);
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
default:
|
|
86
|
+
log("unimplemented connect response payload: %s", response.payload);
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
this.call
|
|
93
|
+
.wait()
|
|
94
|
+
.then(() => {
|
|
95
|
+
if (this._session) {
|
|
96
|
+
this.emit("connected", false);
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
.catch((err) => {
|
|
100
|
+
log("unexpected error on connect call [%s]", err.message);
|
|
101
|
+
})
|
|
102
|
+
.finally(() => {
|
|
103
|
+
_cleanUp.call(this);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
_startPingTimer.call(this);
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
function _startPingTimer() {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
110
|
+
if (this._pingTimer) {
|
|
111
|
+
log("ping timer running");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
this._pingTimer = setTimeout(() => {
|
|
116
|
+
// Send next ping to server.
|
|
117
|
+
const request = { ping: { timestamp: Date.now() } };
|
|
118
|
+
|
|
119
|
+
log("pinging Volt");
|
|
120
|
+
this.call.write(request);
|
|
121
|
+
|
|
122
|
+
this._pingTimer = 0;
|
|
123
|
+
|
|
124
|
+
// Schedule a timeout if we don't hear back from the server.
|
|
125
|
+
if (this._pingTimeoutTimer) {
|
|
126
|
+
clearTimeout(this._pingTimeoutTimer);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
this._pingTimeoutTimer = setTimeout(
|
|
130
|
+
_connectionTimeoutHandler.bind(this),
|
|
131
|
+
this._timeoutInterval,
|
|
132
|
+
);
|
|
133
|
+
}, this._pingInterval);
|
|
126
134
|
}
|
|
127
135
|
|
|
128
136
|
export default class VoltConnection extends EventEmitter {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
137
|
+
constructor(voltClient, grpc) {
|
|
138
|
+
super();
|
|
139
|
+
|
|
140
|
+
const config = voltClient.config;
|
|
141
|
+
this._grpc = grpc;
|
|
142
|
+
this._autoRetry =
|
|
143
|
+
config.autoReconnect === undefined ? true : !!config.autoReconnect;
|
|
144
|
+
this._dying = false;
|
|
145
|
+
this._voltClient = voltClient;
|
|
146
|
+
this._pingTimer = 0;
|
|
147
|
+
this._pingTimeoutTimer = 0;
|
|
148
|
+
this._session = false;
|
|
149
|
+
this._pingInterval = config.pingInterval || 10000;
|
|
150
|
+
this._reconnectInterval = config.reconnectInterval || 5000;
|
|
151
|
+
this._timeoutInterval = config.timeoutInterval || 60000;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
connect(throwOnFail) {
|
|
155
|
+
if (throwOnFail) {
|
|
156
|
+
return _doConnect.call(this);
|
|
157
|
+
} else {
|
|
158
|
+
return Promise.resolve(_cleanUp.call(this, true));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
disconnect() {
|
|
163
|
+
this._dying = true;
|
|
164
|
+
_cleanUp.call(this);
|
|
165
|
+
}
|
|
157
166
|
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/* eslint-disable no-underscore-dangle */
|
|
2
|
+
import debug from "debug";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import forge from "node-forge";
|
|
5
|
+
import jwt from "jsonwebtoken";
|
|
6
|
+
import crypto from "crypto";
|
|
7
|
+
import * as voltUtils from "./utils.js";
|
|
8
|
+
import { constants } from "./constants.js";
|
|
9
|
+
|
|
10
|
+
const { pki } = forge;
|
|
11
|
+
const log = debug("volt-client-grpc:volt-credential");
|
|
12
|
+
const aesAlgorithm = "aes-256-cbc";
|
|
13
|
+
const rs256Algorithm = "RS256";
|
|
14
|
+
|
|
15
|
+
export class VoltCredential {
|
|
16
|
+
constructor(config, configPath) {
|
|
17
|
+
this._config = config;
|
|
18
|
+
this._voltConfig = config.volt;
|
|
19
|
+
this._configPath = configPath;
|
|
20
|
+
this._persistCache = !!configPath;
|
|
21
|
+
|
|
22
|
+
if (this._config.credential) {
|
|
23
|
+
this._cryptoCache = this._config.credential;
|
|
24
|
+
} else {
|
|
25
|
+
this._cryptoCache = {};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Determine if we're dealing with an encrypted key.
|
|
29
|
+
if (
|
|
30
|
+
this._cryptoCache.key?.startsWith(constants.privateEncryptedKeyPrefix)
|
|
31
|
+
) {
|
|
32
|
+
if (!config.p) {
|
|
33
|
+
throw new Error("encrypted key but no passphrase given");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Need to unencrypt the private key.
|
|
37
|
+
const decrypted = pki.decryptRsaPrivateKey(
|
|
38
|
+
this._cryptoCache.key,
|
|
39
|
+
config.p,
|
|
40
|
+
);
|
|
41
|
+
if (!decrypted) {
|
|
42
|
+
throw new Error("failed to decrypt key - check passphrase");
|
|
43
|
+
}
|
|
44
|
+
this._cryptoCache.key = pki.privateKeyToPem(decrypted);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (this._voltConfig.ca_pem) {
|
|
48
|
+
if (!this._cryptoCache.ca) {
|
|
49
|
+
// Copy the volt signing CA to the crypto cache.
|
|
50
|
+
this._cryptoCache.ca = this._voltConfig.ca_pem;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Pre-cache the target Volt public key by extracting it from the signing certificate.
|
|
54
|
+
const voltCert = forge.pki.certificateFromPem(this._voltConfig.ca_pem);
|
|
55
|
+
|
|
56
|
+
// Extract Volt public key (used for encrypting Relay payloads).
|
|
57
|
+
this._voltPublicKey = forge.pki.publicKeyToPem(voltCert.publicKey);
|
|
58
|
+
this._voltFingerprint = voltUtils.createPublicKeyFingerprint(
|
|
59
|
+
this._voltPublicKey,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get config() {
|
|
65
|
+
return this._config;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get cache() {
|
|
69
|
+
return this._cryptoCache;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
get voltFingerprint() {
|
|
73
|
+
return this._voltFingerprint;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
getKey() {
|
|
77
|
+
const privateKey = pki.privateKeyFromPem(this._cryptoCache.key);
|
|
78
|
+
const publicKey = pki.rsa.setPublicKey(privateKey.n, privateKey.e);
|
|
79
|
+
return { privateKey, publicKey };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Retrieves and re-hydrates the pub/priv key data from the crypto cache, or creates one if not found.
|
|
84
|
+
*/
|
|
85
|
+
createKey() {
|
|
86
|
+
if (this._cryptoCache.key) {
|
|
87
|
+
// Already have key data in the cache in pem format => create fully-formed pki instances.
|
|
88
|
+
log("createKey - key already exists");
|
|
89
|
+
return Promise.resolve(this.getKey());
|
|
90
|
+
} else {
|
|
91
|
+
log("creating key");
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
pki.rsa.generateKeyPair({ bits: 2048, workers: -1 }, (err, keypair) => {
|
|
94
|
+
if (err) {
|
|
95
|
+
log("failure creating key [%s]", err.message);
|
|
96
|
+
reject(err);
|
|
97
|
+
} else {
|
|
98
|
+
log("successfully created key");
|
|
99
|
+
const pem = pki.privateKeyToPem(keypair.privateKey);
|
|
100
|
+
this._cryptoCache.key = pem;
|
|
101
|
+
resolve(keypair);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Create cryptocache from scratch.
|
|
110
|
+
*/
|
|
111
|
+
initialise() {
|
|
112
|
+
return this.createKey()
|
|
113
|
+
.then((/* keypair */) => {
|
|
114
|
+
return this.saveCache();
|
|
115
|
+
})
|
|
116
|
+
.then(() => {
|
|
117
|
+
return this._cryptoCache;
|
|
118
|
+
})
|
|
119
|
+
.catch((err) => {
|
|
120
|
+
log("failure initialising crypto [%s]", err.message);
|
|
121
|
+
return Promise.reject(err);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
get isBound() {
|
|
126
|
+
return !!this._cryptoCache.cert;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Signs the identity resource id using the private key.
|
|
131
|
+
* @param {*} audience the token audience (usually the target volt)
|
|
132
|
+
* @param {*} tunnelling flag indicating if the token is required for a tunnelled connection
|
|
133
|
+
* @param {*} ttl time to live in seconds (default to 1 minute)
|
|
134
|
+
*/
|
|
135
|
+
getIdentityToken(audience, tunnelling = false, ttl = 60) {
|
|
136
|
+
if (!this._cryptoCache.key) {
|
|
137
|
+
throw new Error("crypto cache invalid");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const keyPair = this.getKey();
|
|
141
|
+
const publicKeyPem = pki.publicKeyToPem(keyPair.publicKey);
|
|
142
|
+
const base58Key = voltUtils.createPublicKeyFingerprint(publicKeyPem);
|
|
143
|
+
|
|
144
|
+
log("base58 key is %s", base58Key);
|
|
145
|
+
|
|
146
|
+
// Allow TTL either side of the current time.
|
|
147
|
+
// @todo - fix with server time sync on volt connection.
|
|
148
|
+
const payload = {
|
|
149
|
+
aud: audience,
|
|
150
|
+
iat: Math.floor(Date.now() / 1000) - ttl,
|
|
151
|
+
exp: Math.floor(Date.now() / 1000) + ttl,
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
let sharedKey;
|
|
155
|
+
if (tunnelling) {
|
|
156
|
+
// Initialise the Relay encryption key.
|
|
157
|
+
sharedKey = VoltCredential.aesCreateKey();
|
|
158
|
+
|
|
159
|
+
// Encrypt the key details using the target Volt public key and include this in the JWT payload.
|
|
160
|
+
payload.sk = VoltCredential.rsaEncrypt(
|
|
161
|
+
this._voltPublicKey,
|
|
162
|
+
sharedKey.key,
|
|
163
|
+
);
|
|
164
|
+
payload.iv = VoltCredential.rsaEncrypt(this._voltPublicKey, sharedKey.iv);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Sign synchronously.
|
|
168
|
+
const token = jwt.sign(payload, this._cryptoCache.key, {
|
|
169
|
+
algorithm: rs256Algorithm,
|
|
170
|
+
keyid: base58Key,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Return the token along with any encryption key details.
|
|
174
|
+
return {
|
|
175
|
+
token,
|
|
176
|
+
sharedKey,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Creates the metadata required for a grpc call, including the Volt authentication token
|
|
182
|
+
* and optionally tunnel encryption parameters.
|
|
183
|
+
* @param {*} grpc
|
|
184
|
+
* @param {*} audience
|
|
185
|
+
* @param {*} tunnelling
|
|
186
|
+
* @param {*} ttl
|
|
187
|
+
* @returns
|
|
188
|
+
*/
|
|
189
|
+
getIdentityMetadata(grpc, audience, tunnelling = false, ttl = 60) {
|
|
190
|
+
const identityToken = this.getIdentityToken(
|
|
191
|
+
audience || this._voltConfig.id,
|
|
192
|
+
tunnelling,
|
|
193
|
+
ttl,
|
|
194
|
+
);
|
|
195
|
+
const metadata = new grpc.Metadata();
|
|
196
|
+
metadata.add(constants.authTokenName, identityToken.token);
|
|
197
|
+
return { ...identityToken, metadata };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
saveCache() {
|
|
201
|
+
if (this._persistCache) {
|
|
202
|
+
const clone = { ...this._config, credential: { ...this._cryptoCache } };
|
|
203
|
+
if (this._config.p) {
|
|
204
|
+
// A passphrase option exists => encrypt the key before writing the cache file.
|
|
205
|
+
const privateKey = pki.privateKeyFromPem(this._cryptoCache.key);
|
|
206
|
+
clone.credential.key = pki.encryptRsaPrivateKey(
|
|
207
|
+
privateKey,
|
|
208
|
+
this._config.p,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
fs.writeFileSync(this._configPath, JSON.stringify(clone, null, 2));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
static aesCreateKey() {
|
|
216
|
+
// Generate random key for the AES-256 algorithm (32 bytes = 256 bits).
|
|
217
|
+
const key = crypto.randomBytes(32);
|
|
218
|
+
const iv = crypto.randomBytes(16);
|
|
219
|
+
return { key, iv };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
static aesEncrypt(buffer, key, iv) {
|
|
223
|
+
const cipher = crypto.createCipheriv(aesAlgorithm, key, iv);
|
|
224
|
+
const crypted = Buffer.concat([cipher.update(buffer), cipher.final()]);
|
|
225
|
+
return crypted;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
static aesDecrypt(buffer, key, iv) {
|
|
229
|
+
const cipher = crypto.createDecipheriv(aesAlgorithm, key, iv);
|
|
230
|
+
const decrypted = Buffer.concat([cipher.update(buffer), cipher.final()]);
|
|
231
|
+
return decrypted;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
static rsaEncrypt(key, buffer) {
|
|
235
|
+
return crypto.publicEncrypt(key, buffer).toString("base64");
|
|
236
|
+
}
|
|
237
|
+
}
|
package/src/tunnel.pb.js
DELETED
|
File without changes
|