Compare commits

..

67 Commits

Author SHA1 Message Date
5a395a299a Merge branch 'feat/snapcraft' of mkg20001/telebit-relay.js into master 2018-11-23 19:39:55 +00:00
Maciej Krüger
eb36af8269 feat: Initial snapcraft configuration 2018-11-10 17:02:37 +01:00
50c0449206 touch device lastSeen on ws pong 2018-11-04 21:22:44 +00:00
d48707d265 set Content-Type html,utf-8 2018-11-04 20:12:38 +00:00
60f85144a9 note length of time 2018-11-04 20:12:31 +00:00
5dfe25ed95 add last seen 2018-11-04 06:36:49 +00:00
c9d6b46f0f update how recently a domain has been active 2018-11-04 01:48:22 +00:00
0a67728239 Show Bad Gateway rather than disconnect when south-end has gone missing. 2018-11-04 01:12:46 +00:00
9e06faa581 fix closing of device 2018-08-21 03:12:53 +00:00
8be4d35698 remove leftovers 2018-08-21 03:04:49 +00:00
531337bbc9 merge new features 2018-08-21 02:59:54 +00:00
e312d73e23 merge new features 2018-08-21 02:58:04 +00:00
5380a519bd treat devices more like devices and less like domains 2018-08-20 19:45:13 +00:00
bb018c538d v0.20.0: use proxy-packer v2.x 2018-08-08 09:08:56 +00:00
06cc7bbaeb v0.13.1 2018-06-21 20:16:39 +00:00
82aeffd67c bugfix tcp disconnect 2018-06-21 20:16:08 +00:00
1873cdea50 v0.13.0 2018-06-21 20:03:08 +00:00
af63322aca minor logging cleanup 2018-06-21 20:00:02 +00:00
8be76e1eb2 major refactor (only briefly tested) 2018-06-19 23:43:28 +00:00
cb8261fd31 add index.json 2018-06-15 22:53:30 +00:00
92f37a515a Merge branch 'master' of ssh://git.coolaj86.com:22042/coolaj86/telebit-relay.js 2018-06-15 08:46:55 +00:00
e619018276 show meaningful errors 2018-06-15 08:46:43 +00:00
c8c06d1b99 use sharedDomain from config 2018-06-15 06:13:26 +00:00
13a9e77998 Merge branch 'master' of ssh://git.coolaj86.com:22042/coolaj86/telebit-relay.js 2018-06-15 06:09:55 +00:00
f94e01917e update example config 2018-06-15 06:09:46 +00:00
33c3bc9876 Merge branch 'master' of ssh://git.coolaj86.com:22042/coolaj86/telebit-relay.js 2018-06-15 05:16:46 +00:00
f2cd8158f7 ignore node files 2018-06-15 05:16:23 +00:00
02c3836883 change user permissions 2018-06-14 22:22:58 +00:00
6cfad96323 adduser telebit 2018-06-14 21:23:08 +00:00
24b42e1ed9 manual install instructions for linux 2018-06-14 21:21:48 +00:00
39b01fca47 rename telebitd => telebit-relay 2018-06-14 21:19:27 +00:00
4a3a395c14 de-hard-code TELEBIT_RELAY_PATH 2018-06-14 21:13:36 +00:00
7e83b0c3c2 whitespace 2018-06-14 21:13:09 +00:00
c8d974884b read from /dev/tty direct 2018-06-14 20:46:41 +00:00
d700b51494 don't assume the use of /etc 2018-06-14 14:40:43 -06:00
c70ba4f4fd fix bad new path 2018-06-14 10:05:19 +00:00
82619cf88d Merge branch 'master' of ssh://git.coolaj86.com:22042/coolaj86/telebit-relay.js 2018-06-14 10:00:00 +00:00
31932002c9 fix some sni and vhost stuff 2018-06-14 09:59:48 +00:00
AJ ONeal
496d3862f8 telebitd => telebit-relay 2018-06-11 11:24:57 -06:00
5ddd85e14e reuse port 2018-06-09 20:54:32 +00:00
b2a7ecd39b handle existing and new tokens the same 2018-06-07 07:47:30 +00:00
e5563b5842 bugfix moving a few functions to the wrong place 2018-06-07 01:39:16 +00:00
224c3ac9cd handle extension for auth 2018-06-06 10:56:38 +00:00
73f26d6e05 fix finalhandler for 404s 2018-06-06 09:44:35 +00:00
fdcf205b49 make auth async and extensible 2018-06-06 06:59:03 +00:00
5a34b39ff3 important notes 2018-06-01 09:29:48 +00:00
22339275bb enable forwarding of random domain 2018-06-01 09:17:18 +00:00
ebe4003d27 add ssh+https 2018-06-01 09:10:41 +00:00
846590e648 grant tcp, ssh, and https by default 2018-06-01 09:04:58 +00:00
d0ae3a1c0f cleanup and bugfix 2018-06-01 07:24:00 +00:00
3fe62c6b02 fix server.close() bug 2018-06-01 06:48:18 +00:00
c37147a012 Merge branch 'master' of ssh://git.coolaj86.com:22042/coolaj86/telebitd.js 2018-06-01 06:41:40 +00:00
b086e1c0a5 add dynamic tcp and cleanup 2018-06-01 06:41:32 +00:00
934afd8a8d remove tls-sni-01 reference 2018-05-31 20:17:58 +00:00
df3c1c3b04 use proxy-packer 2018-05-31 11:27:11 +00:00
643b5a62ea minor optimization 2018-05-31 06:18:02 +00:00
05cb157cfc typo fixes and pass serviceport 2018-05-31 06:12:37 +00:00
8f7dec1df1 node gives clean exit codes, so restart on failure 2018-05-31 05:59:46 +00:00
9c57bac510 rename a few things 2018-05-31 05:24:43 +00:00
AJ ONeal
e1ee55da02 v0.12.0 admin interface, vhosting, etc 2018-05-26 15:54:13 -06:00
f6011ade83 still playing peekaboo... 2018-05-26 21:21:03 +00:00
539fb4e62a conn.resume() to fix close-or-hangup bug 2018-05-26 08:52:43 +00:00
d3a6ef96d6 begin admin interface 2018-05-26 08:48:23 +00:00
28944a6933 update config 2018-05-26 08:07:49 +00:00
d566a06cb3 fix config template 2018-05-26 07:47:20 +00:00
dcb62b1e2c stop before removing 2018-05-26 07:41:39 +00:00
45aa3a4686 add unistaller, update installer 2018-05-26 07:40:17 +00:00
25 changed files with 1510 additions and 757 deletions

15
.gitignore vendored
View File

@ -1,4 +1,10 @@
node_modules.*
include
bin/node
bin/npm
bin/npx
share
etc
# Logs
logs
@ -37,3 +43,12 @@ jspm_packages
# Optional REPL history
.node_repl_history
# Snapcraft
/parts/
/prime/
/stage/
.snapcraft
*.snap
*.tar.bz2

View File

@ -10,7 +10,7 @@
, "immed": true
, "undef": true
, "unused": true
, "latedef": true
, "latedef": "nofunc"
, "curly": true
, "trailing": true
}

101
README.md
View File

@ -31,15 +31,15 @@ curl -fsSL https://get.telebit.cloud/relay | bash
Of course, feel free to inspect the install script before you run it.
This will install Telebit Relay to `/opt/telebitd` and
put a symlink to `/opt/telebitd/bin/telebitd` in `/usr/local/bin/telebitd`
This will install Telebit Relay to `/opt/telebit-relay` and
put a symlink to `/opt/telebit-relay/bin/telebit-relay` in `/usr/local/bin/telebit-relay`
for convenience.
You can customize the installation:
```bash
export NODEJS_VER=v10.2
export TELEBITD_PATH=/opt/telebitd
export TELEBIT_RELAY_PATH=/opt/telebit-relay
curl -fsSL https://get.telebit.cloud/relay
```
@ -49,7 +49,7 @@ and the path to which Telebit Relay installs.
You can get rid of the tos + email and server domain name prompts by providing them right away:
```bash
curl -fsSL https://get.telebit.cloud/relay | bash -- jon@example.com telebit.example.com
curl -fsSL https://get.telebit.cloud/relay | bash -- jon@example.com telebit-relay.example.com
```
Windows & Node.js
@ -57,37 +57,72 @@ Windows & Node.js
1. Install [node.js](https://nodejs.org)
2. Open _Node.js_
2. Run the command `npm install -g telebitd`
2. Run the command `npm install -g telebit-relay`
**Note**: Use node.js v8.x or v10.x
There is [a bug](https://github.com/nodejs/node/issues/20241) in node v9.x that causes telebitd to crash.
There is [a bug](https://github.com/nodejs/node/issues/20241) in node v9.x that causes telebit-relay to crash.
Manually Install
-----------
```bash
git clone https://git.coolaj86.com/coolaj86/telebit-relay.js.git telebit-relay
# we're very picky to due to bugs in various versions of v8, v9, and v10
export NODEJS_VER="v10.2.1"
# We can keep everything self-contained
export NPM_CONFIG_PREFIX=/opt/telebit-relay
export NODE_PATH=/opt/telebit-relay/lib/node_modules
curl -fsSL https://bit.ly/node-installer | bash -s -- --no-dev-deps
pushd /opt/telebit-relay
bin/node bin/npm install
rsync -a examples/telebit-relay.yml etc/telebit-relay.yml
rsync -a dist/etc/systemd/system/telebit-relay.service /etc/systemd/system/telebit-relay.service
popd
# IMPORTANT: Season the config file to taste
# IMPORTANT: change your email address and domain
edit /opt/telebit-relay/etc/telebit-relay.yml
adduser --home /opt/telebit-relay --gecos '' --disabled-password telebit >/dev/null 2>&1
sudo chown -R telebit:telebit /opt/telebit-relay/
systemctl daemon-reload
systemctl restart telebit-relay
systemctl status telebit-relay
journalctl -xefu telebit-relay
```
Usage
====
```bash
telebitd --config /etc/telebit/telebitd.yml
telebit-relay --config /opt/telebit-relay/etc/telebit-relay.yml
```
Options
`/etc/telebit/telebitd.yml:`
`/opt/telebit-relay/etc/telebit-relay.yml:`
```
email: 'jon@example.com' # must be valid (for certificate recovery and security alerts)
agree_tos: true # agree to the Telebit, Greenlock, and Let's Encrypt TOSes
community_member: true # receive infrequent relevant but non-critical updates
telemetry: true # contribute to project telemetric data
secret: '' # JWT authorization secret. Generate like so:
# node -e "console.log(crypto.randomBytes(16).toString('hex'))"
servernames: # hostnames that direct to the Telebit Relay admin console
- telebit.example.com
- telebit.example.net
vhost: /srv/www/:hostname # securely serve local sites from this path (or false)
# (uses template string, i.e. /var/www/:hostname/public)
email: 'jon@example.com' # must be valid (for certificate recovery and security alerts)
agree_tos: true # agree to the Telebit, Greenlock, and Let's Encrypt TOSes
community_member: true # receive infrequent relevant but non-critical updates
telemetry: true # contribute to project telemetric data
secret: '' # JWT authorization secret. Generate like so:
# node -e "console.log(crypto.randomBytes(16).toString('hex'))"
servernames: # hostnames that direct to the Telebit Relay admin console
- telebit-relay.example.com
- telebit-relay.example.net
vhost: /srv/www/:hostname # securely serve local sites from this path (or false)
# (uses template string, i.e. /var/www/:hostname/public)
greenlock:
store: le-store-certbot # certificate storage plugin
config_dir: /etc/acme # directory for ssl certificates
store: le-store-certbot # certificate storage plugin
config_dir: /opt/telebit-relay/etc/acme # directory for ssl certificates
```
Security
@ -100,7 +135,7 @@ Even though the traffic is encrypted end-to-end, you can't just trust any Telebi
willy-nilly.
A man-in-the-middle attack is possible using Let's Encrypt since an evil Telebit Relay
would be able to complete the http-01 and tls-sni-01 challenges without a problem
would be able to complete the http-01 challenges without a problem
(since that's where your DNS is pointed when you use the service).
Also, the traffic could still be copied and stored for decryption is some era when quantum
@ -128,7 +163,7 @@ Useful Tidbits
## As a systemd service
`./dist/etc/systemd/system/telebitd.service` should be copied to `/etc/systemd/system/telebitd.service`.
`./dist/etc/systemd/system/telebit-relay.service` should be copied to `/etc/systemd/system/telebit-relay.service`.
The user and group `telebit` should be created.
@ -138,3 +173,23 @@ The user and group `telebit` should be created.
# Linux
sudo setcap 'cap_net_bind_service=+ep' $(which node)
```
API
===
The authentication method is abstract so that it can easily be implemented for various users and use cases.
```
// bin/telebit-relay.js
state.authenticate() // calls either state.extensions.authenticate or state.defaults.authenticate
// which, in turn, calls Server.onAuth()
state.extensions = require('../lib/extensions');
state.extensions.authenticate({
state: state // lib/relay.js in-memory state
, auth: 'xyz.abc.123' // arbitrary token, typically a JWT (default handler)
})
// lib/relay.js
Server.onAuth(state, srv, rawAuth, validatedTokenData);
```

View File

@ -0,0 +1,7 @@
{ "terms_of_service": ":hostname/tos/"
, "api_host": ":hostname"
, "tunnel": {
"method": "wss"
, "pathname": ""
}
}

View File

@ -2,8 +2,14 @@
<html>
<head>
<title>Telebit Relay</title>
<meta charset="UTF-8">
</head>
<body>
<script>document.body.hidden = true;</script>
<button class="js-login">Login</button>
<button class="js-login">Sign Up</button>
<br>
[TODO: Admin Interface]
<script src="js/app.js"></script>
</body>
</html>

6
admin/js/app.js Normal file
View File

@ -0,0 +1,6 @@
(function () {
'use strict';
document.body.hidden = false;
}());

View File

@ -5,7 +5,7 @@
var pkg = require('../package.json');
var argv = process.argv.slice(2);
var telebitd = require('../telebitd.js');
var relay = require('../');
var Greenlock = require('greenlock');
var confIndex = argv.indexOf('--config');
@ -19,15 +19,15 @@ function help() {
console.info('');
console.info('Usage:');
console.info('');
console.info('\ttelebitd --config <path>');
console.info('\ttelebit-relay --config <path>');
console.info('');
console.info('Example:');
console.info('');
console.info('\ttelebitd --config /etc/telebit/telebitd.yml');
console.info('\ttelebit-relay --config /opt/telebit-relay/etc/telebit-relay.yml');
console.info('');
console.info('Config:');
console.info('');
console.info('\tSee https://git.coolaj86.com/coolaj86/telebitd.js');
console.info('\tSee https://git.coolaj86.com/coolaj86/telebit-relay.js');
console.info('');
console.info('');
process.exit(0);
@ -41,8 +41,19 @@ if (!confpath || /^--/.test(confpath)) {
}
function applyConfig(config) {
var state = { ports: [ 80, 443 ], tcp: {} };
state.tlsOptions = {}; // TODO just close the sockets that would use this early? or use the admin servername
var state = { defaults: {}, ports: [ 80, 443 ], tcp: {} };
if ('undefined' !== typeof Promise) {
state.Promise = Promise;
} else {
state.Promise = require('bluebird');
}
state.tlsOptions = {
// Handles disconnected devices
// TODO allow user to opt-in to wildcard hosting for a better error page?
SNICallback: function (servername, cb) {
return state.greenlock.tlsOptions.SNICallback(state.config.webminDomain || state.servernames[0], cb);
}
}; // TODO just close the sockets that would use this early? or use the admin servername
state.config = config;
state.servernames = config.servernames || [];
state.secret = state.config.secret;
@ -63,7 +74,7 @@ function applyConfig(config) {
}
function approveDomains(opts, certs, cb) {
console.log('[debug] approveDomains', opts.domains);
if (state.debug) { console.log('[debug] approveDomains', opts.domains); }
// This is where you check your database and associated
// email addresses with domains and agreements and such
@ -75,11 +86,12 @@ function applyConfig(config) {
return;
}
if (state.config.vhost) {
console.log('[sni] vhost checking is turned on');
if (!state.validHosts) { state.validHosts = {}; }
if (!state.validHosts[opts.domains[0]] && state.config.vhost) {
if (state.debug) { console.log('[sni] vhost checking is turned on'); }
var vhost = state.config.vhost.replace(/:hostname/, opts.domains[0]);
require('fs').readdir(vhost, function (err, nodes) {
console.log('[sni] checking fs vhost');
if (state.debug) { console.log('[sni] checking fs vhost', opts.domains[0], !err); }
if (err) { check(); return; }
if (nodes) { approve(); }
});
@ -87,8 +99,10 @@ function applyConfig(config) {
}
function approve() {
state.validHosts[opts.domains[0]] = true;
opts.email = state.config.email;
opts.agreeTos = state.config.agreeTos;
opts.communityMember = state.config.communityMember || state.config.greenlock.communityMember;
opts.challenges = {
// TODO dns-01
'http-01': require('le-challenge-fs').create({ webrootPath: '/tmp/acme-challenges' })
@ -98,61 +112,90 @@ function applyConfig(config) {
}
function check() {
console.log('[sni] checking servername');
if (state.debug) { console.log('[sni] checking servername'); }
if (-1 !== state.servernames.indexOf(opts.domain) || -1 !== (state._servernames||[]).indexOf(opts.domain)) {
approve();
} else {
cb(new Error("failed the approval chain '" + opts.domains[0] + "'"));
}
console.log('Approve Domains cb');
}
check();
}
/*
if (!config.email || !config.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("");
process.exit(1);
}
*/
state.greenlock = Greenlock.create({
version: 'draft-11'
, server: 'https://acme-v02.api.letsencrypt.org/directory'
//, server: 'https://acme-staging-v02.api.letsencrypt.org/directory'
version: state.config.greenlock.version || 'draft-11'
, server: state.config.greenlock.server || 'https://acme-v02.api.letsencrypt.org/directory'
, store: require('le-store-certbot').create({ debug: true, webrootPath: '/tmp/acme-challenges' })
, store: require('le-store-certbot').create({ debug: state.config.debug || state.config.greenlock.debug, webrootPath: '/tmp/acme-challenges' })
, approveDomains: approveDomains
, configDir: state.config.configDir
, debug: true
//, approvedDomains: program.servernames
, telemetry: state.config.telemetry || state.config.greenlock.telemetry
, configDir: state.config.greenlock.configDir
, debug: state.config.debug || state.config.greenlock.debug
});
require('../handlers').create(state); // adds directly to config for now...
try {
// TODO specify extensions in config file
state.extensions = require('../lib/extensions');
} catch(e) {
if ('ENOENT' !== e.code || state.debug) { console.log('[DEBUG] no extensions loaded', e); }
state.extensions = {};
}
require('../lib/handlers').create(state); // adds directly to config for now...
//require('cluster-store').create().then(function (store) {
//program.store = store;
state.authenticate = function (opts) {
if (state.extensions.authenticate) {
try {
return state.extensions.authenticate({
state: state
, auth: opts.auth
});
} catch(e) {
console.error('Extension Error:');
console.error(e);
}
}
return state.defaults.authenticate(opts.auth);
};
// default authenticator for single-user setup
// (i.e. personal use on DO, Vultr, or RPi)
state.defaults.authenticate = function onAuthenticate(jwtoken) {
return state.Promise.resolve().then(function () {
var jwt = require('jsonwebtoken');
var auth;
var token;
var decoded;
try {
token = jwt.verify(jwtoken, state.secret);
} catch (e) {
token = null;
}
return token;
});
};
var net = require('net');
var netConnHandlers = telebitd.create(state); // { tcp, ws }
var netConnHandlers = relay.create(state); // { tcp, ws }
var WebSocketServer = require('ws').Server;
var wss = new WebSocketServer({ server: (state.httpTunnelServer || state.httpServer) });
wss.on('connection', netConnHandlers.ws);
state.ports.forEach(function (port) {
if (state.tcp[port]) {
console.error("skipping previously added port " + port);
console.warn("[cli] skipping previously added port " + port);
return;
}
state.tcp[port] = net.createServer();
state.tcp[port].listen(port, function () {
console.log('listening plain TCP on ' + port);
console.info('[cli] Listening for TCP connections on', port);
});
state.tcp[port].on('connection', netConnHandlers.tcp);
});
@ -257,7 +300,7 @@ function adjustArgs() {
.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 telebitd (used for JWT authentication)')
.option('--secret <STRING>', 'the same secret used by telebit-relay (used for JWT authentication)')
.parse(process.argv)
;

View File

@ -1,11 +1,11 @@
# Pre-req
# sudo adduser telebit --home /opt/telebitd
# sudo mkdir -p /opt/telebitd/
# sudo chown -R telebit:telebit /opt/telebitd/
# sudo adduser telebit --home /opt/telebit-relay
# sudo mkdir -p /opt/telebit-relay/
# sudo chown -R telebit:telebit /opt/telebit-relay/
[Unit]
Description=Telebit Relay
Documentation=https://git.coolaj86.com/coolaj86/telebitd.js/
Documentation=https://git.coolaj86.com/coolaj86/telebit-relay.js/
After=network-online.target
Wants=network-online.target systemd-networkd-wait-online.service
@ -13,7 +13,7 @@ Wants=network-online.target systemd-networkd-wait-online.service
# Restart on crash (bad signal), but not on 'clean' failure (error exit code)
# Allow up to 3 restarts within 10 seconds
# (it's unlikely that a user or properly-running script will do this)
Restart=on-abnormal
Restart=on-failure
StartLimitInterval=10
StartLimitBurst=3
@ -22,9 +22,9 @@ StartLimitBurst=3
User=telebit
Group=telebit
WorkingDirectory=/opt/telebitd
WorkingDirectory=/opt/telebit-relay
# custom directory cannot be set and will be the place where gitea exists, not the working directory
ExecStart=/opt/telebitd/bin/node /opt/telebitd/bin/telebitd.js --config /etc/telebit/telebitd.yml
ExecStart=/opt/telebit-relay/bin/node /opt/telebit-relay/bin/telebit-relay.js --config /opt/telebit-relay/etc/telebit-relay.yml
ExecReload=/bin/kill -USR1 $MAINPID
# Limit the number of file descriptors and processes; see `man systemd.exec` for more limit settings.
@ -44,10 +44,10 @@ ProtectSystem=full
# and /var/log/gitea because we want a place where logs can go.
# This merely retains r/w access rights, it does not add any new.
# Must still be writable on the host!
ReadWriteDirectories=/opt/telebitd /etc/telebit
ReadWriteDirectories=/opt/telebit-relay
# Note: in v231 and above ReadWritePaths has been renamed to ReadWriteDirectories
; ReadWritePaths=/opt/telebitd /etc/telebit
; ReadWritePaths=/opt/telebit-relay /etc/telebit
# The following additional security directives only work with systemd v229 or later.
# They further retrict privileges that can be gained by gitea.

View File

@ -0,0 +1,17 @@
email: 'jon@example.com' # must be valid (for certificate recovery and security alerts)
agree_tos: true # agree to the Telebit, Greenlock, and Let's Encrypt TOSes
community_member: true # receive infrequent relevant updates
telemetry: true # contribute to project telemetric data
webmin_domain: example.com
shared_domain: xm.pl
servernames: # hostnames that direct to the Telebit Relay admin console
- telebit.example.com
- telebit.example.net
vhost: /srv/www/:hostname # load secure websites at this path (uses template string, i.e. /var/www/:hostname/public)
greenlock:
version: 'draft-11'
server: 'https://acme-v02.api.letsencrypt.org/directory'
store:
strategy: le-store-certbot # certificate storage plugin
config_dir: /etc/acme # directory for ssl certificates
secret: '' # generate with node -e "console.log(crypto.randomBytes(16).toString('hex'))"

View File

@ -0,0 +1,10 @@
agree_tos: true
community_member: true
telemetry: true
vhost: /srv/www/:hostname
greenlock:
version: 'draft-11'
server: 'https://acme-v02.api.letsencrypt.org/directory'
store:
strategy: le-store-certbot
config_dir: /opt/telebit-relay/etc/acme

View File

@ -1,12 +0,0 @@
email: 'jon@example.com' # must be valid (for certificate recovery and security alerts)
agree_tos: true # agree to the Telebit, Greenlock, and Let's Encrypt TOSes
community_member: true # receive infrequent relevant updates
telemetry: true # contribute to project telemetric data
servernames: # hostnames that direct to the Telebit Relay admin console
- telebit.example.com
- telebit.example.net
vhost: /srv/www/:hostname # load secure websites at this path (uses template string, i.e. /var/www/:hostname/public)
greenlock:
store: le-store-certbot # certificate storage plugin
config_dir: /etc/acme # directory for ssl certificates
secret: '' # generate with node -e "console.log(crypto.randomBytes(16).toString('hex'))"

View File

@ -1,7 +0,0 @@
agree_tos: true
community_member: true
telemetry: true
vhost: /srv/www/:hostname
greenlock:
store: le-store-certbot
config_dir: /opt/telebitd/acme

View File

@ -60,18 +60,15 @@ detect_http_get
## END HTTP_GET ##
###############################
echo ""
echo ""
echo ""
my_email=${1:-}
my_servername=${2:-}
my_secret=""
my_user="telebit"
my_app="telebitd"
my_bin="telebitd.js"
my_app="telebit-relay"
my_bin="telebit-relay.js"
my_name="Telebit Relay"
my_repo="telebitd.js"
my_repo="telebit-relay.js"
exec 3<>/dev/tty
if [ -z "${my_email}" ]; then
echo ""
@ -81,113 +78,116 @@ if [ -z "${my_email}" ]; then
echo "To accept the Terms of Service for Telebit, Greenlock and Let's Encrypt,"
echo "please enter your email."
echo ""
read -p "email: " my_email
read -u 3 -p "email: " my_email
echo ""
# UX - just want a smooth transition
sleep 0.5
fi
if [ -z "${my_servername}" ]; then
echo "What is the domain of this server (for admin interface)?"
echo ""
read -p "domain (ex: telebit.example.com): " my_servername
read -u 3 -p "domain (ex: telebit-relay.example.com): " my_servername
echo ""
fi
sleep 2
if [ -z "${TELEBITD_PATH:-}" ]; then
echo 'TELEBITD_PATH="'${TELEBITD_PATH:-}'"'
TELEBITD_PATH=/opt/$my_app
# UX - just want a smooth transition
sleep 0.5
fi
echo "Installing $my_name to '$TELEBITD_PATH'"
echo ""
echo "Installing node.js dependencies into $TELEBITD_PATH"
if [ -z "${TELEBIT_RELAY_PATH:-}" ]; then
echo 'TELEBIT_RELAY_PATH="'${TELEBIT_RELAY_PATH:-}'"'
TELEBIT_RELAY_PATH=/opt/$my_app
fi
echo "Installing $my_name to '$TELEBIT_RELAY_PATH'"
echo "Installing node.js dependencies into $TELEBIT_RELAY_PATH"
# v10.2+ has much needed networking fixes, but breaks ursa. v9.x has severe networking bugs. v8.x has working ursa, but requires tls workarounds"
NODEJS_VER="${NODEJS_VER:-v10}"
export NODEJS_VER
export NODE_PATH="$TELEBITD_PATH/lib/node_modules"
export NPM_CONFIG_PREFIX="$TELEBITD_PATH"
export PATH="$TELEBITD_PATH/bin:$PATH"
export NODE_PATH="$TELEBIT_RELAY_PATH/lib/node_modules"
export NPM_CONFIG_PREFIX="$TELEBIT_RELAY_PATH"
export PATH="$TELEBIT_RELAY_PATH/bin:$PATH"
sleep 1
http_bash https://git.coolaj86.com/coolaj86/node-installer.sh/raw/branch/master/install.sh --no-dev-deps >/dev/null 2>/dev/null
my_tree="master"
my_node="$TELEBITD_PATH/bin/node"
my_node="$TELEBIT_RELAY_PATH/bin/node"
my_secret=$($my_node -e "console.info(crypto.randomBytes(16).toString('hex'))")
my_npm="$my_node $TELEBITD_PATH/bin/npm"
my_tmp="$TELEBITD_PATH/tmp"
my_npm="$my_node $TELEBIT_RELAY_PATH/bin/npm"
my_tmp="$TELEBIT_RELAY_PATH/tmp"
mkdir -p $my_tmp
echo "sudo mkdir -p '$TELEBITD_PATH'"
sudo mkdir -p "$TELEBITD_PATH"
echo "sudo mkdir -p '/etc/$my_user/'"
sudo mkdir -p "/etc/$my_user/"
echo "sudo mkdir -p '$TELEBIT_RELAY_PATH'"
sudo mkdir -p "$TELEBIT_RELAY_PATH"
echo "sudo mkdir -p '$TELEBIT_RELAY_PATH/etc'"
sudo mkdir -p "$TELEBIT_RELAY_PATH/etc/"
set +e
#https://git.coolaj86.com/coolaj86/telebitd.js.git
#https://git.coolaj86.com/coolaj86/telebitd.js/archive/:tree:.tar.gz
#https://git.coolaj86.com/coolaj86/telebitd.js/archive/:tree:.zip
#https://git.coolaj86.com/coolaj86/telebit-relay.js.git
#https://git.coolaj86.com/coolaj86/telebit-relay.js/archive/:tree:.tar.gz
#https://git.coolaj86.com/coolaj86/telebit-relay.js/archive/:tree:.zip
my_unzip=$(type -p unzip)
my_tar=$(type -p tar)
if [ -n "$my_unzip" ]; then
rm -f $my_tmp/$my_app-$my_tree.zip
http_get https://git.coolaj86.com/coolaj86/$my_repo/archive/$my_tree.zip $my_tmp/$my_app-$my_tree.zip
# -o means overwrite, and there is no option to strip
$my_unzip -o $my_tmp/$my_app-$my_tree.zip -d $TELEBITD_PATH/ > /dev/null 2>&1
cp -ar $TELEBITD_PATH/$my_repo/* $TELEBITD_PATH/ > /dev/null
rm -rf $TELEBITD_PATH/$my_bin
$my_unzip -o $my_tmp/$my_app-$my_tree.zip -d $TELEBIT_RELAY_PATH/ > /dev/null 2>&1
cp -ar $TELEBIT_RELAY_PATH/$my_repo/* $TELEBIT_RELAY_PATH/ > /dev/null
rm -rf $TELEBIT_RELAY_PATH/$my_bin
elif [ -n "$my_tar" ]; then
rm -f $my_tmp/$my_app-$my_tree.tar.gz
http_get https://git.coolaj86.com/coolaj86/$my_repo/archive/$my_tree.tar.gz $my_tmp/$my_app-$my_tree.tar.gz
ls -lah $my_tmp/$my_app-$my_tree.tar.gz
$my_tar -xzf $my_tmp/$my_app-$my_tree.tar.gz --strip 1 -C $TELEBITD_PATH/
$my_tar -xzf $my_tmp/$my_app-$my_tree.tar.gz --strip 1 -C $TELEBIT_RELAY_PATH/
else
echo "Neither tar nor unzip found. Abort."
exit 13
fi
set -e
pushd $TELEBITD_PATH >/dev/null
pushd $TELEBIT_RELAY_PATH >/dev/null
$my_npm install >/dev/null 2>/dev/null
popd >/dev/null
cat << EOF > $TELEBITD_PATH/bin/$my_app
cat << EOF > $TELEBIT_RELAY_PATH/bin/$my_app
#!/bin/bash
$my_node $TELEBITD_PATH/bin/$my_bin
$my_node $TELEBIT_RELAY_PATH/bin/$my_bin
EOF
chmod a+x $TELEBITD_PATH/bin/$my_app
echo "sudo ln -sf $TELEBITD_PATH/bin/$my_app /usr/local/bin/$my_app"
sudo ln -sf $TELEBITD_PATH/bin/$my_app /usr/local/bin/$my_app
chmod a+x $TELEBIT_RELAY_PATH/bin/$my_app
echo "sudo ln -sf $TELEBIT_RELAY_PATH/bin/$my_app /usr/local/bin/$my_app"
sudo ln -sf $TELEBIT_RELAY_PATH/bin/$my_app /usr/local/bin/$my_app
set +e
if type -p setcap >/dev/null 2>&1; then
#echo "Setting permissions to allow $my_app to run on port 80 and port 443 without sudo or root"
echo "sudo setcap cap_net_bind_service=+ep $TELEBITD_PATH/bin/node"
sudo setcap cap_net_bind_service=+ep $TELEBITD_PATH/bin/node
echo "sudo setcap cap_net_bind_service=+ep $TELEBIT_RELAY_PATH/bin/node"
sudo setcap cap_net_bind_service=+ep $TELEBIT_RELAY_PATH/bin/node
fi
set -e
if [ -z "$(cat /etc/passwd | grep $my_user)" ]; then
echo "sudo adduser --home $TELEBITD_PATH --gecos '' --disabled-password $my_user"
sudo adduser --home $TELEBITD_PATH --gecos '' --disabled-password $my_user >/dev/null 2>&1
echo "sudo adduser --home $TELEBIT_RELAY_PATH --gecos '' --disabled-password $my_user"
sudo adduser --home $TELEBIT_RELAY_PATH --gecos '' --disabled-password $my_user >/dev/null 2>&1
fi
if [ ! -f "/etc/$my_user/$my_app.yml" ]; then
if [ ! -f "$TELEBIT_RELAY_PATH/etc/$my_app.yml" ]; then
echo "### Creating config file from template. sudo may be required"
#echo "sudo rsync -a examples/$my_app.yml /etc/$my_user/$my_app.yml"
sudo bash -c "echo 'email: $my_email' >> /etc/$my_user/$my_app.yml"
sudo bash -c "echo 'secret: $my_secret' >> /etc/$my_user/$my_app.yml"
sudo bash -c "echo 'servernames: [ $my_servername ]' >> /etc/$my_user/$my_app.yml"
sudo bash -c "cat examples/$my_app.yml.tpl >> /etc/$my_user/$my_app.yml"
sudo bash -c "echo 'servernames: []' >> /etc/$my_user/$my_app.yml"
#echo "sudo rsync -a examples/$my_app.yml $TELEBIT_RELAY_PATH/etc/$my_app.yml"
sudo bash -c "echo 'email: $my_email' >> $TELEBIT_RELAY_PATH/etc/$my_app.yml"
sudo bash -c "echo 'secret: $my_secret' >> $TELEBIT_RELAY_PATH/etc/$my_app.yml"
sudo bash -c "echo 'servernames: [ $my_servername ]' >> $TELEBIT_RELAY_PATH/etc/$my_app.yml"
sudo bash -c "cat $TELEBIT_RELAY_PATH/examples/$my_app.yml.tpl >> $TELEBIT_RELAY_PATH/etc/$my_app.yml"
fi
echo "sudo chown -R $my_user '$TELEBITD_PATH' '/etc/$my_user'"
sudo chown -R $my_user "$TELEBITD_PATH" "/etc/$my_user"
echo "sudo chown -R $my_user '$TELEBIT_RELAY_PATH'"
sudo chown -R $my_user "$TELEBIT_RELAY_PATH"
echo "### Adding $my_app is a system service"
echo "sudo rsync -a $TELEBITD_PATH/dist/etc/systemd/system/$my_app.service /etc/systemd/system/$my_app.service"
sudo rsync -a $TELEBITD_PATH/dist/etc/systemd/system/$my_app.service /etc/systemd/system/$my_app.service
echo "sudo rsync -a $TELEBIT_RELAY_PATH/dist/etc/systemd/system/$my_app.service /etc/systemd/system/$my_app.service"
sudo rsync -a $TELEBIT_RELAY_PATH/dist/etc/systemd/system/$my_app.service /etc/systemd/system/$my_app.service
sudo systemctl daemon-reload
echo "sudo systemctl enable $my_app"
sudo systemctl enable $my_app
@ -202,7 +202,7 @@ echo "=============================================="
echo " Privacy Settings in Config"
echo "=============================================="
echo ""
echo "The example config file /etc/telebit/telebitd.yml opts-in to"
echo "The example config file $TELEBIT_RELAY_PATH/etc/$my_app.yml opts-in to"
echo "contributing telemetrics and receiving infrequent relevant updates"
echo "(probably once per quarter or less) such as important notes on"
echo "a new release, an important API change, etc. No spam."
@ -219,13 +219,13 @@ echo "=============================================="
echo ""
echo "Edit the config and restart, if desired:"
echo ""
echo " sudo vim /etc/telebit/telebitd.yml"
echo " sudo vim $TELEBIT_RELAY_PATH/etc/$my_app.yml"
echo " sudo systemctl restart $my_app"
echo ""
echo "Or disabled the service and start manually:"
echo ""
echo " sudo systemctl stop $my_app"
echo " sudo systemctl disable $my_app"
echo " $my_app --config /etc/$my_user/$my_app.yml"
echo " $my_app --config $TELEBIT_RELAY_PATH/etc/$my_app.yml"
echo ""
sleep 1

View File

@ -0,0 +1,5 @@
systemctl disable telebit-relay
systemctl stop telebit-relay
rm -rf /opt/telebit-relay/ /etc/system/systemd/telebit-relay.service /usr/local/bin/telebit-relay /etc/telebit/
userdel -r telebit
groupdel telebit

35
lib/ago-test.js Normal file
View File

@ -0,0 +1,35 @@
'use strict';
var timeago = require('./ago.js').AGO;
function test() {
[ 1.5 * 1000 // a moment ago
, 4.5 * 1000 // moments ago
, 10 * 1000 // 10 seconds ago
, 59 * 1000 // a minute ago
, 60 * 1000 // a minute ago
, 61 * 1000 // a minute ago
, 119 * 1000 // a minute ago
, 120 * 1000 // 2 minutes ago
, 121 * 1000 // 2 minutes ago
, (60 * 60 * 1000) - 1000 // 59 minutes ago
, 1 * 60 * 60 * 1000 // an hour ago
, 1.5 * 60 * 60 * 1000 // an hour ago
, 2.5 * 60 * 60 * 1000 // 2 hours ago
, 1.5 * 24 * 60 * 60 * 1000 // a day ago
, 2.5 * 24 * 60 * 60 * 1000 // 2 days ago
, 7 * 24 * 60 * 60 * 1000 // a week ago
, 14 * 24 * 60 * 60 * 1000 // 2 weeks ago
, 27 * 24 * 60 * 60 * 1000 // 3 weeks ago
, 28 * 24 * 60 * 60 * 1000 // 4 weeks ago
, 29 * 24 * 60 * 60 * 1000 // 4 weeks ago
, 1.5 * 30 * 24 * 60 * 60 * 1000 // a month ago
, 2.5 * 30 * 24 * 60 * 60 * 1000 // 2 months ago
, (12 * 30 * 24 * 60 * 60 * 1000) + 1000 // 12 months ago
, 13 * 30 * 24 * 60 * 60 * 1000 // over a year ago
].forEach(function (d) {
console.log(d, '=', timeago(d));
});
}
test();

50
lib/ago.js Normal file
View File

@ -0,0 +1,50 @@
;(function (exports) {
'use strict';
exports.AGO = function timeago(ms) {
var ago = Math.floor(ms / 1000);
var part = 0;
if (ago < 2) { return "a moment ago"; }
if (ago < 5) { return "moments ago"; }
if (ago < 60) { return ago + " seconds ago"; }
if (ago < 120) { return "a minute ago"; }
if (ago < 3600) {
while (ago >= 60) { ago -= 60; part += 1; }
return part + " minutes ago";
}
if (ago < 7200) { return "an hour ago"; }
if (ago < 86400) {
while (ago >= 3600) { ago -= 3600; part += 1; }
return part + " hours ago";
}
if (ago < 172800) { return "a day ago"; }
if (ago < 604800) {
while (ago >= 172800) { ago -= 172800; part += 1; }
return part + " days ago";
}
if (ago < 1209600) { return "a week ago"; }
if (ago < 2592000) {
while (ago >= 604800) { ago -= 604800; part += 1; }
return part + " weeks ago";
}
if (ago < 5184000) { return "a month ago"; }
if (ago < 31536001) {
while (ago >= 2592000) { ago -= 2592000; part += 1; }
return part + " months ago";
}
if (ago < 315360000) { // 10 years
return "more than year ago";
}
// TODO never
return "";
};
}('undefined' !== typeof module ? module.exports : window));

View File

@ -1,45 +1,138 @@
'use strict';
var Devices = module.exports;
Devices.add = function (store, servername, newDevice) {
var devices = store[servername] || [];
devices.push(newDevice);
store[servername] = devices;
// TODO enumerate store's keys and device's keys for documentation
Devices.addPort = function (store, serverport, newDevice) {
// TODO make special
return Devices.add(store, serverport, newDevice, true);
};
Devices.add = function (store, servername, newDevice, isPort) {
if (isPort) {
if (!store._ports) { store._ports = {}; }
}
// add domain (also handles ports at the moment)
if (!store._domains) { store._domains = {}; }
if (!store._domains[servername]) { store._domains[servername] = []; }
store._domains[servername].push(newDevice);
Devices.touch(store, servername);
// add device
// TODO only use a device id
var devId = newDevice.id || servername;
if (!newDevice.__servername) {
newDevice.__servername = servername;
}
if (!store._devices) { store._devices = {}; }
if (!store._devices[devId]) {
store._devices[devId] = newDevice;
if (!store._devices[devId].domainsMap) { store._devices[devId].domainsMap = {}; }
if (!store._devices[devId].domainsMap[servername]) { store._devices[devId].domainsMap[servername] = true; }
}
};
Devices.alias = function (store, servername, alias) {
if (!store._domains[servername]) {
store._domains[servername] = [];
}
if (!store._domains[servername]._primary) {
store._domains[servername]._primary = servername;
}
if (!store._domains[servername].aliases) {
store._domains[servername].aliases = {};
}
store._domains[alias] = store._domains[servername];
store._domains[servername].aliases[alias] = true;
};
Devices.remove = function (store, servername, device) {
var devices = store[servername] || [];
// Check if this domain has an active device
var devices = store._domains[servername] || [];
var index = devices.indexOf(device);
if (index < 0) {
console.warn('attempted to remove non-present device', device.deviceId, 'from', servername);
return null;
}
// unlink this domain from this device
var domainsMap = store._devices[devices[index].id || servername].domainsMap;
delete domainsMap[servername];
/*
// remove device if no domains remain
// nevermind, a device can hang around in limbo for a bit
if (!Object.keys(domains).length) {
delete store._devices[devices[index].id || servername];
}
*/
// unlink this device from this domain
return devices.splice(index, 1)[0];
};
Devices.close = function (store, device) {
var dev = store._devices[device.id || device.__servername];
// because we're actually using names rather than don't have reliable deviceIds yet
if (!dev) {
Object.keys(store._devices).some(function (key) {
if (store._devices[key].socketId === device.socketId) {
// TODO double check that all domains are removed
delete store._devices[key];
return true;
}
});
}
};
Devices.bySocket = function (store, socketId) {
var dev;
Object.keys(store._devices).some(function (k) {
if (store._devices[k].socketId === socketId) {
dev = store._devices[k];
return dev;
}
});
return dev;
};
Devices.list = function (store, servername) {
if (store[servername] && store[servername].length) {
return store[servername];
console.log('[dontkeepme] servername', servername);
// efficient lookup first
if (store._domains[servername] && store._domains[servername].length) {
// aliases have ._primary which is the name of the original
return store._domains[servername]._primary && store._domains[store._domains[servername]._primary] || store._domains[servername];
}
// There wasn't an exact match so check any of the wildcard domains, sorted longest
// first so the one with the biggest natural match with be found first.
var deviceList = [];
Object.keys(store).filter(function (pattern) {
return pattern[0] === '*' && store[pattern].length;
Object.keys(store._domains).filter(function (pattern) {
return pattern[0] === '*' && store._domains[pattern].length;
}).sort(function (a, b) {
return b.length - a.length;
}).some(function (pattern) {
var subPiece = pattern.slice(1);
if (subPiece === servername.slice(-subPiece.length)) {
console.log('"'+servername+'" matches "'+pattern+'"');
deviceList = store[pattern];
console.log('[Devices.list] "'+servername+'" matches "'+pattern+'"');
deviceList = store._domains[pattern];
// Devices.alias(store, '*.example.com', 'sub.example.com'
// '*.example.com' retrieves a reference to 'example.com'
// and this reference then also referenced by 'sub.example.com'
// Hence this O(n) check is replaced with the O(1) check above
Devices.alias(store, pattern, servername);
return true;
}
});
return deviceList;
};
/*
Devices.active = function (store, id) {
var dev = store._devices[id];
return !!dev;
};
*/
Devices.exist = function (store, servername) {
return !!(Devices.list(store, servername).length);
if (Devices.list(store, servername).length) {
Devices.touch(store, servername);
return true;
}
return false;
};
Devices.next = function (store, servername) {
var devices = Devices.list(store, servername);
@ -51,5 +144,20 @@ Devices.next = function (store, servername) {
device = devices[devices._index || 0];
devices._index = (devices._index || 0) + 1;
if (device) { Devices.touch(store, servername); }
return device;
};
Devices.touchDevice = function (store, device) {
// TODO use device.id (which will be pubkey thumbprint) and store._devices[id].domainsMap
Object.keys(device.domainsMap).forEach(function (servername) {
Devices.touch(store, servername);
});
};
Devices.touch = function (store, servername) {
if (!store._recency) { store._recency = {}; }
store._recency[servername] = Date.now();
};
Devices.lastSeen = function (store, servername) {
if (!store._recency) { store._recency = {}; }
return store._recency[servername] || 0;
};

View File

@ -2,7 +2,7 @@
var http = require('http');
var tls = require('tls');
var wrapSocket = require('tunnel-packer').wrapSocket;
var wrapSocket = require('proxy-packer').wrapSocket;
var redirectHttps = require('redirect-https')();
function noSniCallback(tag) {
@ -10,7 +10,7 @@ function noSniCallback(tag) {
var err = new Error("[noSniCallback] no handler set for '" + tag + "':'" + servername + "'");
console.error(err.message);
cb(new Error(err));
}
};
}
module.exports.create = function (state) {
@ -19,8 +19,8 @@ module.exports.create = function (state) {
var setupTlsOpts = {
SNICallback: function (servername, cb) {
if (!setupSniCallback) {
console.error("No way to get https certificates...");
cb(new Error("telebitd sni setup fail"));
console.error("[setup.SNICallback] No way to get https certificates...");
cb(new Error("telebit-relay sni setup fail"));
return;
}
setupSniCallback(servername, cb);
@ -29,7 +29,6 @@ module.exports.create = function (state) {
// 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");
}
@ -53,7 +52,7 @@ module.exports.create = function (state) {
|| redirectHttpsAndClose
);
state.handleInsecureHttp = function (servername, socket) {
console.log("handleInsecureHttp('" + servername + "', socket)");
console.log("[handlers] insecure http for '" + servername + "'");
socket.__my_servername = servername;
state.httpInsecureServer.emit('connection', socket);
};
@ -73,38 +72,44 @@ module.exports.create = function (state) {
state.tlsInvalidSniServer.on('tlsClientError', function () {
console.error('tlsClientError InvalidSniServer');
});
state.httpsInvalid = function (servername, socket) {
state.createHttpInvalid = function (opts) {
return http.createServer(function (req, res) {
if (!opts.servername) {
res.statusCode = 422;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end(
"3. An inexplicable temporal shift of the quantum realm... that makes me feel uncomfortable.\n\n"
+ "[ERROR] No SNI header was sent. I can only think of two possible explanations for this:\n"
+ "\t1. You really love Windows XP and you just won't let go of Internet Explorer 6\n"
+ "\t2. You're writing a bot and you forgot to set the servername parameter\n"
);
return;
}
// TODO use req.headers.host instead of servername (since domain fronting is disabled anyway)
res.statusCode = 502;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(
"<h1>Oops!</h1>"
+ "<p>It looks like '" + encodeURIComponent(opts.servername) + "' isn't connected right now.</p>"
+ "<p><small>Last seen: " + opts.ago + "</small></p>"
+ "<p><small>Error: 502 Bad Gateway</small></p>"
);
});
};
state.httpsInvalid = function (opts, 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);
console.log('httpsInvalid servername', servername);
console.log('[httpsInvalid] servername', opts.servername);
//state.tlsInvalidSniServer.emit('connection', wrapSocket(socket));
var tlsInvalidSniServer = tls.createServer(state.tlsOptions, function (tlsSocket) {
console.log('tls connection');
// things get a little messed up here
var httpInvalidSniServer = http.createServer(function (req, res) {
if (!servername) {
res.statusCode = 422;
res.end(
"3. An inexplicable temporal shift of the quantum realm... that makes me feel uncomfortable.\n\n"
+ "[ERROR] No SNI header was sent. I can only think of two possible explanations for this:\n"
+ "\t1. You really love Windows XP and you just won't let go of Internet Explorer 6\n"
+ "\t2. You're writing a bot and you forgot to set the servername parameter\n"
);
return;
}
res.end(
"You came in hot looking for '" + servername + "' and, granted, the IP address for that domain"
+ " must be pointing here (or else how could you be here?), nevertheless either it's not registered"
+ " in the internal system at all (which Seth says isn't even a thing) or there is no device"
+ " connected on the south side of the network which has informed me that it's ready to have traffic"
+ " for that domain forwarded to it (sorry I didn't check that deeply to determine which).\n\n"
+ "Either way, you're doing strange things that make me feel uncomfortable... Please don't touch me there any more.");
});
httpInvalidSniServer.emit('connection', tlsSocket);
console.log('[tlsInvalid] tls connection');
// We create an entire http server object because it's difficult to figure out
// how to access the original tlsSocket to get the servername
state.createHttpInvalid(opts).emit('connection', tlsSocket);
});
tlsInvalidSniServer.on('tlsClientError', function () {
console.error('tlsClientError InvalidSniServer httpsInvalid');
@ -115,29 +120,43 @@ module.exports.create = function (state) {
//
// To ADMIN / CONTROL PANEL of the Tunnel Server Itself
//
var serveAdmin = require('serve-static')(__dirname + '/admin', { redirect: true });
var serveAdmin = require('serve-static')(__dirname + '/../admin', { redirect: true });
var finalhandler = require('finalhandler');
state.httpTunnelServer = http.createServer(function (req, res) {
console.log('req.socket.encrypted', req.socket.encrypted);
state.defaults.webadmin = function (req, res) {
serveAdmin(req, res, finalhandler(req, res));
};
state.httpTunnelServer = http.createServer(function (req, res) {
res.setHeader('connection', 'close');
if (state.extensions.webadmin) {
state.extensions.webadmin(state, req, res);
} else {
state.defaults.webadmin(req, res);
}
});
Object.keys(state.tlsOptions).forEach(function (key) {
tunnelAdminTlsOpts[key] = state.tlsOptions[key];
});
if (state.greenlock && state.greenlock.tlsOptions) {
console.log('greenlock tlsOptions for SNICallback');
tunnelAdminTlsOpts.SNICallback = function (servername, cb) {
console.log("time to handle '" + servername + "'");
state.greenlock.tlsOptions.SNICallback(servername, cb);
};
tunnelAdminTlsOpts.SNICallback = state.greenlock.tlsOptions.SNICallback;
} else {
console.log('custom or null tlsOptions for SNICallback');
console.log('[Admin] custom or null tlsOptions for SNICallback');
tunnelAdminTlsOpts.SNICallback = tunnelAdminTlsOpts.SNICallback || noSniCallback('admin');
}
var MPROXY = Buffer.from("MPROXY");
state.tlsTunnelServer = tls.createServer(tunnelAdminTlsOpts, function (tlsSocket) {
console.log('(Admin) tls connection');
// things get a little messed up here
(state.httpTunnelServer || state.httpServer).emit('connection', tlsSocket);
if (state.debug) { console.log('[Admin] new tls-terminated connection'); }
tlsSocket.once('readable', function () {
var firstChunk = tlsSocket.read();
tlsSocket.unshift(firstChunk);
if (0 === MPROXY.compare(firstChunk.slice(0, 4))) {
tlsSocket.end("MPROXY isn't supported yet");
return;
}
// things get a little messed up here
(state.httpTunnelServer || state.httpServer).emit('connection', tlsSocket);
});
});
state.tlsTunnelServer.on('tlsClientError', function () {
console.error('tlsClientError TunnelServer client error');
@ -148,38 +167,36 @@ module.exports.create = function (state) {
// tlsServer.emit('connection', socket); // this didn't work either
//console.log('chunkLen', firstChunk.byteLength);
console.log('httpsTunnel (Admin) servername', servername);
if (state.debug) { console.log('[Admin] new raw tls connection for', servername); }
state.tlsTunnelServer.emit('connection', wrapSocket(socket));
};
//
// First time setup
//
var serveSetup = require('serve-static')(__dirname + '/admin/setup', { redirect: true });
var serveSetup = require('serve-static')(__dirname + '/../admin/setup', { redirect: true });
var finalhandler = require('finalhandler');
state.httpSetupServer = http.createServer(function (req, res) {
console.log('req.socket.encrypted', req.socket.encrypted);
if (req.socket.encrypted) {
serveSetup(req, res, finalhandler(req, res));
return;
}
console.log('try greenlock middleware');
(state.greenlock && state.greenlock.middleware(redirectHttpsAndClose)
|| redirectHttpsAndClose)(req, res, function () {
console.log('fallthrough to setup ui');
console.log('[Setup] fallthrough to setup ui');
serveSetup(req, res, finalhandler(req, res));
});
});
state.tlsSetupServer = tls.createServer(setupTlsOpts, function (tlsSocket) {
console.log('tls connection');
console.log('[Setup] terminated tls connection');
// things get a little messed up here
state.httpSetupServer.emit('connection', tlsSocket);
});
state.tlsSetupServer.on('tlsClientError', function () {
console.error('tlsClientError SetupServer');
console.error('[Setup] tlsClientError SetupServer');
});
state.httpsSetupServer = function (servername, socket) {
console.log('httpsTunnel (Setup) servername', servername);
console.log('[Setup] raw tls connection for', servername);
state._servernames = [servername];
state.config.agreeTos = true; // TODO: BUG XXX BAD, make user accept
setupSniCallback = state.greenlock.tlsOptions.SNICallback || noSniCallback('setup');
@ -190,39 +207,38 @@ module.exports.create = function (state) {
// vhost
//
state.httpVhost = http.createServer(function (req, res) {
console.log('httpVhost (local)');
console.log('req.socket.encrypted', req.socket.encrypted);
if (state.debug) { console.log('[vhost] encrypted?', req.socket.encrypted); }
var finalhandler = require('finalhandler');
// TODO compare SNI to hostname?
var host = (req.headers.host||'').toLowerCase().trim();
var serveSetup = require('serve-static')(state.config.vhost.replace(/:hostname/g, host), { redirect: true });
var serveVhost = require('serve-static')(state.config.vhost.replace(/:hostname/g, host), { redirect: true });
if (req.socket.encrypted) { serveSetup(req, res, finalhandler(req, res)); return; }
if (req.socket.encrypted) { serveVhost(req, res, finalhandler(req, res)); return; }
console.log('try greenlock middleware for vhost');
(state.greenlock && state.greenlock.middleware(redirectHttpsAndClose)
|| redirectHttpsAndClose)(req, res, function () {
console.log('fallthrough to vhost serving???');
serveSetup(req, res, finalhandler(req, res));
});
if (!state.greenlock) {
console.error("Cannot vhost without greenlock options");
res.end("Cannot vhost without greenlock options");
}
state.greenlock.middleware(redirectHttpsAndClose);
});
state.tlsVhost = tls.createServer(
{ SNICallback: function (servername, cb) {
console.log('tlsVhost debug SNICallback', servername);
if (state.debug) { console.log('[vhost] SNICallback for', servername); }
tunnelAdminTlsOpts.SNICallback(servername, cb);
}
}
, function (tlsSocket) {
console.log('tlsVhost (local)');
if (state.debug) { console.log('tlsVhost (local)'); }
state.httpVhost.emit('connection', tlsSocket);
}
);
state.tlsVhost.on('tlsClientError', function () {
console.error('tlsClientError Vhost');
state.tlsVhost.on('tlsClientError', function (e) {
console.error('tlsClientError Vhost', e);
});
state.httpsVhost = function (servername, socket) {
console.log('httpsVhost (local)', servername);
if (state.debug) { console.log('[vhost] httpsVhost (local) for', servername); }
state.tlsVhost.emit('connection', wrapSocket(socket));
};
};

67
lib/pipe-ws.js Normal file
View File

@ -0,0 +1,67 @@
'use strict';
var Packer = require('proxy-packer');
module.exports = function pipeWs(servername, service, srv, conn, serviceport) {
var browserAddr = Packer.socketToAddr(conn);
var cid = Packer.addrToId(browserAddr);
browserAddr.service = service;
browserAddr.serviceport = serviceport;
browserAddr.name = servername;
conn.tunnelCid = cid;
var rid = Packer.socketToId(srv.upgradeReq.socket);
//if (state.debug) { console.log('[pipeWs] client', cid, '=> remote', rid, 'for', servername, 'via', service); }
function sendWs(data, serviceOverride) {
if (srv.ws && (!conn.tunnelClosing || serviceOverride)) {
try {
if (data && !Buffer.isBuffer(data)) {
data = Buffer.from(JSON.stringify(data));
}
srv.ws.send(Packer.packHeader(browserAddr, data, serviceOverride), { binary: true });
if (data) {
srv.ws.send(data, { binary: true });
}
// If we can't send data over the websocket as fast as this connection can send it to us
// (or there are a lot of connections trying to send over the same websocket) then we
// need to pause the connection for a little. We pause all connections if any are paused
// to make things more fair so a connection doesn't get stuck waiting for everyone else
// to finish because it got caught on the boundary. Also if serviceOverride is set it
// means the connection is over, so no need to pause it.
if (!serviceOverride && (srv.pausedConns.length || srv.ws.bufferedAmount > 1024*1024)) {
// console.log('pausing', cid, 'to allow web socket to catch up');
conn.pause();
srv.pausedConns.push(conn);
}
} catch (err) {
console.warn('[pipeWs] srv', rid, ' => client', cid, 'error sending websocket message', err);
}
}
}
srv.clients[cid] = conn;
conn.servername = servername;
conn.serviceport = serviceport;
conn.service = service;
// send peek at data too?
srv.ws.send(Packer.packHeader(browserAddr, null, 'connection'), { binary: true });
// TODO convert to read stream?
conn.on('data', function (chunk) {
//if (state.debug) { console.log('[pipeWs] client', cid, ' => srv', rid, chunk.byteLength, 'bytes'); }
sendWs(chunk);
});
conn.on('error', function (err) {
console.warn('[pipeWs] client', cid, 'connection error:', err);
});
conn.on('close', function (hadErr) {
//if (state.debug) { console.log('[pipeWs] client', cid, 'closing'); }
sendWs(null, hadErr ? 'error': 'end');
delete srv.clients[cid];
});
};

87
lib/relay.js Normal file
View File

@ -0,0 +1,87 @@
'use strict';
var Packer = require('proxy-packer');
var Devices = require('./device-tracker');
var Server = require('./server.js');
module.exports.store = { Devices: Devices };
module.exports.create = function (state) {
state.deviceLists = { _domains: {}, _devices: {} };
state.deviceCallbacks = {};
state.srvs = {};
if (!parseInt(state.activityTimeout, 10)) {
state.activityTimeout = 2 * 60 * 1000;
}
if (!parseInt(state.pongTimeout, 10)) {
state.pongTimeout = 10 * 1000;
}
state.Devices = Devices;
// TODO Use a Single TCP Handler
// Issues:
// * dynamic ports are dedicated to a device or cluster
// * servernames could come in on ports that belong to a different device
// * servernames could come in that belong to no device
// * this could lead to an attack / security vulnerability with ACME certificates
// Solutions
// * Restrict dynamic ports to a particular device
// * Restrict the use of servernames
function onWsConnection(_ws, _upgradeReq) {
var srv = {};
var initToken;
srv.ws = _ws;
srv.upgradeReq = _upgradeReq;
// TODO use device's ECDSA thumbprint as device id
srv.id = null;
srv.socketId = Packer.socketToId(srv.upgradeReq.socket);
srv.grants = {};
srv.clients = {};
srv.domainsMap = {};
srv.portsMap = {};
srv.pausedConns = [];
srv.domains = [];
srv.ports = [];
if (state.debug) { console.log('[ws] connection', srv.socketId); }
initToken = Server.parseAuth(state, srv);
srv.ws._socket.on('drain', function () {
// the websocket library has it's own buffer apart from node's socket buffer, but that one
// is much more difficult to watch, so we watch for the lower level buffer to drain and
// then check to see if the upper level buffer is still too full to write to. Note that
// the websocket library buffer has something to do with compression, so I'm not requiring
// that to be 0 before we start up again.
if (srv.ws.bufferedAmount > 128*1024) {
return;
}
srv.pausedConns.forEach(function (conn) {
if (!conn.manualPause) {
// console.log('resuming', conn.tunnelCid, 'now that the web socket has caught up');
conn.resume();
}
});
srv.pausedConns.length = 0;
});
if (initToken) {
return Server.addToken(state, srv, initToken).then(function () {
Server.init(state, srv);
}).catch(function (err) {
Server.sendTunnelMsg(srv, null, [0, err], 'control');
srv.ws.close();
});
} else {
return Server.init(state, srv);
}
}
return {
tcp: require('./unwrap-tls').createTcpConnectionHandler(state)
, ws: onWsConnection
, isClientDomain: Devices.exist.bind(null, state.deviceLists)
};
};

580
lib/server.js Normal file
View File

@ -0,0 +1,580 @@
'use strict';
var url = require('url');
var sni = require('sni');
var Packer = require('proxy-packer');
var PromiseA;
try {
PromiseA = require('bluebird');
} catch(e) {
PromiseA = global.Promise;
}
function timeoutPromise(duration) {
return new PromiseA(function (resolve) {
setTimeout(resolve, duration);
});
}
var Devices = require('./device-tracker');
var pipeWs = require('./pipe-ws.js');
var PortServers = {};
var Server = {
_initCommandHandlers: function (state, srv) {
var commandHandlers = {
add_token: function addToken(newAuth) {
return Server.addToken(state, srv, newAuth);
}
, delete_token: function (token) {
return state.Promise.resolve(function () {
var err;
if (token !== '*') {
err = Server.removeToken(state, srv, token);
if (err) { return state.Promise.reject(err); }
}
Object.keys(srv.grants).some(function (jwtoken) {
err = Server.removeToken(state, srv, jwtoken);
return err;
});
if (err) { return state.Promise.reject(err); }
return null;
});
}
};
commandHandlers.auth = commandHandlers.add_token;
commandHandlers.authn = commandHandlers.add_token;
commandHandlers.authz = commandHandlers.add_token;
srv._commandHandlers = commandHandlers;
}
, _initPackerHandlers: function (state, srv) {
var packerHandlers = {
oncontrol: function (tun) {
var cmd;
try {
cmd = JSON.parse(tun.data.toString());
} catch (e) {}
if (!Array.isArray(cmd) || typeof cmd[0] !== 'number') {
var msg = 'received bad command "' + tun.data.toString() + '"';
console.warn(msg, 'from websocket', srv.socketId);
Server.sendTunnelMsg(srv, null, [0, {message: msg, code: 'E_BAD_COMMAND'}], 'control');
return;
}
if (cmd[0] < 0) {
// We only ever send one command and we send it once, so we just hard coded the ID as 1.
if (cmd[0] === -1) {
if (cmd[1]) {
console.warn('received error response to hello from', srv.socketId, cmd[1]);
}
}
else {
console.warn('received response to unknown command', cmd, 'from', srv.socketId);
}
return;
}
if (cmd[0] === 0) {
console.warn('received dis-associated error from', srv.socketId, cmd[1]);
return;
}
function onSuccess() {
Server.sendTunnelMsg(srv, null, [-cmd[0], null], 'control');
}
function onError(err) {
Server.sendTunnelMsg(srv, null, [-cmd[0], err], 'control');
}
if (!srv._commandHandlers[cmd[1]]) {
onError({ message: 'unknown command "'+cmd[1]+'"', code: 'E_UNKNOWN_COMMAND' });
return;
}
console.log('command:', cmd[1], cmd.slice(2));
return srv._commandHandlers[cmd[1]].apply(null, cmd.slice(2)).then(onSuccess, onError);
}
, onconnection: function (/*tun*/) {
// I don't think this event can happen since this relay
// is acting the part of the client, but just in case...
// (in fact it should probably be explicitly disallowed)
console.error("[SANITY FAIL] reverse connection start");
}
, onmessage: function (tun) {
var cid = Packer.addrToId(tun);
if (state.debug) { console.log("remote '" + Server.logName(state, srv) + "' has data for '" + cid + "'", tun.data.byteLength); }
var browserConn = Server.getBrowserConn(state, srv, cid);
if (!browserConn) {
Server.sendTunnelMsg(srv, tun, {message: 'no matching connection', code: 'E_NO_CONN'}, 'error');
return;
}
browserConn.write(tun.data);
// tunnelRead is how many bytes we've read from the tunnel, and written to the browser.
browserConn.tunnelRead = (browserConn.tunnelRead || 0) + tun.data.byteLength;
// If we have more than 1MB buffered data we need to tell the other side to slow down.
// Once we've finished sending what we have we can tell the other side to keep going.
// If we've already sent the 'pause' message though don't send it again, because we're
// probably just dealing with data queued before our message got to them.
if (!browserConn.remotePaused && browserConn.bufferSize > 1024*1024) {
Server.sendTunnelMsg(srv, tun, browserConn.tunnelRead, 'pause');
browserConn.remotePaused = true;
browserConn.once('drain', function () {
Server.sendTunnelMsg(srv, tun, browserConn.tunnelRead, 'resume');
browserConn.remotePaused = false;
});
}
}
, onpause: function (tun) {
var cid = Packer.addrToId(tun);
console.log('[TunnelPause]', cid);
var browserConn = Server.getBrowserConn(state, srv, cid);
if (browserConn) {
browserConn.manualPause = true;
browserConn.pause();
} else {
Server.sendTunnelMsg(srv, tun, {message: 'no matching connection', code: 'E_NO_CONN'}, 'error');
}
}
, onresume: function (tun) {
var cid = Packer.addrToId(tun);
console.log('[TunnelResume]', cid);
var browserConn = Server.getBrowserConn(state, srv, cid);
if (browserConn) {
browserConn.manualPause = false;
browserConn.resume();
} else {
Server.sendTunnelMsg(srv, tun, {message: 'no matching connection', code: 'E_NO_CONN'}, 'error');
}
}
, onend: function (tun) {
var cid = Packer.addrToId(tun);
console.log('[TunnelEnd]', cid);
Server.closeBrowserConn(state, srv, cid);
}
, onerror: function (tun) {
var cid = Packer.addrToId(tun);
console.warn('[TunnelError]', cid, tun.message);
Server.closeBrowserConn(state, srv, cid);
}
};
srv._packerHandlers = packerHandlers;
srv.unpacker = Packer.create(srv._packerHandlers);
}
, _initSocketHandlers: function (state, srv) {
function refreshTimeout() {
srv.lastActivity = Date.now();
Devices.touchDevice(state.deviceLists, srv);
}
function checkTimeout() {
// Determine how long the connection has been "silent", ie no activity.
var silent = Date.now() - srv.lastActivity;
// If we have had activity within the last activityTimeout then all we need to do is
// call this function again at the soonest time when the connection could be timed out.
if (silent < state.activityTimeout) {
srv.timeoutId = setTimeout(checkTimeout, state.activityTimeout - silent);
}
// Otherwise we check to see if the pong has also timed out, and if not we send a ping
// and call this function again when the pong will have timed out.
else if (silent < state.activityTimeout + state.pongTimeout) {
if (state.debug) { console.log('pinging', Server.logName(state, srv)); }
try {
srv.ws.ping();
} catch (err) {
console.warn('failed to ping home cloud', Server.logName(state, srv));
}
srv.timeoutId = setTimeout(checkTimeout, state.pongTimeout);
}
// Last case means the ping we sent before didn't get a response soon enough, so we
// need to close the websocket connection.
else {
console.warn('home cloud', Server.logName(state, srv), 'connection timed out');
srv.ws.close(1013, 'connection timeout');
}
}
function forwardMessage(chunk) {
refreshTimeout();
if (state.debug) { console.log('[ws] device => client : demultiplexing message ', chunk.byteLength, 'bytes'); }
//console.log(chunk.toString());
srv.unpacker.fns.addChunk(chunk);
}
function hangup() {
clearTimeout(srv.timeoutId);
console.log('[ws] device hangup', Server.logName(state, srv), 'connection closing');
// remove the allowed domains from the list (but leave the socket)
Object.keys(srv.grants).forEach(function (jwtoken) {
Server.removeToken(state, srv, jwtoken);
});
srv.ws.terminate();
// remove the socket from the list, period
Devices.close(state.deviceLists, srv);
}
srv.lastActivity = Date.now();
srv.timeoutId = null;
srv.timeoutId = setTimeout(checkTimeout, state.activityTimeout);
// Note that our websocket library automatically handles pong responses on ping requests
// before it even emits the event.
srv.ws.on('ping', refreshTimeout);
srv.ws.on('pong', refreshTimeout);
srv.ws.on('message', forwardMessage);
srv.ws.on('close', hangup);
srv.ws.on('error', hangup);
}
, init: function init(state, srv) {
Server._initCommandHandlers(state, srv);
Server._initPackerHandlers(state, srv);
Server._initSocketHandlers(state, srv);
// Status Code '1' for Status 'hello'
Server.sendTunnelMsg(srv, null, [1, 'hello', [srv.unpacker._version], Object.keys(srv._commandHandlers)], 'control');
}
, sendTunnelMsg: function sendTunnelMsg(srv, addr, data, service) {
if (data && !Buffer.isBuffer()) {
data = Buffer.from(JSON.stringify(data));
}
srv.ws.send(Packer.packHeader(addr, data, service), {binary: true});
srv.ws.send(data, {binary: true});
}
, logName: function logName(state, srv) {
var result = Object.keys(srv.grants).map(function (jwtoken) {
return srv.grants[jwtoken].currentDesc;
}).join(';');
return result || srv.socketId;
}
, onAuth: function onAuth(state, srv, rawAuth, grant) {
console.log('\n[relay.js] onAuth');
console.log(rawAuth);
//console.log(grant);
//var stringauth;
var err;
if (!grant || 'object' !== typeof grant) {
console.log('[relay.js] invalid token', grant);
err = new Error("invalid access token");
err.code = "E_INVALID_TOKEN";
return state.Promise.reject(err);
}
// deprecated (for json object on connect)
if ('string' !== typeof rawAuth) {
rawAuth = JSON.stringify(rawAuth);
}
// TODO don't fire the onAuth event on non-authz updates
if (!grant.jwt && !(grant.domains||[]).length && !(grant.ports||[]).length) {
console.log("[onAuth] nothing to offer at all");
return null;
}
console.log('[onAuth] check for upgrade token');
//console.log(grant);
if (grant.jwt) {
if (rawAuth !== grant.jwt) {
console.log('[onAuth] token is new');
}
// TODO only send token when new
if (true) {
// Access Token
console.log('[onAuth] sending back token');
Server.sendTunnelMsg(
srv
, null
, [ 3
, 'access_token'
, { jwt: grant.jwt }
]
, 'control'
);
// these aren't needed internally once they're sent
grant.jwt = null;
}
}
/*
if (!Array.isArray(grant.domains) || !grant.domains.length) {
err = new Error("invalid domains array");
err.code = "E_INVALID_NAME";
return state.Promise.reject(err);
}
*/
if (grant.domains.some(function (name) { return typeof name !== 'string'; })) {
console.log('bad domain names');
err = new Error("invalid domain name(s)");
err.code = "E_INVALID_NAME";
return state.Promise.reject(err);
}
console.log('[onAuth] strolling through pleasantries');
// Add the custom properties we need to manage this remote, then add it to all the relevant
// domains and the list of all this websocket's grants.
grant.domains.forEach(function (domainname) {
console.log('add', domainname, 'to device lists');
srv.domainsMap[domainname] = true;
Devices.add(state.deviceLists, domainname, srv);
// TODO allow subs to go to individual devices
Devices.alias(state.deviceLists, domainname, '*.' + domainname);
});
srv.domains = Object.keys(srv.domainsMap);
srv.currentDesc = (grant.device && (grant.device.id || grant.device.hostname)) || srv.domains.join(',');
grant.currentDesc = (grant.device && (grant.device.id || grant.device.hostname)) || grant.domains.join(',');
//grant.srv = srv;
//grant.ws = srv.ws;
//grant.upgradeReq = srv.upgradeReq;
grant.clients = {};
if (!grant.ports) { grant.ports = []; }
function openPort(serviceport) {
function tcpListener(conn) {
Server.onDynTcpConn(state, srv, srv.portsMap[serviceport], conn);
}
serviceport = parseInt(serviceport, 10) || 0;
if (!serviceport) {
// TODO error message about bad port
return;
}
if (PortServers[serviceport]) {
console.log('reuse', serviceport, 'for this connection');
//grant.ports = [];
srv.portsMap[serviceport] = PortServers[serviceport];
srv.portsMap[serviceport].on('connection', tcpListener);
srv.portsMap[serviceport].tcpListener = tcpListener;
Devices.addPort(state.deviceLists, serviceport, srv);
} else {
try {
console.log('use new', serviceport, 'for this connection');
srv.portsMap[serviceport] = PortServers[serviceport] = require('net').createServer(tcpListener);
srv.portsMap[serviceport].tcpListener = tcpListener;
srv.portsMap[serviceport].listen(serviceport, function () {
console.info('[DynTcpConn] Port', serviceport, 'now open for', grant.currentDesc);
Devices.addPort(state.deviceLists, serviceport, srv);
});
srv.portsMap[serviceport].on('error', function (e) {
// TODO try again with random port
console.error("Server Error assigning a dynamic port to a new connection:", e);
});
} catch(e) {
// what a wonderful problem it will be the day that this bug needs to be fixed
// (i.e. there are enough users to run out of ports)
console.error("Error assigning a dynamic port to a new connection:", e);
}
}
}
grant.ports.forEach(openPort);
console.info("[ws] authorized", srv.socketId, "for", grant.currentDesc);
console.log('notify of grants', grant.domains, grant.ports);
srv.grants[rawAuth] = grant;
Server.sendTunnelMsg(
srv
, null
, [ 2
, 'grant'
, [ ['ssh+https', grant.domains[0], 443 ]
// TODO the shared domain should be token specific
, ['ssh', 'ssh.' + state.config.sharedDomain, [grant.ports[0]] ]
, ['tcp', 'tcp.' + state.config.sharedDomain, [grant.ports[0]] ]
, ['https', grant.domains[0] ]
]
]
, 'control'
);
return null;
}
, onDynTcpConn: function onDynTcpConn(state, srv, server, conn) {
var serviceport = server.address().port;
console.log('[DynTcpConn] new connection on', serviceport);
var nextDevice = Devices.next(state.deviceLists, serviceport);
if (!nextDevice) {
conn.write("[Sanity Error] I've got a blank space baby, but nowhere to write your name.");
conn.end();
try {
server.close();
} catch(e) {
console.error("[DynTcpConn] failed to close server:", e);
}
return;
}
// When using raw TCP we're already paired to the client by port
// and we can begin connecting right away, but we'll wait just a sec
// to reject known bad connections
var sendConnection = setTimeout(function () {
conn.removeListener('data', peekFirstPacket);
console.log("[debug tcp conn] Connecting possible telnet client to device...");
pipeWs(null, 'tcp', nextDevice, conn, serviceport);
}, 350);
function peekFirstPacket(firstChunk) {
clearTimeout(sendConnection);
if (state.debug) { console.log("[DynTcp]", serviceport, "examining firstChunk from", Packer.socketToId(conn)); }
conn.pause();
//conn.unshift(firstChunk);
conn._handle.onread(firstChunk.length, firstChunk);
var servername;
var hostname;
var str;
var m;
if (22 === firstChunk[0]) {
servername = (sni(firstChunk)||'').toLowerCase();
} else if (firstChunk[0] > 32 && firstChunk[0] < 127) {
str = firstChunk.toString();
m = str.match(/(?:^|[\r\n])Host: ([^\r\n]+)[\r\n]*/im);
hostname = (m && m[1].toLowerCase() || '').split(':')[0];
}
if (servername || hostname) {
if (servername) {
conn.write("TLS with sni is allowed only on standard ports. If you've registered '" + servername + "' use port 443.");
} else {
conn.write("HTTP with Host headers is not allowed on dynamic ports. If you've registered '" + hostname + "' use port 80.");
}
conn.end();
return;
}
// pipeWs(servername, servicename, srv, client, serviceport)
// remote.clients is managed as part of the piping process
if (state.debug) { console.log("[DynTcp]", serviceport, "piping to srv (via loadbal)"); }
pipeWs(null, 'tcp', nextDevice, conn, serviceport);
process.nextTick(function () { conn.resume(); });
}
conn.once('data', peekFirstPacket);
}
, addToken: function addToken(state, srv, rawAuth) {
console.log("[addToken]", rawAuth);
if (srv.grants[rawAuth]) {
console.log("addToken - duplicate");
// return { message: "token sent multiple times", code: "E_TOKEN_REPEAT" };
return state.Promise.resolve(null);
}
// [Extension] [Auth] This is where authentication is either handed off to
// an extension or the default authencitation handler.
return state.authenticate({ auth: rawAuth }).then(function (validatedTokenData) {
console.log('\n[relay.js] rawAuth');
console.log(rawAuth);
console.log('\n[relay.js] authnToken');
console.log(validatedTokenData);
// For tracking state between token exchanges
// and tacking on extra attributes (i.e. for extensions)
// TODO close on delete
if (!state.srvs[validatedTokenData.id]) {
state.srvs[validatedTokenData.id] = {};
}
if (!state.srvs[validatedTokenData.id].updateAuth) {
// be sure to always pass latest srv since the connection may change
// and reuse the same token
state.srvs[validatedTokenData.id].updateAuth = function (srv, validatedTokenData) {
return Server.onAuth(state, srv, rawAuth, validatedTokenData);
};
}
state.srvs[validatedTokenData.id].updateAuth(srv, validatedTokenData);
});
}
, removeToken: function removeToken(state, srv, jwtoken) {
var grant = srv.grants[jwtoken];
if (!grant) {
return { message: 'specified token not present', code: 'E_INVALID_TOKEN'};
}
// Prevent any more browser connections for this grant being sent to this srv,
// and any existing connections from trying to send more data across the connection.
grant.domains.forEach(function (domainname) {
Devices.remove(state.deviceLists, domainname, srv);
});
grant.ports.forEach(function (portnumber) {
Devices.remove(state.deviceLists, portnumber, srv);
if (!srv.portsMap[portnumber]) { return; }
try {
srv.portsMap[portnumber].close(function () {
console.log("[DynTcpConn] closing server for ", portnumber);
delete srv.portsMap[portnumber];
delete PortServers[portnumber];
});
} catch(e) { /*ignore*/ }
});
// Close all of the existing browser connections associated with this websocket connection.
Object.keys(grant.clients).forEach(function (cid) {
Server.closeBrowserConn(state, srv, cid);
});
delete srv.grants[jwtoken];
console.log("[ws] removed token '" + grant.currentDesc + "' from", srv.socketId);
return null;
}
, getBrowserConn: function getBrowserConn(state, srv, cid) {
return srv.clients[cid];
}
, closeBrowserConn: function closeBrowserConn(state, srv, cid) {
if (!srv.clients[cid]) {
return;
}
PromiseA.resolve().then(function () {
var conn = srv.clients[cid];
conn.tunnelClosing = true;
conn.end();
// If no data is buffered for writing then we don't need to wait for it to drain.
if (!conn.bufferSize) {
return timeoutPromise(500);
}
// Otherwise we want the connection to be able to finish, but we also want to impose
// a time limit for it to drain, since it shouldn't have more than 1MB buffered.
return new PromiseA(function (resolve) {
var timeoutId = setTimeout(resolve, 60*1000);
conn.once('drain', function () {
clearTimeout(timeoutId);
setTimeout(resolve, 500);
});
});
}).then(function () {
if (srv.clients[cid]) {
console.warn(cid, 'browser connection still present after calling `end`');
srv.clients[cid].destroy();
return timeoutPromise(500);
}
}).then(function () {
if (srv.clients[cid]) {
console.error(cid, 'browser connection still present after calling `destroy`');
delete srv.clients[cid];
}
}).catch(function (err) {
console.warn('failed to close browser connection', cid, err);
});
}
, parseAuth: function parseAuth(state, srv) {
var authn = (srv.upgradeReq.headers.authorization||'').split(/\s+/);
if (authn[0] && 'basic' === authn[0].toLowerCase()) {
try {
authn = new Buffer(authn[1], 'base64').toString('ascii').split(':');
return authn[1];
} catch (err) { }
}
return url.parse(srv.upgradeReq.url, true).query.access_token;
}
};
module.exports = Server;

View File

@ -1,57 +1,23 @@
'use strict';
var packer = require('tunnel-packer');
var sni = require('sni');
var pipeWs = require('./pipe-ws.js');
var ago = require('./ago.js').AGO;
var up = Date.now();
function pipeWs(servername, service, conn, remote) {
console.log('[pipeWs] servername:', servername, 'service:', service);
var browserAddr = packer.socketToAddr(conn);
browserAddr.service = service;
var cid = packer.addrToId(browserAddr);
conn.tunnelCid = cid;
console.log('[pipeWs] browser is', cid, 'home-cloud is', packer.socketToId(remote.upgradeReq.socket));
function sendWs(data, serviceOverride) {
if (remote.ws && (!conn.tunnelClosing || serviceOverride)) {
try {
remote.ws.send(packer.pack(browserAddr, data, serviceOverride), { binary: true });
// If we can't send data over the websocket as fast as this connection can send it to us
// (or there are a lot of connections trying to send over the same websocket) then we
// need to pause the connection for a little. We pause all connections if any are paused
// to make things more fair so a connection doesn't get stuck waiting for everyone else
// to finish because it got caught on the boundary. Also if serviceOverride is set it
// means the connection is over, so no need to pause it.
if (!serviceOverride && (remote.pausedConns.length || remote.ws.bufferedAmount > 1024*1024)) {
// console.log('pausing', cid, 'to allow web socket to catch up');
conn.pause();
remote.pausedConns.push(conn);
}
} catch (err) {
console.warn('[pipeWs] error sending websocket message', err);
}
}
function fromUptime(ms) {
if (ms) {
return ago(Date.now() - ms);
} else {
return "Not seen since relay restarted, " + ago(Date.now() - up);
}
remote.clients[cid] = conn;
conn.on('data', function (chunk) {
console.log('[pipeWs] data from browser to tunneler', chunk.byteLength);
sendWs(chunk);
});
conn.on('error', function (err) {
console.warn('[pipeWs] browser connection error', err);
});
conn.on('close', function (hadErr) {
console.log('[pipeWs] browser connection closing');
sendWs(null, hadErr ? 'error': 'end');
delete remote.clients[cid];
});
}
module.exports.createTcpConnectionHandler = function (copts) {
var Devices = copts.Devices;
module.exports.createTcpConnectionHandler = function (state) {
var Devices = state.Devices;
return function onTcpConnection(conn) {
return function onTcpConnection(conn, serviceport) {
serviceport = serviceport || conn.localPort;
// this works when I put it here, but I don't know if it's tls yet here
// httpsServer.emit('connection', socket);
//tls3000.emit('connection', socket);
@ -62,7 +28,28 @@ module.exports.createTcpConnectionHandler = function (copts) {
//});
//return;
conn.once('data', function (firstChunk) {
//conn.once('data', function (firstChunk) {
//});
conn.once('readable', function () {
var firstChunk = conn.read();
var service = 'tcp';
var servername;
var str;
var m;
if (!firstChunk) {
try {
conn.end();
} catch(e) {
console.error("[lib/unwrap-tls.js] Error:", e);
conn.destroy();
}
return;
}
//conn.pause();
conn.unshift(firstChunk);
// BUG XXX: this assumes that the packet won't be chunked smaller
// than the 'hello' or the point of the 'Host' header.
// This is fairly reasonable, but there are edge cases where
@ -70,110 +57,154 @@ module.exports.createTcpConnectionHandler = function (copts) {
// and so it should be fixed at some point in the future
// defer after return (instead of being in many places)
process.nextTick(function () {
conn.unshift(firstChunk);
});
function deferData(fn) {
if ('httpsInvalid' === fn) {
state[fn]({
servername: servername
, ago: fromUptime(Devices.lastSeen(state.deviceLists, servername))
}, conn);
} else if (fn) {
state[fn](servername, conn);
} else {
console.error("[SANITY ERROR] '" + fn + "' doesn't have a state handler");
}
/*
process.nextTick(function () {
conn.resume();
});
*/
}
var service = 'tcp';
var servername;
var str;
var m;
var httpOutcomes = {
missingServername: function () {
console.log("[debug] [http] missing servername");
// TODO use a more specific error page
deferData('handleInsecureHttp');
}
, requiresSetup: function () {
console.log("[debug] [http] requires setup");
// TODO Insecure connections for setup will not work on secure domains (i.e. .app)
state.httpSetupServer.emit('connection', conn);
}
, isInternal: function () {
console.log("[debug] [http] is known internally (admin)");
if (/well-known/.test(str)) {
deferData('handleHttp');
} else {
deferData('handleInsecureHttp');
}
}
, isVhost: function () {
console.log("[debug] [http] is vhost (normal server)");
if (/well-known/.test(str)) {
deferData('handleHttp');
} else {
deferData('handleInsecureHttp');
}
}
, assumeExternal: function () {
console.log("[debug] [http] assume external");
var service = 'http';
function tryTls() {
if (!Devices.exist(state.deviceLists, servername)) {
// It would be better to just re-read the host header rather
// than creating a whole server object, but this is a "rare"
// case and I'm feeling lazy right now.
console.log("[debug] [http] no device connected");
state.createHttpInvalid({
servername: servername
, ago: fromUptime(Devices.lastSeen(state.deviceLists, servername))
}).emit('connection', conn);
return;
}
// TODO make https redirect configurable on a per-domain basis
// /^\/\.well-known\/acme-challenge\//.test(str)
if (/well-known/.test(str)) {
// HTTP
console.log("[debug] [http] passthru");
pipeWs(servername, service, Devices.next(state.deviceLists, servername), conn, serviceport);
return;
} else {
console.log("[debug] [http] redirect to https");
deferData('handleInsecureHttp');
}
}
};
var tlsOutcomes = {
missingServername: function () {
if (state.debug) { console.log("No SNI was given, so there's nothing we can do here"); }
deferData('httpsInvalid');
}
, requiresSetup: function () {
console.info("[Setup] https => admin => setup => (needs bogus tls certs to start?)");
deferData('httpsSetupServer');
}
, isInternal: function () {
if (state.debug) { console.log("[Admin]", servername); }
deferData('httpsTunnel');
}
, isVhost: function (vhost) {
if (state.debug) { console.log("[tcp] [vhost]", state.config.vhost, "=>", vhost); }
deferData('httpsVhost');
}
, assumeExternal: function () {
var nextDevice = Devices.next(state.deviceLists, servername);
if (!nextDevice) {
if (state.debug) { console.log("No devices match the given servername"); }
deferData('httpsInvalid');
return;
}
if (state.debug) { console.log("pipeWs(servername, service, deviceLists['" + servername + "'], socket)"); }
pipeWs(servername, service, nextDevice, conn, serviceport);
}
};
function handleConnection(outcomes) {
var vhost;
console.log("");
// No routing information available
if (!servername) { outcomes.missingServername(); return; }
// Server needs to be set up
if (!state.servernames.length) { outcomes.requiresSetup(); return; }
// This is one of the admin domains
if (-1 !== state.servernames.indexOf(servername)) { outcomes.isInternal(); return; }
if (!copts.servernames.length) {
console.log("https => admin => setup => (needs bogus tls certs to start?)");
copts.httpsSetupServer(servername, conn);
return;
}
if (-1 !== copts.servernames.indexOf(servername)) {
console.log("Lock and load, admin interface time!");
copts.httpsTunnel(servername, conn);
return;
}
if (copts.config.nowww && /^www\./i.test(servername)) {
console.log("TODO: use www bare redirect");
}
function run() {
if (!servername) {
console.log("No SNI was given, so there's nothing we can do here");
copts.httpsInvalid(servername, conn);
return;
}
var nextDevice = Devices.next(copts.deviceLists, servername);
if (!nextDevice) {
console.log("No devices match the given servername");
copts.httpsInvalid(servername, conn);
return;
}
console.log("pipeWs(servername, service, socket, deviceLists['" + servername + "'])");
pipeWs(servername, service, conn, nextDevice);
}
if (copts.config.vhost) {
console.log("VHOST path", copts.config.vhost);
vhost = copts.config.vhost.replace(/:hostname/, (servername||''));
console.log("VHOST name", vhost);
conn.pause();
//copts.httpsVhost(servername, conn);
//return;
// TODO don't run an fs check if we already know this is working elsewhere
//if (!state.validHosts) { state.validHosts = {}; }
if (state.config.vhost) {
vhost = state.config.vhost.replace(/:hostname/, servername);
require('fs').readdir(vhost, function (err, nodes) {
console.log("VHOST error?", err);
if (err) { run(); return; }
if (nodes) { copts.httpsVhost(servername, conn); }
if (state.debug && err) { console.log("VHOST error", err); }
if (err || !nodes) { outcomes.assumeExternal(); return; }
outcomes.isVhost(vhost);
});
return;
}
run();
outcomes.assumeExternal();
}
// https://github.com/mscdex/httpolyglot/issues/3#issuecomment-173680155
if (22 === firstChunk[0]) {
// TLS
service = 'https';
servername = (sni(firstChunk)||'').toLowerCase();
console.log("tls hello servername:", servername);
tryTls();
servername = (sni(firstChunk)||'').toLowerCase().trim();
if (state.debug) { console.log("[tcp] tls hello from '" + servername + "'"); }
handleConnection(tlsOutcomes);
return;
}
if (firstChunk[0] > 32 && firstChunk[0] < 127) {
// (probably) HTTP
str = firstChunk.toString();
m = str.match(/(?:^|[\r\n])Host: ([^\r\n]+)[\r\n]*/im);
servername = (m && m[1].toLowerCase() || '').split(':')[0];
console.log('servername', servername);
if (state.debug) { console.log("[tcp] http hostname '" + servername + "'"); }
if (/HTTP\//i.test(str)) {
if (!copts.servernames.length) {
console.log('copts.httpSetupServer', copts.httpSetupServer);
copts.httpSetupServer.emit('connection', conn);
return;
}
service = 'http';
// TODO make https redirect configurable
// /^\/\.well-known\/acme-challenge\//.test(str)
if (/well-known/.test(str)) {
// HTTP
if (Devices.exist(copts.deviceLists, servername)) {
pipeWs(servername, service, conn, Devices.next(copts.deviceLists, servername));
return;
}
copts.handleHttp(servername, conn);
}
else {
// redirect to https
copts.handleInsecureHttp(servername, conn);
}
handleConnection(httpOutcomes);
return;
}
}

View File

@ -1,17 +1,17 @@
{
"name": "telebitd",
"version": "0.11.0",
"name": "telebit-relay",
"version": "0.20.0",
"description": "Friends don't let friends localhost. Expose your bits with a secure connection even from behind NAT, Firewalls, in a box, with a fox, on a train or in a plane... or a Raspberry Pi in your closet. An attempt to create a better localtunnel.me server, a more open ngrok. Uses Automated HTTPS (Free SSL) via ServerName Indication (SNI). Can also tunnel tls and plain tcp.",
"main": "telebitd.js",
"main": "lib/relay.js",
"bin": {
"telebitd": "bin/telebitd.js"
"telebit-relay": "bin/telebit-relay.js"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "https://git.coolaj86.com/coolaj86/telebitd.js.git"
"url": "https://git.coolaj86.com/coolaj86/telebit-relay.js.git"
},
"keywords": [
"http",
@ -33,21 +33,26 @@
"author": "AJ ONeal <coolaj86@gmail.com> (https://coolaj86.com/)",
"license": "(MIT OR Apache-2.0)",
"bugs": {
"url": "https://git.coolaj86.com/coolaj86/telebitd.js/issues"
"url": "https://git.coolaj86.com/coolaj86/telebit-relay.js/issues"
},
"homepage": "https://git.coolaj86.com/coolaj86/telebitd.js",
"homepage": "https://git.coolaj86.com/coolaj86/telebit-relay.js",
"dependencies": {
"bluebird": "^3.5.1",
"cluster-store": "^2.0.8",
"finalhandler": "^1.1.1",
"greenlock": "^2.2.4",
"human-readable-ids": "^1.0.4",
"js-yaml": "^3.11.0",
"jsonwebtoken": "^8.2.1",
"jsonwebtoken": "^8.3.0",
"proxy-packer": "^2.0.0",
"recase": "^1.0.4",
"redirect-https": "^1.1.5",
"serve-static": "^1.13.2",
"sni": "^1.0.0",
"tunnel-packer": "^1.4.0",
"ws": "^5.1.1"
},
"engineStrict": true,
"engines": {
"node": "10.2.1"
}
}

24
snap/snapcraft.yaml Normal file
View File

@ -0,0 +1,24 @@
name: telebit-relay
version: '0.20.0'
summary: Because friends don't let friends localhost
description: |
A server that works in combination with Telebit Remote
to allow you to serve http and https from any computer,
anywhere through a secure tunnel.
grade: stable
confinement: strict
apps:
telebit-relay:
command: telebit-relay --config $SNAP_COMMON/config.yml
plugs: [network, network-bind]
daemon: simple
parts:
telebit-relay:
plugin: nodejs
node-engine: 10.13.0
source: .
override-build: |
snapcraftctl build

View File

@ -1,395 +0,0 @@
'use strict';
var url = require('url');
var PromiseA = require('bluebird');
var jwt = require('jsonwebtoken');
var packer = require('tunnel-packer');
function timeoutPromise(duration) {
return new PromiseA(function (resolve) {
setTimeout(resolve, duration);
});
}
var Devices = require('./lib/device-tracker');
module.exports.store = { Devices: Devices };
module.exports.create = function (copts) {
copts.deviceLists = {};
//var deviceLists = {};
var activityTimeout = copts.activityTimeout || 2*60*1000;
var pongTimeout = copts.pongTimeout || 10*1000;
copts.Devices = Devices;
var onTcpConnection = require('./lib/unwrap-tls').createTcpConnectionHandler(copts);
function onWsConnection(ws, upgradeReq) {
console.log(ws);
var socketId = packer.socketToId(upgradeReq.socket);
var remotes = {};
function logName() {
var result = Object.keys(remotes).map(function (jwtoken) {
return remotes[jwtoken].deviceId;
}).join(';');
return result || socketId;
}
function sendTunnelMsg(addr, data, service) {
ws.send(packer.pack(addr, data, service), {binary: true});
}
function getBrowserConn(cid) {
var browserConn;
Object.keys(remotes).some(function (jwtoken) {
if (remotes[jwtoken].clients[cid]) {
browserConn = remotes[jwtoken].clients[cid];
return true;
}
});
return browserConn;
}
function closeBrowserConn(cid) {
var remote;
Object.keys(remotes).some(function (jwtoken) {
if (remotes[jwtoken].clients[cid]) {
remote = remotes[jwtoken];
return true;
}
});
if (!remote) {
return;
}
PromiseA.resolve().then(function () {
var conn = remote.clients[cid];
conn.tunnelClosing = true;
conn.end();
// If no data is buffered for writing then we don't need to wait for it to drain.
if (!conn.bufferSize) {
return timeoutPromise(500);
}
// Otherwise we want the connection to be able to finish, but we also want to impose
// a time limit for it to drain, since it shouldn't have more than 1MB buffered.
return new PromiseA(function (resolve) {
var timeoutId = setTimeout(resolve, 60*1000);
conn.once('drain', function () {
clearTimeout(timeoutId);
setTimeout(resolve, 500);
});
});
}).then(function () {
if (remote.clients[cid]) {
console.warn(cid, 'browser connection still present after calling `end`');
remote.clients[cid].destroy();
return timeoutPromise(500);
}
}).then(function () {
if (remote.clients[cid]) {
console.error(cid, 'browser connection still present after calling `destroy`');
delete remote.clients[cid];
}
}).catch(function (err) {
console.warn('failed to close browser connection', cid, err);
});
}
function addToken(jwtoken) {
if (remotes[jwtoken]) {
// return { message: "token sent multiple times", code: "E_TOKEN_REPEAT" };
return null;
}
var token;
try {
token = jwt.verify(jwtoken, copts.secret);
} catch (e) {
token = null;
}
if (!token) {
return { message: "invalid access token", code: "E_INVALID_TOKEN" };
}
if (!Array.isArray(token.domains)) {
if ('string' === typeof token.name) {
token.domains = [ token.name ];
}
}
if (!Array.isArray(token.domains) || !token.domains.length) {
return { message: "invalid server name", code: "E_INVALID_NAME" };
}
if (token.domains.some(function (name) { return typeof name !== 'string'; })) {
return { message: "invalid server name", code: "E_INVALID_NAME" };
}
// Add the custom properties we need to manage this remote, then add it to all the relevant
// domains and the list of all this websocket's remotes.
token.deviceId = (token.device && (token.device.id || token.device.hostname)) || token.domains.join(',');
token.ws = ws;
token.upgradeReq = upgradeReq;
token.clients = {};
token.pausedConns = [];
ws._socket.on('drain', function () {
// the websocket library has it's own buffer apart from node's socket buffer, but that one
// is much more difficult to watch, so we watch for the lower level buffer to drain and
// then check to see if the upper level buffer is still too full to write to. Note that
// the websocket library buffer has something to do with compression, so I'm not requiring
// that to be 0 before we start up again.
if (ws.bufferedAmount > 128*1024) {
return;
}
token.pausedConns.forEach(function (conn) {
if (!conn.manualPause) {
// console.log('resuming', conn.tunnelCid, 'now that the web socket has caught up');
conn.resume();
}
});
token.pausedConns.length = 0;
});
token.domains.forEach(function (domainname) {
console.log('domainname', domainname);
Devices.add(copts.deviceLists, domainname, token);
});
remotes[jwtoken] = token;
console.log("added token '" + token.deviceId + "' to websocket", socketId);
return null;
}
function removeToken(jwtoken) {
var remote = remotes[jwtoken];
if (!remote) {
return { message: 'specified token not present', code: 'E_INVALID_TOKEN'};
}
// Prevent any more browser connections being sent to this remote, and any existing
// connections from trying to send more data across the connection.
remote.domains.forEach(function (domainname) {
Devices.remove(copts.deviceLists, domainname, remote);
});
remote.ws = null;
remote.upgradeReq = null;
// Close all of the existing browser connections associated with this websocket connection.
Object.keys(remote.clients).forEach(function (cid) {
closeBrowserConn(cid);
});
delete remotes[jwtoken];
console.log("removed token '" + remote.deviceId + "' from websocket", socketId);
return null;
}
var firstToken;
var authn = (upgradeReq.headers.authorization||'').split(/\s+/);
if (authn[0] && 'basic' === authn[0].toLowerCase()) {
try {
authn = new Buffer(authn[1], 'base64').toString('ascii').split(':');
firstToken = authn[1];
} catch (err) { }
}
if (!firstToken) {
firstToken = url.parse(upgradeReq.url, true).query.access_token;
}
if (firstToken) {
var err = addToken(firstToken);
if (err) {
sendTunnelMsg(null, [0, err], 'control');
ws.close();
return;
}
}
var commandHandlers = {
add_token: addToken
, delete_token: function (token) {
if (token !== '*') {
return removeToken(token);
}
var err;
Object.keys(remotes).some(function (jwtoken) {
err = removeToken(jwtoken);
return err;
});
return err;
}
};
var packerHandlers = {
oncontrol: function (opts) {
var cmd, err;
try {
cmd = JSON.parse(opts.data.toString());
} catch (err) {}
if (!Array.isArray(cmd) || typeof cmd[0] !== 'number') {
var msg = 'received bad command "' + opts.data.toString() + '"';
console.warn(msg, 'from websocket', socketId);
sendTunnelMsg(null, [0, {message: msg, code: 'E_BAD_COMMAND'}], 'control');
return;
}
if (cmd[0] < 0) {
// We only ever send one command and we send it once, so we just hard coded the ID as 1.
if (cmd[0] === -1) {
if (cmd[1]) {
console.log('received error response to hello from', socketId, cmd[1]);
}
}
else {
console.warn('received response to unknown command', cmd, 'from', socketId);
}
return;
}
if (cmd[0] === 0) {
console.warn('received dis-associated error from', socketId, cmd[1]);
return;
}
if (commandHandlers[cmd[1]]) {
err = commandHandlers[cmd[1]].apply(null, cmd.slice(2));
}
else {
err = { message: 'unknown command "'+cmd[1]+'"', code: 'E_UNKNOWN_COMMAND' };
}
sendTunnelMsg(null, [-cmd[0], err], 'control');
}
, onmessage: function (opts) {
var cid = packer.addrToId(opts);
console.log("remote '" + logName() + "' has data for '" + cid + "'", opts.data.byteLength);
var browserConn = getBrowserConn(cid);
if (!browserConn) {
sendTunnelMsg(opts, {message: 'no matching connection', code: 'E_NO_CONN'}, 'error');
return;
}
browserConn.write(opts.data);
// tunnelRead is how many bytes we've read from the tunnel, and written to the browser.
browserConn.tunnelRead = (browserConn.tunnelRead || 0) + opts.data.byteLength;
// If we have more than 1MB buffered data we need to tell the other side to slow down.
// Once we've finished sending what we have we can tell the other side to keep going.
// If we've already sent the 'pause' message though don't send it again, because we're
// probably just dealing with data queued before our message got to them.
if (!browserConn.remotePaused && browserConn.bufferSize > 1024*1024) {
sendTunnelMsg(opts, browserConn.tunnelRead, 'pause');
browserConn.remotePaused = true;
browserConn.once('drain', function () {
sendTunnelMsg(opts, browserConn.tunnelRead, 'resume');
browserConn.remotePaused = false;
});
}
}
, onpause: function (opts) {
var cid = packer.addrToId(opts);
console.log('[TunnelPause]', cid);
var browserConn = getBrowserConn(cid);
if (browserConn) {
browserConn.manualPause = true;
browserConn.pause();
} else {
sendTunnelMsg(opts, {message: 'no matching connection', code: 'E_NO_CONN'}, 'error');
}
}
, onresume: function (opts) {
var cid = packer.addrToId(opts);
console.log('[TunnelResume]', cid);
var browserConn = getBrowserConn(cid);
if (browserConn) {
browserConn.manualPause = false;
browserConn.resume();
} else {
sendTunnelMsg(opts, {message: 'no matching connection', code: 'E_NO_CONN'}, 'error');
}
}
, onend: function (opts) {
var cid = packer.addrToId(opts);
console.log('[TunnelEnd]', cid);
closeBrowserConn(cid);
}
, onerror: function (opts) {
var cid = packer.addrToId(opts);
console.log('[TunnelError]', cid, opts.message);
closeBrowserConn(cid);
}
};
var unpacker = packer.create(packerHandlers);
var lastActivity = Date.now();
var timeoutId;
function refreshTimeout() {
lastActivity = Date.now();
}
function checkTimeout() {
// Determine how long the connection has been "silent", ie no activity.
var silent = Date.now() - lastActivity;
// If we have had activity within the last activityTimeout then all we need to do is
// call this function again at the soonest time when the connection could be timed out.
if (silent < activityTimeout) {
timeoutId = setTimeout(checkTimeout, activityTimeout-silent);
}
// Otherwise we check to see if the pong has also timed out, and if not we send a ping
// and call this function again when the pong will have timed out.
else if (silent < activityTimeout + pongTimeout) {
console.log('pinging', logName());
try {
ws.ping();
} catch (err) {
console.warn('failed to ping home cloud', logName());
}
timeoutId = setTimeout(checkTimeout, pongTimeout);
}
// Last case means the ping we sent before didn't get a response soon enough, so we
// need to close the websocket connection.
else {
console.log('home cloud', logName(), 'connection timed out');
ws.close(1013, 'connection timeout');
}
}
timeoutId = setTimeout(checkTimeout, activityTimeout);
// Note that our websocket library automatically handles pong responses on ping requests
// before it even emits the event.
ws.on('ping', refreshTimeout);
ws.on('pong', refreshTimeout);
ws.on('message', function forwardMessage(chunk) {
refreshTimeout();
console.log('message from home cloud to tunneler to browser', chunk.byteLength);
//console.log(chunk.toString());
unpacker.fns.addChunk(chunk);
});
function hangup() {
clearTimeout(timeoutId);
console.log('home cloud', logName(), 'connection closing');
Object.keys(remotes).forEach(function (jwtoken) {
removeToken(jwtoken);
});
ws.terminate();
}
ws.on('close', hangup);
ws.on('error', hangup);
// We only ever send one command and we send it once, so we just hard code the ID as 1
sendTunnelMsg(null, [1, 'hello', [unpacker._version], Object.keys(commandHandlers)], 'control');
}
return {
tcp: onTcpConnection
, ws: onWsConnection
, isClientDomain: Devices.exist.bind(null, copts.deviceLists)
};
};