commit
5503714b53
121
bin/stunneld.js
121
bin/stunneld.js
|
@ -6,6 +6,15 @@ var pkg = require('../package.json');
|
|||
|
||||
var program = require('commander');
|
||||
var stunneld = require('../wstunneld.js');
|
||||
var greenlock = require('greenlock');
|
||||
|
||||
function collectServernames(val, memo) {
|
||||
val.split(/,/).forEach(function (servername) {
|
||||
memo.push(servername.toLowerCase());
|
||||
});
|
||||
|
||||
return memo;
|
||||
}
|
||||
|
||||
function collectProxies(val, memo) {
|
||||
var vals = val.split(/,/g);
|
||||
|
@ -62,16 +71,15 @@ function collectPorts(val, memo) {
|
|||
|
||||
program
|
||||
.version(pkg.version)
|
||||
.option('--agree-tos', "Accept the Daplie and Let's Encrypt Terms of Service")
|
||||
.option('--email <EMAIL>', "Email to use for Daplie and Let's Encrypt accounts")
|
||||
.option('--serve <URL>', 'comma separated list of <proto>:<//><servername>:<port> to which matching incoming http and https should forward (reverse proxy). Ex: https://john.example.com,tls:*:1337', collectProxies, [ ])
|
||||
.option('--ports <PORT>', 'comma separated list of ports on which to listen. Ex: 80,443,1337', collectPorts, [ ])
|
||||
.option('--servernames <STRING>', 'comma separated list of servernames to use for the admin interface. Ex: tunnel.example.com,tunnel.example.net', collectServernames, [ ])
|
||||
.option('--secret <STRING>', 'the same secret used by stunneld (used for JWT authentication)')
|
||||
.parse(process.argv)
|
||||
;
|
||||
|
||||
if (!program.serve.length) {
|
||||
throw new Error("must specify at least one server");
|
||||
}
|
||||
|
||||
var portsMap = {};
|
||||
var servernamesMap = {};
|
||||
program.serve.forEach(function (proxy) {
|
||||
|
@ -80,27 +88,108 @@ program.serve.forEach(function (proxy) {
|
|||
portsMap[proxy.port] = true;
|
||||
}
|
||||
});
|
||||
program.servernames.forEach(function (name) {
|
||||
servernamesMap[name] = true;
|
||||
});
|
||||
program.ports.forEach(function (port) {
|
||||
portsMap[port] = true;
|
||||
});
|
||||
|
||||
var opts = {};
|
||||
opts.servernames = Object.keys(servernamesMap);
|
||||
opts.ports = Object.keys(portsMap);
|
||||
if (!opts.ports.length) {
|
||||
opts.ports = [ 80, 443 ];
|
||||
program.servernames = Object.keys(servernamesMap);
|
||||
if (!program.servernames.length) {
|
||||
throw new Error('must specify at least one server or servername');
|
||||
}
|
||||
|
||||
if (program.secret) {
|
||||
opts.secret = program.secret;
|
||||
} else {
|
||||
program.ports = Object.keys(portsMap);
|
||||
if (!opts.ports.length) {
|
||||
program.ports = [ 80, 443 ];
|
||||
}
|
||||
|
||||
if (!program.secret) {
|
||||
// TODO randomly generate and store in file?
|
||||
console.warn("[SECURITY] using default --secret 'shhhhh'");
|
||||
opts.secret = 'shhhhh';
|
||||
console.warn("[SECURITY] you must provide --secret '" + require('crypto').randomBytes(16).toString('hex') + "'");
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO letsencrypt
|
||||
opts.tlsOptions = require('localhost.daplie.com-certificates').merge({});
|
||||
program.tlsOptions = require('localhost.daplie.com-certificates').merge({});
|
||||
|
||||
function approveDomains(opts, certs, cb) {
|
||||
// This is where you check your database and associated
|
||||
// email addresses with domains and agreements and such
|
||||
|
||||
// The domains being approved for the first time are listed in opts.domains
|
||||
// Certs being renewed are listed in certs.altnames
|
||||
if (certs) {
|
||||
opts.domains = certs.altnames;
|
||||
}
|
||||
else {
|
||||
if (-1 !== program.servernames.indexOf(opts.domain)) {
|
||||
opts.email = program.email;
|
||||
opts.agreeTos = program.agreeTos;
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: you can also change other options such as `challengeType` and `challenge`
|
||||
// opts.challengeType = 'http-01';
|
||||
// opts.challenge = require('le-challenge-fs').create({});
|
||||
|
||||
cb(null, { options: opts, certs: certs });
|
||||
}
|
||||
|
||||
if (!program.email || !program.agreeTos) {
|
||||
console.error("You didn't specify --email <EMAIL> and --agree-tos");
|
||||
console.error("(required for ACME / Let's Encrypt / Greenlock TLS/SSL certs)");
|
||||
console.error("");
|
||||
}
|
||||
else {
|
||||
program.greenlock = greenlock.create({
|
||||
|
||||
//server: 'staging'
|
||||
server: 'https://acme-v01.api.letsencrypt.org/directory'
|
||||
|
||||
, challenges: {
|
||||
// TODO dns-01
|
||||
'http-01': require('le-challenge-fs').create({ webrootPath: '/tmp/acme-challenges' })
|
||||
}
|
||||
|
||||
, store: require('le-store-certbot').create({ webrootPath: '/tmp/acme-challenges' })
|
||||
|
||||
, email: program.email
|
||||
|
||||
, agreeTos: program.agreeTos
|
||||
|
||||
, approveDomains: approveDomains
|
||||
|
||||
//, approvedDomains: program.servernames
|
||||
|
||||
});
|
||||
}
|
||||
//program.tlsOptions.SNICallback = program.greenlock.httpsOptions.SNICallback;
|
||||
/*
|
||||
program.middleware = program.greenlock.middleware(function (req, res) {
|
||||
res.end('Hello, World!');
|
||||
});
|
||||
*/
|
||||
|
||||
require('../handlers').create(program); // adds directly to program for now...
|
||||
|
||||
//require('cluster-store').create().then(function (store) {
|
||||
//program.store = store;
|
||||
|
||||
var net = require('net');
|
||||
var netConnHandlers = stunneld.create(program); // { tcp, ws }
|
||||
var WebSocketServer = require('ws').Server;
|
||||
var wss = new WebSocketServer({ server: (program.httpTunnelServer || program.httpServer) });
|
||||
wss.on('connection', netConnHandlers.ws);
|
||||
program.ports.forEach(function (port) {
|
||||
var tcp3000 = net.createServer();
|
||||
tcp3000.listen(port, function () {
|
||||
console.log('listening on ' + port);
|
||||
});
|
||||
tcp3000.on('connection', netConnHandlers.tcp);
|
||||
});
|
||||
//});
|
||||
|
||||
stunneld.create(opts);
|
||||
}());
|
||||
|
|
|
@ -0,0 +1,114 @@
|
|||
'use strict';
|
||||
|
||||
var http = require('http');
|
||||
var tls = require('tls');
|
||||
var packerStream = require('tunnel-packer').Stream;
|
||||
var redirectHttps = require('redirect-https')();
|
||||
|
||||
module.exports.create = function (program) {
|
||||
var tunnelAdminTlsOpts = {};
|
||||
|
||||
// Probably a reverse proxy on an internal network (or ACME challenge)
|
||||
function notFound(req, res) {
|
||||
console.log('req.socket.encrypted', req.socket.encrypted);
|
||||
res.statusCode = 404;
|
||||
res.end("File not found.\n");
|
||||
}
|
||||
program.httpServer = http.createServer(
|
||||
program.greenlock && program.greenlock.middleware(notFound)
|
||||
|| notFound
|
||||
);
|
||||
program.handleHttp = function (servername, socket) {
|
||||
console.log("handleHttp('" + servername + "', socket)");
|
||||
socket.__my_servername = servername;
|
||||
program.httpServer.emit('connection', socket);
|
||||
};
|
||||
|
||||
// Probably something that needs to be redirected to https
|
||||
function redirectHttpsAndClose(req, res) {
|
||||
res.setHeader('Connection', 'close');
|
||||
redirectHttps(req, res);
|
||||
}
|
||||
program.httpInsecureServer = http.createServer(
|
||||
program.greenlock && program.greenlock.middleware(redirectHttpsAndClose)
|
||||
|| redirectHttpsAndClose
|
||||
);
|
||||
program.handleInsecureHttp = function (servername, socket) {
|
||||
console.log("handleInsecureHttp('" + servername + "', socket)");
|
||||
socket.__my_servername = servername;
|
||||
program.httpInsecureServer.emit('connection', socket);
|
||||
};
|
||||
|
||||
|
||||
//
|
||||
// SNI is not recogonized / cannot be handled
|
||||
//
|
||||
program.httpInvalidSniServer = http.createServer(function (req, res) {
|
||||
res.end("You're doing strange things that make me feel uncomfortable. Please don't touch me there any more.");
|
||||
});
|
||||
program.tlsInvalidSniServer = tls.createServer(program.tlsOptions, function (tlsSocket) {
|
||||
console.log('tls connection');
|
||||
// things get a little messed up here
|
||||
program.httpInvalidSniServer.emit('connection', tlsSocket);
|
||||
});
|
||||
program.httpsInvalid = function (servername, socket) {
|
||||
// none of these methods work:
|
||||
// httpsServer.emit('connection', socket); // this didn't work
|
||||
// tlsServer.emit('connection', socket); // this didn't work either
|
||||
//console.log('chunkLen', firstChunk.byteLength);
|
||||
|
||||
var myDuplex = packerStream.create(socket);
|
||||
|
||||
console.log('httpsInvalid servername', servername);
|
||||
program.tlsInvalidSniServer.emit('connection', myDuplex);
|
||||
|
||||
socket.on('data', function (chunk) {
|
||||
console.log('[' + Date.now() + '] socket data', chunk.byteLength);
|
||||
myDuplex.push(chunk);
|
||||
});
|
||||
socket.on('error', function (err) {
|
||||
console.error('[error] httpsInvalid TODO close');
|
||||
console.error(err);
|
||||
});
|
||||
};
|
||||
|
||||
//
|
||||
// To ADMIN / CONTROL PANEL of the Tunnel Server Itself
|
||||
//
|
||||
program.httpTunnelServer = http.createServer(function (req, res) {
|
||||
console.log('req.socket.encrypted', req.socket.encrypted);
|
||||
res.end('Hello, World!');
|
||||
});
|
||||
Object.keys(program.tlsOptions).forEach(function (key) {
|
||||
tunnelAdminTlsOpts[key] = program.tlsOptions[key];
|
||||
});
|
||||
tunnelAdminTlsOpts.SNICallback = (program.greenlock && program.greenlock.httpsOptions && function (servername, cb) {
|
||||
console.log("time to handle '" + servername + "'");
|
||||
program.greenlock.httpsOptions.SNICallback(servername, cb);
|
||||
}) || tunnelAdminTlsOpts.SNICallback;
|
||||
program.tlsTunnelServer = tls.createServer(tunnelAdminTlsOpts, function (tlsSocket) {
|
||||
console.log('tls connection');
|
||||
// things get a little messed up here
|
||||
(program.httpTunnelServer || program.httpServer).emit('connection', tlsSocket);
|
||||
});
|
||||
program.httpsTunnel = function (servername, socket) {
|
||||
// none of these methods work:
|
||||
// httpsServer.emit('connection', socket); // this didn't work
|
||||
// tlsServer.emit('connection', socket); // this didn't work either
|
||||
//console.log('chunkLen', firstChunk.byteLength);
|
||||
|
||||
var myDuplex = packerStream.create(socket);
|
||||
|
||||
console.log('httpsTunnel (Admin) servername', servername);
|
||||
program.tlsTunnelServer.emit('connection', myDuplex);
|
||||
|
||||
socket.on('data', function (chunk) {
|
||||
console.log('[' + Date.now() + '] socket data', chunk.byteLength);
|
||||
myDuplex.push(chunk);
|
||||
});
|
||||
socket.on('error', function (err) {
|
||||
console.error('[error] httpsTunnel (Admin) TODO close');
|
||||
console.error(err);
|
||||
});
|
||||
};
|
||||
};
|
|
@ -48,6 +48,7 @@
|
|||
"dependencies": {
|
||||
"cluster-store": "^2.0.4",
|
||||
"commander": "^2.9.0",
|
||||
"greenlock": "^2.1.12",
|
||||
"jsonwebtoken": "^7.1.9",
|
||||
"localhost.daplie.com-certificates": "^1.2.3",
|
||||
"redirect-https": "^1.1.0",
|
||||
|
|
208
wstunneld.js
208
wstunneld.js
|
@ -1,23 +1,86 @@
|
|||
'use strict';
|
||||
|
||||
var net = require('net');
|
||||
var tls = require('tls');
|
||||
var http = require('http');
|
||||
var sni = require('sni');
|
||||
var url = require('url');
|
||||
var jwt = require('jsonwebtoken');
|
||||
var packer = require('tunnel-packer');
|
||||
var WebSocketServer = require('ws').Server;
|
||||
|
||||
var Devices = {};
|
||||
Devices.replace = function (store, servername, newDevice) {
|
||||
var devices = Devices.list(store, servername);
|
||||
var oldDevice;
|
||||
if (!devices.some(function (device, i) {
|
||||
if ((device.deviceId && device.deviceId === newDevice.deviceId)
|
||||
|| (device.servername && device.servername === newDevice.servername)) {
|
||||
oldDevice = devices[i];
|
||||
devices[i] = newDevice;
|
||||
return true;
|
||||
}
|
||||
})) {
|
||||
devices.push(newDevice);
|
||||
store[servername] = devices;
|
||||
}
|
||||
return oldDevice;
|
||||
};
|
||||
Devices.remove = function (store, servername, newDevice) {
|
||||
var devices = Devices.list(store, servername);
|
||||
var oldDevice;
|
||||
devices.some(function (device, i) {
|
||||
if ((device.deviceId && device.deviceId === newDevice.deviceId)
|
||||
|| (device.servername && device.servername === newDevice.servername)) {
|
||||
oldDevice = devices.splice(i, 1);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
return oldDevice;
|
||||
};
|
||||
Devices.list = function (store, servername) {
|
||||
return store[servername] || [];
|
||||
};
|
||||
Devices.exist = function (store, servername) {
|
||||
return (store[servername] || []).length;
|
||||
};
|
||||
Devices.next = function (store, servername) {
|
||||
var devices = Devices.list(store, servername);
|
||||
var device;
|
||||
|
||||
if (devices._index >= devices.length) {
|
||||
devices._index = 0;
|
||||
}
|
||||
device = devices[devices._index || 0];
|
||||
devices._index = (devices._index || 0) + 1;
|
||||
|
||||
return device;
|
||||
};
|
||||
|
||||
module.exports.store = { Devices: Devices };
|
||||
module.exports.create = function (copts) {
|
||||
var deviceLists = {};
|
||||
|
||||
function onWsConnection(ws) {
|
||||
var location = url.parse(ws.upgradeReq.url, true);
|
||||
//var token = jwt.decode(location.query.access_token);
|
||||
var authn = (ws.upgradeReq.headers.authorization||'').split(/\s+/);
|
||||
var jwtoken;
|
||||
var token;
|
||||
|
||||
try {
|
||||
token = jwt.verify(location.query.access_token, secret);
|
||||
if (authn[0]) {
|
||||
if ('basic' === authn[0].toLowerCase()) {
|
||||
authn = new Buffer(authn[1], 'base64').toString('ascii').split(':');
|
||||
}
|
||||
/*
|
||||
if (-1 !== [ 'bearer', 'jwk' ].indexOf(authn[0].toLowerCase())) {
|
||||
jwtoken = authn[1];
|
||||
}
|
||||
*/
|
||||
}
|
||||
jwtoken = authn[1] || location.query.access_token;
|
||||
} catch(e) {
|
||||
jwtoken = null;
|
||||
}
|
||||
|
||||
try {
|
||||
token = jwt.verify(jwtoken, copts.secret);
|
||||
} catch(e) {
|
||||
token = null;
|
||||
}
|
||||
|
@ -36,6 +99,8 @@ module.exports.create = function (copts) {
|
|||
return;
|
||||
}
|
||||
|
||||
//console.log('[wstunneld.js] DEBUG', token);
|
||||
|
||||
if (!Array.isArray(token.domains)) {
|
||||
if ('string' === typeof token.name) {
|
||||
token.domains = [ token.name ];
|
||||
|
@ -50,13 +115,13 @@ module.exports.create = function (copts) {
|
|||
|
||||
var remote;
|
||||
token.domains.some(function (domainname) {
|
||||
remote = remotes[domainname];
|
||||
remote = Devices.next(deviceLists, domainname);
|
||||
return remote;
|
||||
});
|
||||
remote = remote || {};
|
||||
token.domains.forEach(function (domainname) {
|
||||
console.log('domainname', domainname);
|
||||
remotes[domainname] = remote;
|
||||
Devices.replace(deviceLists, domainname, remote);
|
||||
});
|
||||
var handlers = {
|
||||
onmessage: function (opts) {
|
||||
|
@ -100,7 +165,8 @@ module.exports.create = function (copts) {
|
|||
};
|
||||
// TODO allow more than one remote per servername
|
||||
remote.ws = ws;
|
||||
remote.servername = token.domains.join(',');
|
||||
remote.servername = (token.device && token.device.hostname) || token.domains.join(',');
|
||||
remote.deviceId = (token.device && token.device.id) || null;
|
||||
remote.id = packer.socketToId(ws.upgradeReq.socket);
|
||||
console.log("remote.id", remote.id);
|
||||
// TODO allow tls to be decrypted by server if client is actually a browser
|
||||
|
@ -110,12 +176,14 @@ module.exports.create = function (copts) {
|
|||
remote.clients = {};
|
||||
remote.handle = { address: null, handle: null };
|
||||
remote.unpacker = packer.create(handlers);
|
||||
ws.on('message', function (chunk) {
|
||||
remote.domains = token.domains;
|
||||
|
||||
function forwardMessage(chunk) {
|
||||
console.log('message from home cloud to tunneler to browser', chunk.byteLength);
|
||||
//console.log(chunk.toString());
|
||||
remote.unpacker.fns.addChunk(chunk);
|
||||
});
|
||||
ws.on('close', function () {
|
||||
}
|
||||
function hangup() {
|
||||
// the remote will handle closing its local connections
|
||||
Object.keys(remote.clients).forEach(function (cid) {
|
||||
try {
|
||||
|
@ -124,46 +192,23 @@ module.exports.create = function (copts) {
|
|||
// ignore
|
||||
}
|
||||
});
|
||||
});
|
||||
ws.on('error', function () {
|
||||
// ignore
|
||||
// the remote will retry if it wants to
|
||||
});
|
||||
token.domains.forEach(function (domainname) {
|
||||
Devices.remove(deviceLists, domainname, remote);
|
||||
});
|
||||
}
|
||||
function die() {
|
||||
hangup();
|
||||
}
|
||||
|
||||
//store.set(token.name, remote.handle);
|
||||
ws.on('message', forwardMessage);
|
||||
ws.on('close', hangup);
|
||||
ws.on('error', die);
|
||||
}
|
||||
|
||||
function connectHttp(servername, socket) {
|
||||
console.log("connectHttp('" + servername + "', socket)");
|
||||
socket.__my_servername = servername;
|
||||
redirectServer.emit('connection', socket);
|
||||
}
|
||||
|
||||
function connectHttps(servername, socket) {
|
||||
// none of these methods work:
|
||||
// httpsServer.emit('connection', socket); // this didn't work
|
||||
// tlsServer.emit('connection', socket); // this didn't work either
|
||||
//console.log('chunkLen', firstChunk.byteLength);
|
||||
|
||||
var myDuplex = packer.Stream.create(socket);
|
||||
|
||||
console.log('connectHttps servername', servername);
|
||||
tls3000.emit('connection', myDuplex);
|
||||
|
||||
socket.on('data', function (chunk) {
|
||||
console.log('[' + Date.now() + '] socket data', chunk.byteLength);
|
||||
myDuplex.push(chunk);
|
||||
});
|
||||
socket.on('error', function (err) {
|
||||
console.error('[error] connectHttps TODO close');
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
function pipeWs(servername, service, browser, remote) {
|
||||
console.log('pipeWs');
|
||||
|
||||
//var remote = remotes[servername];
|
||||
//var remote = deviceLists[servername];
|
||||
var ws = remote.ws;
|
||||
//var address = packer.socketToAddr(ws.upgradeReq.socket);
|
||||
var baddress = packer.socketToAddr(browser);
|
||||
|
@ -259,14 +304,29 @@ module.exports.create = function (copts) {
|
|||
var m;
|
||||
|
||||
function tryTls() {
|
||||
if (!servername || (-1 !== selfnames.indexOf(servername)) || !remotes[servername]) {
|
||||
console.log('this is a server or an unknown');
|
||||
connectHttps(servername, browser);
|
||||
var nextDevice;
|
||||
|
||||
if (-1 !== copts.servernames.indexOf(servername)) {
|
||||
console.log("Lock and load, admin interface time!");
|
||||
copts.httpsTunnel(servername, browser);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("pipeWs(servername, service, socket, remotes['" + servername + "'])");
|
||||
pipeWs(servername, service, browser, remotes[servername]);
|
||||
if (!servername) {
|
||||
console.log("No SNI was given, so there's nothing we can do here");
|
||||
copts.httpsInvalid(servername, browser);
|
||||
return;
|
||||
}
|
||||
|
||||
nextDevice = Devices.next(deviceLists, servername);
|
||||
if (!nextDevice) {
|
||||
console.log("No devices match the given servername");
|
||||
copts.httpsInvalid(servername, browser);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("pipeWs(servername, service, socket, deviceLists['" + servername + "'])");
|
||||
pipeWs(servername, service, browser, nextDevice);
|
||||
}
|
||||
|
||||
// https://github.com/mscdex/httpolyglot/issues/3#issuecomment-173680155
|
||||
|
@ -286,17 +346,19 @@ module.exports.create = function (copts) {
|
|||
console.log('servername', servername);
|
||||
if (/HTTP\//i.test(str)) {
|
||||
service = 'http';
|
||||
if (/^\/\.well-known\/acme-challenge\//.test(str)) {
|
||||
// TODO disallow http entirely
|
||||
// /^\/\.well-known\/acme-challenge\//.test(str)
|
||||
if (/well-known/.test(str)) {
|
||||
// HTTP
|
||||
if (remotes[servername]) {
|
||||
pipeWs(servername, service, browser, remotes[servername]);
|
||||
if (Devices.exist(deviceLists, servername)) {
|
||||
pipeWs(servername, service, browser, Devices.next(deviceLists, servername));
|
||||
return;
|
||||
}
|
||||
connectHttp(servername, browser);
|
||||
copts.handleHttp(servername, browser);
|
||||
}
|
||||
else {
|
||||
// redirect to https
|
||||
connectHttp(servername, browser);
|
||||
copts.handleInsecureHttp(servername, browser);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
@ -316,37 +378,5 @@ module.exports.create = function (copts) {
|
|||
|
||||
}
|
||||
|
||||
var tlsOpts = copts.tlsOptions;
|
||||
//var store = copts.store;
|
||||
|
||||
var remotes = {};
|
||||
var selfnames = copts.servernames;
|
||||
var secret = copts.secret;
|
||||
var redirectHttps = require('redirect-https')();
|
||||
|
||||
var redirectServer = http.createServer(function (req, res) {
|
||||
res.setHeader('Connection', 'close');
|
||||
redirectHttps(req, res);
|
||||
});
|
||||
var httpServer = http.createServer(function (req, res) {
|
||||
console.log('req.socket.encrypted', req.socket.encrypted);
|
||||
res.end('Hello, World!');
|
||||
});
|
||||
var tls3000 = tls.createServer(tlsOpts, function (tlsSocket) {
|
||||
console.log('tls connection');
|
||||
// things get a little messed up here
|
||||
httpServer.emit('connection', tlsSocket);
|
||||
});
|
||||
var wss = new WebSocketServer({ server: httpServer });
|
||||
|
||||
wss.on('connection', onWsConnection);
|
||||
|
||||
copts.ports.forEach(function (port) {
|
||||
var tcp3000 = net.createServer();
|
||||
tcp3000.listen(port, function () {
|
||||
console.log('listening on ' + port);
|
||||
});
|
||||
tcp3000.on('connection', onTcpConnection);
|
||||
});
|
||||
|
||||
return { tcp: onTcpConnection, ws: onWsConnection };
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue