Comparing version 0.8.1 to 0.9.0
@@ -13,6 +13,26 @@ #!/usr/bin/env node | ||
var vals = val.split(/,/g); | ||
vals.map(function (location) { | ||
function parseProxy(location) { | ||
// john.example.com | ||
// https:3443 | ||
// http:john.example.com:3000 | ||
// http://john.example.com:3000 | ||
var parts = location.split(':'); | ||
var dual = false; | ||
if (1 === parts.length) { | ||
// john.example.com -> :john.example.com:0 | ||
parts[1] = parts[0]; | ||
parts[0] = ''; | ||
parts[2] = 0; | ||
dual = true; | ||
} | ||
else if (2 === parts.length) { | ||
// https:3443 -> https:*:3443 | ||
parts[2] = parts[1]; | ||
parts[1] = '*'; | ||
} | ||
parts[0] = parts[0].toLowerCase(); | ||
@@ -23,3 +43,3 @@ parts[1] = parts[1].toLowerCase().replace(/(\/\/)?/, '') || '*'; | ||
// TODO grab OS list of standard ports? | ||
if ('http' === parts[0]) { | ||
if (!parts[0] || 'http' === parts[0]) { | ||
parts[2] = 80; | ||
@@ -35,9 +55,19 @@ } | ||
return { | ||
protocol: parts[0] | ||
memo.push({ | ||
protocol: parts[0] || 'https' | ||
, hostname: parts[1] | ||
, port: parts[2] | ||
}; | ||
}).forEach(function (val) { | ||
memo.push(val); | ||
, port: parts[2] || 443 | ||
}); | ||
if (dual) { | ||
memo.push({ | ||
protocol: 'http' | ||
, hostname: parts[1] | ||
, port: 80 | ||
}); | ||
} | ||
} | ||
vals.map(function (val) { | ||
return parseProxy(val); | ||
}); | ||
@@ -56,40 +86,119 @@ | ||
.option('-k --insecure', 'Allow TLS connections to stunneld without valid certs (rejectUnauthorized: false)') | ||
.option('--locals <LINE>', '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, [ ]) // --reverse-proxies | ||
.option('--locals <LIST>', 'comma separated list of <proto>:<port> to which matching incoming http and https should forward (reverse proxy). Ex: https:8443,smtps:8465', collectProxies, [ ]) // --reverse-proxies | ||
.option('--domains <LIST>', 'comma separated list of domain names to set to the tunnel (to caputer a specific protocol to a specific local port use the format https:example.com:1337 instead). Ex: example.com,example.net', collectProxies, [ ]) | ||
.option('--device [HOSTNAME]', 'Tunnel all domains associated with this device instead of specific domainnames. Use with --locals <proto>:*:<port>. Ex: macbook-pro.local (the output of `hostname`)') | ||
.option('--stunneld <URL>', 'the domain (or ip address) at which you are running stunneld.js (the proxy)') // --proxy | ||
.option('--secret <STRING>', 'the same secret used by stunneld (used for JWT authentication)') | ||
.option('--token <STRING>', 'a pre-generated token for use with stunneld (instead of generating one with --secret)') | ||
.option('--agree-tos', 'agree to the Daplie Terms of Service (requires user validation)') | ||
.option('--email <EMAIL>', 'email address (or cloud address) for user validation') | ||
.option('--oauth3-url <URL>', 'Cloud Authentication to use (default: https://oauth3.org)') | ||
.parse(process.argv) | ||
; | ||
program.stunneld = program.stunneld || 'wss://pokemap.hellabit.com:3000'; | ||
function connectTunnel() { | ||
program.net = { | ||
createConnection: function (info, cb) { | ||
// data is the hello packet / first chunk | ||
// info = { data, servername, port, host, remoteFamily, remoteAddress, remotePort } | ||
var net = require('net'); | ||
// socket = { write, push, end, events: [ 'readable', 'data', 'error', 'end' ] }; | ||
var socket = net.createConnection({ port: info.port, host: info.host }, cb); | ||
return socket; | ||
} | ||
}; | ||
var jwt = require('jsonwebtoken'); | ||
var domainsMap = {}; | ||
var tokenData = { | ||
name: null | ||
, domains: null | ||
}; | ||
var location = url.parse(program.stunneld); | ||
program.locals.forEach(function (proxy) { | ||
console.log('[local proxy]', proxy.protocol + '://' + proxy.hostname + ':' + proxy.port); | ||
}); | ||
if (!location.protocol || /\./.test(location.protocol)) { | ||
program.stunneld = 'wss://' + program.stunneld; | ||
location = url.parse(program.stunneld); | ||
stunnel.connect({ | ||
stunneld: program.stunneld | ||
, locals: program.locals | ||
, services: program.services | ||
, net: program.net | ||
, insecure: program.insecure | ||
, token: program.token | ||
}); | ||
} | ||
program.stunneld = location.protocol + '//' + location.hostname + (location.port ? ':' + location.port : ''); | ||
function rawTunnel() { | ||
program.stunneld = program.stunneld || 'wss://tunnel.daplie.com'; | ||
if (!(program.secret || program.token)) { | ||
console.error("You must use --secret or --token with --stunneld"); | ||
process.exit(1); | ||
return; | ||
} | ||
var jwt = require('jsonwebtoken'); | ||
var tokenData = { | ||
domains: null | ||
}; | ||
var location = url.parse(program.stunneld); | ||
if (!location.protocol || /\./.test(location.protocol)) { | ||
program.stunneld = 'wss://' + program.stunneld; | ||
location = url.parse(program.stunneld); | ||
} | ||
program.stunneld = location.protocol + '//' + location.hostname + (location.port ? ':' + location.port : ''); | ||
tokenData.domains = Object.keys(domainsMap).filter(Boolean); | ||
program.token = program.token || jwt.sign(tokenData, program.secret); | ||
connectTunnel(); | ||
} | ||
function daplieTunnel() { | ||
//var OAUTH3 = require('oauth3.js'); | ||
var Oauth3Cli = require('oauth3.js/bin/oauth3.js'); | ||
require('oauth3.js/oauth3.tunnel.js'); | ||
return Oauth3Cli.login({ | ||
email: program.email | ||
, providerUri: program.oauth3Url | ||
}).then(function (oauth3) { | ||
var data = { device: null, domains: [] }; | ||
var domains = Object.keys(domainsMap).filter(Boolean); | ||
if (program.device) { | ||
// TODO use device API to select device by id | ||
data.device = { hostname: program.device }; | ||
if (true === program.device) { | ||
data.device.hostname = require('os').hostname(); | ||
console.log("Using device hostname '" + data.device.hostname + "'"); | ||
} | ||
} | ||
if (domains.length) { | ||
data.domains = domains; | ||
} | ||
return oauth3.api('tunnel.token', { data: data }).then(function (results) { | ||
var token = new Buffer(results.jwt.split('.')[1], 'base64').toString('utf8'); | ||
console.log('tunnel token issued:'); | ||
console.log(token); | ||
program.token = results.jwt; | ||
program.stunneld = results.tunnelUrl || ('wss://' + token.aud + '/'); | ||
connectTunnel(); | ||
}); | ||
}); | ||
} | ||
var domainsMap = {}; | ||
program.locals = program.locals.concat(program.domains); | ||
program.locals.forEach(function (proxy) { | ||
domainsMap[proxy.hostname] = true; | ||
}); | ||
tokenData.domains = Object.keys(domainsMap); | ||
tokenData.name = tokenData.domains[0]; | ||
if (domainsMap.hasOwnProperty('*')) { | ||
//delete domainsMap['*']; | ||
domainsMap['*'] = false; | ||
} | ||
program.services = {}; | ||
program.locals.forEach(function (proxy) { | ||
//program.services = { 'ssh': 22, 'http': 80, 'https': 443 }; | ||
program.services[proxy.protocol] = proxy.port; | ||
}); | ||
program.token = program.token || jwt.sign(tokenData, program.secret || 'shhhhh'); | ||
if (!(program.secret || program.token) && !program.stunneld) { | ||
daplieTunnel(); | ||
} | ||
else { | ||
rawTunnel(); | ||
} | ||
stunnel.connect(program); | ||
}()); |
@@ -12,3 +12,3 @@ 'use strict'; | ||
var services = { 'ssh': 22, 'http': 4080, 'https': 8443 }; | ||
var hostname = 'aj.daplie.me'; // 'pokemap.hellabit.com' | ||
var hostname = 'aj.daplie.me'; // 'test.hellabit.com' | ||
@@ -15,0 +15,0 @@ function addrToId(address) { |
{ | ||
"name": "stunnel", | ||
"version": "0.8.1", | ||
"version": "0.9.0", | ||
"description": "A pure-JavaScript tunnel client for http and https similar to localtunnel.me, but uses TLS (SSL) with ServerName Indication (SNI) over https to work even in harsh network conditions such as in student dorms and behind HOAs, corporate firewalls, public libraries, airports, airplanes, etc. Can also tunnel tls and plain tcp.", | ||
@@ -16,3 +16,3 @@ "main": "wsclient.js", | ||
"type": "git", | ||
"url": "git+ssh://git@github.com/Daplie/node-tunnel-client.git" | ||
"url": "git+ssh://git@git.daplie.com/Daplie/node-tunnel-client.git" | ||
}, | ||
@@ -46,12 +46,13 @@ "keywords": [ | ||
"bugs": { | ||
"url": "https://github.com/Daplie/node-tunnel-client/issues" | ||
"url": "https://git.daplie.com/Daplie/node-tunnel-client/issues" | ||
}, | ||
"homepage": "https://github.com/Daplie/node-tunnel-client#readme", | ||
"homepage": "https://git.daplie.com/Daplie/node-tunnel-client#readme", | ||
"dependencies": { | ||
"commander": "^2.9.0", | ||
"oauth3.js": "git+https://git.daplie.com:OAuth3/oauth3.js.git#v1", | ||
"jsonwebtoken": "^7.1.9", | ||
"sni": "^1.0.0", | ||
"tunnel-packer": "^1.0.0", | ||
"tunnel-packer": "^1.1.0", | ||
"ws": "^1.1.1" | ||
} | ||
} |
163
README.md
@@ -0,1 +1,16 @@ | ||
<!-- BANNER_TPL_BEGIN --> | ||
About Daplie: We're taking back the Internet! | ||
-------------- | ||
Down with Google, Apple, and Facebook! | ||
We're re-decentralizing the web and making it read-write again - one home cloud system at a time. | ||
Tired of serving the Empire? Come join the Rebel Alliance: | ||
<a href="mailto:jobs@daplie.com">jobs@daplie.com</a> | [Invest in Daplie on Wefunder](https://daplie.com/invest/) | [Pre-order Cloud](https://daplie.com/preorder/), The World's First Home Server for Everyone | ||
<!-- BANNER_TPL_END --> | ||
# stunnel.js | ||
@@ -6,2 +21,5 @@ | ||
* CLI | ||
* Library | ||
CLI | ||
@@ -19,11 +37,52 @@ === | ||
### Advanced Usage | ||
### Usage with OAuth3.org | ||
Daplie's OAuth3.org tunnel service is in Beta. | ||
**Terms of Service**: The Software and Services shall be used for Good, not Evil. | ||
Examples of good: education, business, pleasure. Examples of evil: crime, abuse, extortion. | ||
```bash | ||
stunnel.js --agree-tos --email john@example.com --locals http:*:4080,https:*:8443 --device | ||
``` | ||
```bash | ||
stunnel.js \ | ||
--agree-tos --email <EMAIL> \ | ||
--locals <List of <SCHEME>:<EXTERNAL_DOMAINNAME>:<INTERNAL_PORT>> \ | ||
--device [HOSTNAME] \ | ||
--domains [Comma-separated list of domains to attach to device] \ | ||
--oauth3-url <Tunnel Service OAuth3 URL> | ||
``` | ||
### Advanced Usage (DIY) | ||
How to use `stunnel.js` with your own instance of `stunneld.js`: | ||
```bash | ||
stunnel.js --locals http:john.example.com:3000,https:john.example.com --stunneld https://tunnel.example.com:443 --secret abc123 | ||
stunnel.js \ | ||
--locals <<external domain name>> \ | ||
--stunneld wss://<<tunnel domain>>:<<tunnel port>> \ | ||
--secret <<128-bit hex key>> | ||
``` | ||
```bash | ||
stunnel.js --locals john.example.com --stunneld wss://tunnel.example.com:443 --secret abc123 | ||
``` | ||
```bash | ||
stunnel.js \ | ||
--locals <<protocol>>:<<external domain name>>:<<local port>> \ | ||
--stunneld wss://<<tunnel domain>>:<<tunnel port>> \ | ||
--secret <<128-bit hex key>> | ||
``` | ||
```bash | ||
stunnel.js \ | ||
--locals http:john.example.com:3000,https:john.example.com \ | ||
--stunneld wss://tunnel.example.com:443 \ | ||
--secret abc123 | ||
``` | ||
``` | ||
--secret the same secret used by stunneld (used for authentication) | ||
@@ -36,13 +95,99 @@ --locals comma separated list of <proto>:<servername>:<port> to which | ||
### Usage | ||
Library | ||
======= | ||
**NOT YET IMPLEMENTED** | ||
### Example | ||
Daplie's tunneling service is not yet publicly available. | ||
```javascript | ||
var stunnel = require('stunnel'); | ||
**Terms of Service**: The Software and Services shall be used for Good, not Evil. | ||
Examples of good: education, business, pleasure. Examples of evil: crime, abuse, extortion. | ||
stunnel.connect({ | ||
stunneld: 'wss://tunnel.example.com' | ||
, token: '...' | ||
, locals: [ | ||
// defaults to sending http to local port 80 and https to local port 443 | ||
{ hostname: 'doe.net' } | ||
```bash | ||
stunnel.js --agree-tos --email john@example.com --locals http:john.example.com:4080,https:john.example.com:8443 | ||
// sends both http and https to local port 3000 (httpolyglot) | ||
, { protocol: 'https', hostname: 'john.doe.net', port: 3000 } | ||
// send http to local port 4080 and https to local port 8443 | ||
, { protocol: 'https', hostname: 'jane.doe.net', port: 4080 } | ||
, { protocol: 'https', hostname: 'jane.doe.net', port: 8443 } | ||
] | ||
, net: require('net') | ||
, insecure: false | ||
}); | ||
``` | ||
* You can get sneaky with `net` and provide a `createConnection` that returns a `stream.Duplex`. | ||
### Token | ||
```javascript | ||
var tokenData = { domains: [ 'doe.net', 'john.doe.net', 'jane.doe.net' ] } | ||
var secret = 'shhhhh'; | ||
var token = jwt.sign(tokenData, secret); | ||
``` | ||
### net | ||
Let's say you want to handle http requests in-process | ||
or decrypt https before passing it to the local http handler. | ||
You'll need to create a pair of streams to connect between the | ||
local handler and the tunnel handler. | ||
You could do a little magic like this: | ||
```js | ||
var Dup = { | ||
write: function (chunk, encoding, cb) { | ||
this.__my_socket.write(chunk, encoding); | ||
cb(); | ||
} | ||
, read: function (size) { | ||
var x = this.__my_socket.read(size); | ||
if (x) { this.push(x); } | ||
} | ||
}; | ||
stunnel.connect({ | ||
// ... | ||
, net: { | ||
createConnection: function (info, cb) { | ||
// data is the hello packet / first chunk | ||
// info = { data, servername, port, host, remoteAddress: { family, address, port } } | ||
var myDuplex = new (require('stream').Duplex)(); | ||
var myDuplex2 = new (require('stream').Duplex)(); | ||
// duplex = { write, push, end, events: [ 'readable', 'data', 'error', 'end' ] }; | ||
myDuplex2.__my_socket = myDuplex; | ||
myDuplex2._write = Dup.write; | ||
myDuplex2._read = Dup.read; | ||
myDuplex.__my_socket = myDuplex2; | ||
myDuplex._write = Dup.write; | ||
myDuplex._read = Dup.read; | ||
myDuplex.remoteFamily = info.remoteFamily; | ||
myDuplex.remoteAddress = info.remoteAddress; | ||
myDuplex.remotePort = info.remotePort; | ||
// socket.local{Family,Address,Port} | ||
myDuplex.localFamily = 'IPv4'; | ||
myDuplex.localAddress = '127.0.01'; | ||
myDuplex.localPort = info.port; | ||
httpsServer.emit('connection', myDuplex); | ||
if (cb) { | ||
process.nextTick(cb); | ||
} | ||
return myDuplex2; | ||
} | ||
}); | ||
``` |
140
wsclient.js
(function () { | ||
'use strict'; | ||
var net = require('net'); | ||
var WebSocket = require('ws'); | ||
var sni = require('sni'); | ||
var pack = require('tunnel-packer').pack; | ||
var Packer = require('tunnel-packer'); | ||
var authenticated = false; | ||
// TODO move these helpers to tunnel-packer package | ||
function addrToId(address) { | ||
return address.family + ',' + address.address + ',' + address.port; | ||
} | ||
function run(copts) { | ||
// TODO pair with hostname / sni | ||
copts.services = {}; | ||
copts.locals.forEach(function (proxy) { | ||
//program.services = { 'ssh': 22, 'http': 80, 'https': 443 }; | ||
copts.services[proxy.protocol] = proxy.port; | ||
}); | ||
/* | ||
function socketToAddr(socket) { | ||
return { family: socket.remoteFamily, address: socket.remoteAddress, port: socket.remotePort }; | ||
} | ||
function socketToId(socket) { | ||
return addrToId(socketToAddr(socket)); | ||
} | ||
*/ | ||
/* | ||
var request = require('request'); | ||
request.get('https://pokemap.hellabit.com:3000?access_token=' + token, { rejectUnauthorized: false }, function (err, resp) { | ||
console.log('resp.body'); | ||
console.log(resp.body); | ||
}); | ||
return; | ||
//*/ | ||
function run(copts) { | ||
var services = copts.services; // TODO pair with hostname / sni | ||
var token = copts.token; | ||
var tunnelUrl = copts.stunneld + '/?access_token=' + token; | ||
var tunnelUrl = copts.stunneld.replace(/\/$/, '') + '/?access_token=' + copts.token; | ||
var wstunneler; | ||
var retry = true; | ||
var localclients = {}; | ||
@@ -50,5 +27,6 @@ // BaaS / Backendless / noBackend / horizon.io | ||
onmessage: function (opts) { | ||
var cid = addrToId(opts); | ||
var net = copts.net || require('net'); | ||
var cid = Packer.addrToId(opts); | ||
var service = opts.service; | ||
var port = services[service]; | ||
var port = copts.services[service]; | ||
var servername; | ||
@@ -81,3 +59,3 @@ var str; | ||
//console.warn(opts.data.toString()); | ||
wstunneler.send(pack(opts, null, 'error'), { binary: true }); | ||
wstunneler.send(Packer.pack(opts, null, 'error'), { binary: true }); | ||
return; | ||
@@ -88,11 +66,49 @@ } | ||
localclients[cid] = net.createConnection({ port: port, host: '127.0.0.1' }, function () { | ||
console.log('port', port, opts.port, service, copts.services); | ||
localclients[cid] = net.createConnection({ | ||
port: port | ||
, host: '127.0.0.1' | ||
, servername: servername | ||
, data: opts.data | ||
, remoteFamily: opts.family | ||
, remoteAddress: opts.address | ||
, remotePort: opts.port | ||
}, function () { | ||
//console.log("[=>] first packet from tunneler to '" + cid + "' as '" + opts.service + "'", opts.data.byteLength); | ||
// this will happen before 'data' is triggered | ||
localclients[cid].write(opts.data); | ||
}); | ||
// 'data' | ||
/* | ||
localclients[cid].on('data', function (chunk) { | ||
//console.log("[<=] local '" + opts.service + "' sent to '" + cid + "' <= ", chunk.byteLength, "bytes"); | ||
//console.log(JSON.stringify(chunk.toString())); | ||
wstunneler.send(pack(opts, chunk), { binary: true }); | ||
wstunneler.send(Packer.pack(opts, chunk), { binary: true }); | ||
}); | ||
//*/ | ||
///* | ||
localclients[cid].on('readable', function (size) { | ||
var chunk; | ||
if (!localclients[cid]) { | ||
console.error("[error] localclients[cid]", cid); | ||
return; | ||
} | ||
if (!localclients[cid].read) { | ||
console.error("[error] localclients[cid].read", cid); | ||
console.log(localclients[cid]); | ||
return; | ||
} | ||
do { | ||
chunk = localclients[cid].read(size); | ||
//console.log("[<=] local '" + opts.service + "' sent to '" + cid + "' <= ", chunk.byteLength, "bytes"); | ||
//console.log(JSON.stringify(chunk.toString())); | ||
if (chunk) { | ||
wstunneler.send(Packer.pack(opts, chunk), { binary: true }); | ||
} | ||
} while (chunk); | ||
}); | ||
//*/ | ||
localclients[cid].on('error', function (err) { | ||
@@ -107,3 +123,3 @@ handlers._onLocalError(cid, opts, err); | ||
, onend: function (opts) { | ||
var cid = addrToId(opts); | ||
var cid = Packer.addrToId(opts); | ||
//console.log("[end] '" + cid + "'"); | ||
@@ -113,3 +129,3 @@ handlers._onend(cid); | ||
, onerror: function (opts) { | ||
var cid = addrToId(opts); | ||
var cid = Packer.addrToId(opts); | ||
//console.log("[error] '" + cid + "'", opts.code || '', opts.message); | ||
@@ -119,2 +135,3 @@ handlers._onend(cid); | ||
, _onend: function (cid) { | ||
console.log('[_onend]'); | ||
if (localclients[cid]) { | ||
@@ -130,4 +147,5 @@ try { | ||
, _onLocalClose: function (cid, opts, err) { | ||
console.log('[_onLocalClose]'); | ||
try { | ||
wstunneler.send(pack(opts, null, err && 'error' || 'end'), { binary: true }); | ||
wstunneler.send(Packer.pack(opts, null, err && 'error' || 'end'), { binary: true }); | ||
} catch(e) { | ||
@@ -151,7 +169,21 @@ // ignore | ||
, retry: true | ||
, closeClients: function () { | ||
console.log('[close clients]'); | ||
Object.keys(localclients).forEach(function (cid) { | ||
try { | ||
localclients[cid].end(); | ||
} catch(e) { | ||
// ignore | ||
} | ||
delete localclients[cid]; | ||
}); | ||
} | ||
, onClose: function () { | ||
console.log('ON CLOSE'); | ||
if (!authenticated) { | ||
console.info('[close] failed on first attempt... check authentication.'); | ||
} | ||
else if (retry) { | ||
else if (wsHandlers.retry) { | ||
console.info('[retry] disconnected and waiting...'); | ||
@@ -166,10 +198,3 @@ setTimeout(run, 5000, copts); | ||
process.removeListener('SIGINT', wsHandlers.onExit); | ||
Object.keys(localclients).forEach(function (cid) { | ||
try { | ||
localclients[cid].end(); | ||
} catch(e) { | ||
// ignore | ||
} | ||
delete localclients[cid]; | ||
}); | ||
wsHandlers.closeClients(); | ||
} | ||
@@ -183,3 +208,5 @@ | ||
, onExit: function () { | ||
retry = false; | ||
console.log('[wait] closing wstunneler...'); | ||
wsHandlers.retry = false; | ||
wsHandlers.closeClients(); | ||
try { | ||
@@ -209,4 +236,13 @@ wstunneler.close(); | ||
wstunneler.on('error', wsHandlers.onError); | ||
process.on('exit', wsHandlers.onExit); | ||
process.on('SIGINT', wsHandlers.onExit); | ||
process.on('beforeExit', function (x) { | ||
console.log('[beforeExit] event loop closing?', x); | ||
}); | ||
process.on('exit', function (x) { | ||
console.log('[exit] loop closed', x); | ||
//wsHandlers.onExit(x); | ||
}); | ||
process.on('SIGINT', function (x) { | ||
console.log('SIGINT'); | ||
wsHandlers.onExit(x); | ||
}); | ||
} | ||
@@ -213,0 +249,0 @@ |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Git dependency
Supply chain riskContains a dependency which resolves to a remote git URL. Dependencies fetched from git URLs are not immutable can be used to inject untrusted code or reduce the likelihood of a reproducible install.
Found 1 instance in 1 package
No bug tracker
MaintenancePackage does not have a linked bug tracker in package.json.
Found 1 instance in 1 package
No website
QualityPackage does not have a website.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
No bug tracker
MaintenancePackage does not have a linked bug tracker in package.json.
Found 1 instance in 1 package
No website
QualityPackage does not have a website.
Found 1 instance in 1 package
36463
10
507
191
6
1
3