MAJOR: Updates for Authenticated Web UI and CLI #30

Open
coolaj86 wants to merge 77 commits from next into master
28 changed files with 18444 additions and 755 deletions

View File

@ -1,6 +1,8 @@
# Telebit™ Remote
Because friends don't let friends localhost™
The T-Rex Long-Arm of the Internet
<small>because friends don't let friends localhost</small>
| Sponsored by [ppl](https://ppl.family)
| **Telebit Remote**
@ -524,7 +526,7 @@ rm -rf ~/.config/telebit ~/.local/share/telebit
Browser Library
=======
This is implemented with websockets, so you should be able to
This is implemented with websockets, so browser compatibility is a hopeful future outcome. Would love help.
LICENSE
=======

File diff suppressed because it is too large Load Diff

View File

@ -21,6 +21,7 @@ if ('rsync' === process.argv[2]) {
require('sclient/bin/sclient.js');
return;
}
// handle ssh client rather than ssh https tunnel
if ('ssh' === process.argv[2] && /[\w-]+\.[a-z]{2,}/i.test(process.argv[3])) {
process.argv.splice(1,1,'sclient');
process.argv.splice(2,1,'ssh');

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,60 @@
<!DOCTYPE html>
<html>
<head>
<title>Telebit Documentation</title>
</head>
<body>
<div class="v-app">
<h1>Telebit (Remote) Documentation</h1>
<section>
<h2>GET /api/config</h2>
<pre><code>{{ config }}</code></pre>
</section>
<section>
<h2>GET /api/status</h2>
<pre><code>{{ status }}</code></pre>
</section>
<section>
<h2>POST /api/init</h2>
<form v-on:submit.stop.prevent="initialize">
<label for="-email">Email:</label>
<input id="-email" v-model="init.email" type="text" placeholder="john@example.com">
<br>
<label for="-teletos"><input id="-teletos" v-model="init.teletos" type="checkbox">
Accept Telebit Terms of Service</label>
<br>
<label for="-letos"><input id="-letos" v-model="init.letos" type="checkbox">
Accept Let's Encrypt Terms of Service</label>
<br>
</form>
<pre><code>{{ init }}</code></pre>
</section>
<section>
<h2>POST /api/http</h2>
<pre><code>{{ http }}</code></pre>
</section>
<section>
<h2>POST /api/tcp</h2>
<pre><code>{{ tcp }}</code></pre>
</section>
<section>
<h2>POST /api/ssh</h2>
<pre><code>{{ ssh }}</code></pre>
</section>
</div>
<script src="/js/vue.js"></script>
<script src="/js/app.js"></script>
</body>
</html>

235
lib/admin/index.html Normal file
View File

@ -0,0 +1,235 @@
<!DOCTYPE html>
<html>
<head>
<title>Telebit Setup</title>
</head>
<body>
<script>document.body.hidden = true;</script>
<div class="v-app">
<h1>Telebit (Remote) Setup v{{ config.version }}</h1>
<section v-if="views.flash.error">
{{ views.flash.error }}
</section>
<section v-if="views.section.loading">
Loading...
</section>
<section v-if="views.section.setup">
<h2>Create Account</h2>
<form v-on:submit.stop.prevent="initialize">
<label for="-email">Email:</label>
<input id="-email" v-model="init.email" type="text" placeholder="john@example.com" required>
<br>
<label for="-teletos"><input id="-teletos" v-model="init.teletos" type="checkbox" required>
Accept Telebit Terms of Service</label>
<br>
<label for="-letos"><input id="-letos" v-model="init.letos" type="checkbox" required>
Accept Let's Encrypt Terms of Service</label>
<br>
<label for="-notifications">Notification Preferences</label>
<select id="-notifications" v-model="init.notifications">
<option value="newsletter">Occassional Newsletter</option>
<option value="important" default><strong>Important Messages Only</strong></option>
<option value="required">Required Only</option>
</select>
<small>
<p v-if="'newsletter' == init.notifications">
You'll receive a friendly note now and then in addition to the important updates.
</p>
<p v-if="'important' == init.notifications">
You'll only receive updates that we believe will be of the most value to you, and the required updates.
</p>
<p v-if="'required' == init.notifications">
You'll only receive security updates, transactional and account-related messages, and legal notices.
</p>
</small>
<details><summary><small>Advanced</small></summary>
<label for="-relay">Relay:</label>
<input id="-relay" v-model="init.relay" type="text" placeholder="telebit.cloud">
<br>
<button type="button" v-on:click="defaultRelay">Use Default</button>
<button type="button" v-on:click="betaRelay">Use Beta</button>
<br>
<br>
<label for="-acme-server">ACME (Let's Encrypt) Server:</label>
<input id="-acme-server" v-model="init.acmeServer" type="text" placeholder="https://acme-v02.api.letsencrypt.org/directory">
<br>
<button type="button" v-on:click="productionAcme">Use Production</button>
<button type="button" v-on:click="stagingAcme">Use Staging</button>
<br>
<br>
</details>
<button type="submit">Accept &amp; Continue</button>
</form>
<pre><code>{{ init }}</code></pre>
</section>
<section v-if="views.section.advanced">
<h2>Advanced Setup for {{ init.relay }}</h2>
<form v-on:submit.stop.prevent="advance">
<strong><label for="-secret">Relay Shared Secret:</label></strong>
<input id="-secret" v-model="init.secret" type="text" placeholder="ex: xxxxxxxxxxxx">
<br>
<strong><label for="-domains">Domains:</label></strong>
<br>
<small>(comma separated list of domains to use for http, tls, https, etc)</small>
<br>
<input id="-domains" v-model="init.domains" type="text" placeholder="ex: whatever.com, example.com">
<br>
<strong><label for="-ports">TCP Ports:</label></strong>
<br>
<small>(comman separated list of ports, excluding 80 and 443, typically port over 1024)</small>
<br>
<input id="-ports" v-model="init.ports" type="text" placeholder="ex: 5050, 3000, 8080">
<br>
<label for="-telemetry"><input id="-telemetry" v-model="init.telemetry" type="checkbox">
Contribute to Telebit by sharing telemetry</label>
<br>
<button type="submit">Finish</button>
</form>
<pre><code>{{ init }}</code></pre>
</section>
<section v-if="views.section.otp">
<pre><code><h2>{{ init.otp }}</h2></code></pre>
</section>
<section v-if="views.section.status">
http://localhost:{{ status.port }}
<br>
<br>
<section v-if="views.section.status_chooser">
<button v-on:click.prevent.stop="changeState('status/share')">Share Files &amp; Folders</button>
<button v-on:click.prevent.stop="changeState('status/host')">Host a Website or Webapp</button>
<button v-on:click.prevent.stop="changeState('status/access')">Remote Access via SSH</button>
</section>
<section v-if="views.section.status_access">
SSH:
<span v-if="status.ssh">{{ status.ssh }}
<button v-on:click="ssh(-1)">Disable SSH</button></span>
<span v-if="!status.ssh"><input type="text" v-model="state.ssh" placeholder="22">
<button v-on:click="ssh(state.ssh)">Enable SSH</button></span>
<br>
<br>
<div v-if="state.ssh_active">SSH is currently running</div>
<div v-if="!state.ssh_active">SSH is not currently running</div>
<br>
<div v-if="state.ssh_insecure">Password Authentication is NOT disabled.
Please consider updating your <code>sshd_config</code> and restarting ssh.
<pre><code>{{ status }}</code></pre>
</div>
<div v-if="!state.ssh_insecure">Key-Only Authentication is enabled :)</div>
<br>
<div class="alert alert-info">
<strong>Important:</strong> Accessing this device with other SSH clients:
<br>
In order to use your other ssh clients with telebit you will need to put them into
<strong>ssh+https mode</strong>.
We recommend downloading <code><a href="https://telebit.cloud/sclient/" target="_blank">sclient</a></code>
to do so, because it makes it as simple as adding <code>-o ProxyCommand="sclient %h"</code> to your
ssh command to enable ssh+https:
<pre><code>ssh -o ProxyCommand="sclient %h" {{ newHttp.name }}</code></pre>
<br>
However, most clients can also use <code>openssl s_client</code>, which does the same thing, but is
more difficult to remember:
<pre><code>proxy_cmd='openssl s_client -connect %h:443 -servername %h -quiet'
ssh -o ProxyCommand="$proxy_cmd" hot-skunk-45.telebit.io</code></pre>
</div>
</section>
<section v-if="views.section.status_share">
Path Hosting:
<ul>
<li v-for="domain in status.pathHosting">
<form v-on:submit.prevent.stop="changePathHost(domain, domain.path)">
{{ domain.name }}
<input type="text" v-model="domain.path" v-bind:placeholder="domain.handler">
<button type="submit"
v-if="domain.handler == domain.path">Save</button>
<button type="button" v-on:click="deletePathHost(domain)">X</button>
</form>
</li>
</ul>
<form v-on:submit.prevent.stop="createShare(newHttp.sub, newHttp.name, newHttp.handler)">
<input v-model="newHttp.sub" type="text" placeholder="subdomain (ex: pub)">
<select v-model="newHttp.name">
<option v-for="w in status.wildDomains" v-bind:value="w.name">{{ w.name }}</option>
</select>
<input v-model="newHttp.handler" type="text" placeholder="path (ex: ~/Public)" required>
<button>Add</button>
</form>
<br>
</section>
<section v-if="views.section.status_host">
Port Forwarding:
<ul>
<li v-for="domain in status.portForwards">
<form v-on:submit.prevent.stop="changePortForward(domain, domain._port)">
{{ domain.name }}
<input type="text" v-model="domain._port" v-bind:placeholder="domain.handler">
<button type="submit"
v-if="domain.handler == domain._port">Save</button>
<button type="button" v-on:click="deletePortForward(domain)">X</button>
</form>
</li>
</ul>
<form v-on:submit="createHost(newHttp.sub, newHttp.name, newHttp.handler)">
<input v-model="newHttp.sub" type="text" placeholder="subdomain (ex: api)">
<select v-model="newHttp.name">
<option v-for="w in status.wildDomains" v-bind:value="w.name">{{ w.name }}</option>
</select>
<input v-model="newHttp.handler" type="number" placeholder="port (ex: 3000)" required>
<button>Add</button>
</form>
</section>
<br>
Uptime: {{ statusUptime }}
<br>
Runtime: {{ statusRuntime }}
<br>
Reconnects: {{ status.reconnects }}
<details><summary><small>Advanced</small></summary>
<button v-if="!status.enabled" v-on:click="enable">Enable Traffic</button>
<button v-if="status.enabled" v-on:click="disable">Disable Traffic</button>
<br>
<br>
<pre><code>{{ status }}</code></pre>
</details>
</section>
</div>
<script src="/js/vue.js"></script>
<script src="/js/bluecrypt-acme.js"></script>
<script src="/js/telebit.js"></script>
<script src="/js/telebit-token.js"></script>
<script src="/js/app.js"></script>
</body>
</html>

608
lib/admin/js/app.js Normal file
View File

@ -0,0 +1,608 @@
;(function () {
'use strict';
var Vue = window.Vue;
var Telebit = window.TELEBIT;
var Keypairs = window.Keypairs;
var ACME = window.ACME;
var api = {};
/*
function safeFetch(url, opts) {
var controller = new AbortController();
var tok = setTimeout(function () {
controller.abort();
}, 4000);
if (!opts) {
opts = {};
}
opts.signal = controller.signal;
return window.fetch(url, opts).finally(function () {
clearTimeout(tok);
});
}
*/
api.config = function apiConfig() {
return Telebit.reqLocalAsync({
method: "GET"
, url: "/api/config"
, key: api._key
}).then(function (resp) {
var json = resp.body;
appData.config = json;
return json;
});
};
api.status = function apiStatus() {
return Telebit.reqLocalAsync({
method: "GET"
, url: "/api/status"
, key: api._key
}).then(function (resp) {
var json = resp.body;
return json;
});
};
api.http = function apiHttp(o) {
var opts = {
method: "POST"
, url: "/api/http"
, headers: { 'Content-Type': 'application/json' }
, json: { name: o.name, handler: o.handler, indexes: o.indexes }
, key: api._key
};
return Telebit.reqLocalAsync(opts).then(function (resp) {
var json = resp.body;
appData.initResult = json;
return json;
}).catch(function (err) {
window.alert("Error: [init] " + (err.message || JSON.stringify(err, null, 2)));
});
};
api.ssh = function apiSsh(port) {
var opts = {
method: "POST"
, url: "/api/ssh"
, headers: { 'Content-Type': 'application/json' }
, json: { port: port }
, key: api._key
};
return Telebit.reqLocalAsync(opts).then(function (resp) {
var json = resp.body;
appData.initResult = json;
return json;
}).catch(function (err) {
window.alert("Error: [init] " + (err.message || JSON.stringify(err, null, 2)));
});
};
api.enable = function apiEnable() {
var opts = {
method: "POST"
, url: "/api/enable"
//, headers: { 'Content-Type': 'application/json' }
, key: api._key
};
return Telebit.reqLocalAsync(opts).then(function (resp) {
var json = resp.body;
console.log('enable', json);
return json;
}).catch(function (err) {
window.alert("Error: [enable] " + (err.message || JSON.stringify(err, null, 2)));
});
};
api.disable = function apiDisable() {
var opts = {
method: "POST"
, url: "/api/disable"
//, headers: { 'Content-Type': 'application/json' }
, key: api._key
};
return Telebit.reqLocalAsync(opts).then(function (resp) {
var json = resp.body;
console.log('disable', json);
return json;
}).catch(function (err) {
window.alert("Error: [disable] " + (err.message || JSON.stringify(err, null, 2)));
});
};
function showOtp(otp, pollUrl) {
localStorage.setItem('poll_url', pollUrl);
telebitState.pollUrl = pollUrl;
appData.init.otp = otp;
changeState('otp');
}
function doConfigure() {
if (telebitState.dir.pair_request) {
telebitState._can_pair = true;
}
//
// Read config from form
//
// Create Empty Config, If Necessary
if (!telebitState.config) { telebitState.config = {}; }
if (!telebitState.config.greenlock) { telebitState.config.greenlock = {}; }
// Populate Config
if (appData.init.teletos && appData.init.letos) { telebitState.config.agreeTos = true; }
if (appData.init.relay) { telebitState.config.relay = appData.init.relay; }
if (appData.init.email) { telebitState.config.email = appData.init.email; }
if ('undefined' !== typeof appData.init.letos) { telebitState.config.greenlock.agree = appData.init.letos; }
if ('newsletter' === appData.init.notifications) {
telebitState.config.newsletter = true; telebitState.config.communityMember = true;
}
if ('important' === appData.init.notifications) { telebitState.config.communityMember = true; }
if (appData.init.acmeVersion) { telebitState.config.greenlock.version = appData.init.acmeVersion; }
if (appData.init.acmeServer) { telebitState.config.greenlock.server = appData.init.acmeServer; }
// Temporary State
telebitState._otp = Telebit.otp();
appData.init.otp = telebitState._otp;
return Telebit.authorize(telebitState, showOtp).then(function () {
return changeState('status');
});
}
// TODO test for internet connectivity (and telebit connectivity)
var DEFAULT_RELAY = 'telebit.cloud';
var BETA_RELAY = 'telebit.ppl.family';
var TELEBIT_RELAYS = [
DEFAULT_RELAY
, BETA_RELAY
];
var PRODUCTION_ACME = 'https://acme-v02.api.letsencrypt.org/directory';
var STAGING_ACME = 'https://acme-staging-v02.api.letsencrypt.org/directory';
var appData = {
config: {}
, status: {}
, init: {
teletos: true
, letos: true
, notifications: "important"
, relay: DEFAULT_RELAY
, telemetry: true
, acmeServer: PRODUCTION_ACME
}
, state: {}
, views: {
flash: {
error: ""
}
, section: {
loading: true
, setup: false
, advanced: false
, otp: false
, status: false
}
}
, newHttp: {}
};
var telebitState = {};
var appMethods = {
initialize: function () {
console.log("call initialize");
return requestAccountHelper().then(function (/*key*/) {
if (!appData.init.relay) {
appData.init.relay = DEFAULT_RELAY;
}
appData.init.relay = appData.init.relay.toLowerCase();
telebitState = { relay: appData.init.relay };
return Telebit.api.directory(telebitState).then(function (dir) {
if (!dir.api_host) {
window.alert("Error: '" + telebitState.relay + "' does not appear to be a valid telebit service");
return;
}
telebitState.dir = dir;
// If it's one of the well-known relays
if (-1 !== TELEBIT_RELAYS.indexOf(appData.init.relay)) {
return doConfigure();
} else {
changeState('advanced');
}
}).catch(function (err) {
console.error(err);
window.alert("Error: [initialize] " + (err.message || JSON.stringify(err, null, 2)));
});
});
}
, advance: function () {
return doConfigure();
}
, productionAcme: function () {
console.log("prod acme:");
appData.init.acmeServer = PRODUCTION_ACME;
console.log(appData.init.acmeServer);
}
, stagingAcme: function () {
console.log("staging acme:");
appData.init.acmeServer = STAGING_ACME;
console.log(appData.init.acmeServer);
}
, defaultRelay: function () {
appData.init.relay = DEFAULT_RELAY;
}
, betaRelay: function () {
appData.init.relay = BETA_RELAY;
}
, enable: function () {
api.enable();
}
, disable: function () {
api.disable();
}
, ssh: function (port) {
// -1 to disable
// 0 is auto (22)
// 1-65536
api.ssh(port || 22);
}
, createShare: function (sub, domain, handler) {
if (sub) {
domain = sub + '.' + domain;
}
api.http({ name: domain, handler: handler, indexes: true });
appData.newHttp = {};
}
, createHost: function (sub, domain, handler) {
if (sub) {
domain = sub + '.' + domain;
}
api.http({ name: domain, handler: handler, 'x-forwarded-for': name });
appData.newHttp = {};
}
, changePortForward: function (domain, port) {
api.http({ name: domain.name, handler: port });
}
, deletePortForward: function (domain) {
api.http({ name: domain.name, handler: 'none' });
}
, changePathHost: function (domain, path) {
api.http({ name: domain.name, handler: path });
}
, deletePathHost: function (domain) {
api.http({ name: domain.name, handler: 'none' });
}
, changeState: changeState
};
var appStates = {
setup: function () {
appData.views.section = { setup: true };
}
, advanced: function () {
appData.views.section = { advanced: true };
}
, otp: function () {
appData.views.section = { otp: true };
}
, status: function () {
function exitState() {
clearInterval(tok);
}
var tok = setInterval(updateStatus, 2000);
return updateStatus().then(function () {
appData.views.section = { status: true, status_chooser: true };
return exitState;
});
}
};
appStates.status.share = function () {
function exitState() {
clearInterval(tok);
}
var tok = setInterval(updateStatus, 2000);
appData.views.section = { status: true, status_share: true };
return updateStatus().then(function () {
return exitState;
});
};
appStates.status.host = function () {
function exitState() {
clearInterval(tok);
}
var tok = setInterval(updateStatus, 2000);
appData.views.section = { status: true, status_host: true };
return updateStatus().then(function () {
return exitState;
});
};
appStates.status.access = function () {
function exitState() {
clearInterval(tok);
}
var tok = setInterval(updateStatus, 2000);
appData.views.section = { status: true, status_access: true };
return updateStatus().then(function () {
return exitState;
});
};
function updateStatus() {
return api.status().then(function (status) {
if (status.error) {
appData.views.flash.error = status.error.message || JSON.stringify(status.error, null, 2);
}
var wilddomains = [];
var rootdomains = [];
var subdomains = [];
var directories = [];
var portforwards = [];
var free = [];
appData.status = status;
if ('maybe' === status.ssh_requests_password) {
appData.status.ssh_active = false;
} else {
appData.status.ssh_active = true;
if ('yes' === status.ssh_requests_password) {
appData.status.ssh_insecure = true;
}
}
if ('yes' === status.ssh_password_authentication) {
appData.status.ssh_insecure = true;
}
if ('yes' === status.ssh_permit_root_login) {
appData.status.ssh_insecure = true;
}
// only update what's changed
if (appData.state.ssh !== appData.status.ssh) {
appData.state.ssh = appData.status.ssh;
}
if (appData.state.ssh_insecure !== appData.status.ssh_insecure) {
appData.state.ssh_insecure = appData.status.ssh_insecure;
}
if (appData.state.ssh_active !== appData.status.ssh_active) {
appData.state.ssh_active = appData.status.ssh_active;
}
Object.keys(appData.status.servernames).forEach(function (k) {
var s = appData.status.servernames[k];
s.name = k;
if (s.wildcard) { wilddomains.push(s); }
if (!s.sub && !s.wildcard) { rootdomains.push(s); }
if (s.sub) { subdomains.push(s); }
if (s.handler) {
if (s.handler.toString() === parseInt(s.handler, 10).toString()) {
s._port = s.handler;
portforwards.push(s);
} else {
s.path = s.handler;
directories.push(s);
}
} else {
free.push(s);
}
});
appData.status.portForwards = portforwards;
appData.status.pathHosting = directories;
appData.status.wildDomains = wilddomains;
appData.newHttp.name = (appData.status.wildDomains[0] || {}).name;
appData.state.ssh = (appData.status.ssh > 0) && appData.status.ssh || undefined;
});
}
function changeState(newstate) {
var newhash = '#/' + newstate + '/';
if (location.hash === newhash) {
if (!telebitState.firstState) {
telebitState.firstState = true;
setState();
}
}
location.hash = newhash;
}
/*globals Promise*/
window.addEventListener('hashchange', setState, false);
function setState(/*ev*/) {
//ev.oldURL
//ev.newURL
if (appData.exit) {
console.log('previous state exiting');
appData.exit.then(function (exit) {
if ('function' === typeof exit) {
exit();
}
});
}
var parts = location.hash.substr(1).replace(/^\//, '').replace(/\/$/, '').split('/').filter(Boolean);
var fn = appStates;
parts.forEach(function (s) {
console.log("state:", s);
fn = fn[s];
});
appData.exit = Promise.resolve(fn());
//appMethods.states[newstate]();
}
function msToHumanReadable(ms) {
var uptime = ms;
var uptimed = uptime / 1000;
var minute = 60;
var hour = 60 * minute;
var day = 24 * hour;
var days = 0;
var times = [];
while (uptimed > day) {
uptimed -= day;
days += 1;
}
times.push(days + " days ");
var hours = 0;
while (uptimed > hour) {
uptimed -= hour;
hours += 1;
}
times.push(hours.toString().padStart(2, "0") + " h ");
var minutes = 0;
while (uptimed > minute) {
uptimed -= minute;
minutes += 1;
}
times.push(minutes.toString().padStart(2, "0") + " m ");
var seconds = Math.round(uptimed);
times.push(seconds.toString().padStart(2, "0") + " s ");
return times.join('');
}
new Vue({
el: ".v-app"
, data: appData
, computed: {
statusProctime: function () {
return msToHumanReadable(this.status.proctime);
}
, statusRuntime: function () {
return msToHumanReadable(this.status.runtime);
}
, statusUptime: function () {
return msToHumanReadable(this.status.uptime);
}
}
, methods: appMethods
});
function requestAccountHelper() {
function reset() {
changeState('setup');
setState();
}
return new Promise(function (resolve) {
appData.init.email = localStorage.getItem('email');
if (!appData.init.email) {
// don't resolve
reset();
return;
}
return requestAccount(appData.init.email).then(function (key) {
if (!key) { throw new Error("[SANITY] Error: completed without key"); }
resolve(key);
}).catch(function (err) {
appData.init.email = "";
localStorage.removeItem('email');
console.error(err);
window.alert("something went wrong");
// don't resolve
reset();
});
});
}
function run() {
return requestAccountHelper().then(function (key) {
api._key = key;
// TODO create session instance of Telebit
Telebit._key = key;
// 😁 1. Get ACME directory
// 😁 2. Fetch ACME account
// 3. Test if account has access
// 4. Show command line auth instructions to auth
// 😁 5. Sign requests / use JWT
// 😁 6. Enforce token required for config, status, etc
// 7. Move admin interface to standard ports (admin.foo-bar-123.telebit.xyz)
api.config().then(function (config) {
telebitState.config = config;
if (config.greenlock) {
appData.init.acmeServer = config.greenlock.server;
}
if (config.relay) {
appData.init.relay = config.relay;
}
if (config.email) {
appData.init.email = config.email;
}
if (config.agreeTos) {
appData.init.letos = config.agreeTos;
appData.init.teletos = config.agreeTos;
}
if (config._otp) {
appData.init.otp = config._otp;
}
telebitState.pollUrl = config._pollUrl || localStorage.getItem('poll_url');
if ((!config.token && !config._otp) || !config.relay || !config.email || !config.agreeTos) {
changeState('setup');
setState();
return;
}
if (!config.token && config._otp) {
changeState('otp');
setState();
// this will skip ahead as necessary
return Telebit.authorize(telebitState, showOtp).then(function () {
return changeState('status');
});
}
// TODO handle default state
changeState('status');
}).catch(function (err) {
appData.views.flash.error = err.message || JSON.stringify(err, null, 2);
});
});
}
// TODO protect key with passphrase (or QR code?)
function getKey() {
var jwk;
try {
jwk = JSON.parse(localStorage.getItem('key'));
} catch(e) {
// ignore
}
if (jwk && jwk.kid && jwk.d) {
return Promise.resolve(jwk);
}
return Keypairs.generate().then(function (pair) {
jwk = pair.private;
localStorage.setItem('key', JSON.stringify(jwk));
return jwk;
});
}
function requestAccount(email) {
return getKey().then(function (jwk) {
// creates new or returns existing
var acme = ACME.create({});
var url = window.location.protocol + '//' + window.location.host + '/acme/directory';
return acme.init(url).then(function () {
return acme.accounts.create({
agreeToTerms: function (tos) { return tos; }
, accountKeypair: { privateKeyJwk: jwk }
, email: email
}).then(function (account) {
console.log('account:');
console.log(account);
if (account.id) {
localStorage.setItem('email', email);
}
return jwk;
});
});
});
}
window.api = api;
run();
setTimeout(function () {
document.body.hidden = false;
}, 50);
// Debug
window.changeState = changeState;
}());

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,116 @@
;(function (exports) {
'use strict';
/* global Promise */
var PromiseA;
if ('undefined' !== typeof Promise) {
PromiseA = Promise;
} else {
throw new Error("no Promise implementation defined");
}
var common = exports.TELEBIT || require('./lib/common.js');
common.authorize = common.getToken = function getToken(state, showOtp) {
state.relay = state.config.relay;
// { _otp, config: {} }
return common.api.token(state, {
error: function (err) { console.error("[Error] common.api.token handlers.error: \n", err); return PromiseA.reject(err); }
, directory: function (dir) {
/*console.log('[directory] Telebit Relay Discovered:', dir);*/
state._apiDirectory = dir;
return PromiseA.resolve();
}
, tunnelUrl: function (tunnelUrl) {
//console.log('[tunnelUrl] Telebit Relay Tunnel Socket:', tunnelUrl);
state.wss = tunnelUrl;
return PromiseA.resolve();
}
, requested: function (authReq, pollUrl) {
console.log("[requested] Pairing Requested");
state._otp = state._otp = authReq.otp;
if (!state.config.token && state._can_pair) {
console.info("0000".replace(/0000/g, state._otp));
showOtp(authReq.otp, pollUrl);
}
return PromiseA.resolve();
}
, connect: function (pretoken) {
console.log("[connect] Enabling Pairing Locally...");
state.config.pretoken = pretoken;
state._connecting = true;
// This will only be saved to the session
state.config._otp = state._otp;
return common.reqLocalAsync({ url: '/api/config', method: 'POST', body: state.config, json: true }).then(function () {
console.info("waiting...");
return PromiseA.resolve();
}).catch(function (err) {
state._error = err;
console.error("Error while initializing config [connect]:");
console.error(err);
return PromiseA.reject(err);
});
}
, offer: function (token) {
//console.log("[offer] Pairing Enabled by Relay");
state.token = token;
state.config.token = token;
if (state._error) {
return;
}
state._connecting = true;
try {
//require('jsonwebtoken').decode(token);
token = token.split('.');
token[0] = token[0].replace(/_/g, '/').replace(/-/g, '+');
while (token[0].length % 4) { token[0] += '='; }
btoa(token[0]);
token[1] = token[1].replace(/_/g, '/').replace(/-/g, '+');
while (token[1].length % 4) { token[1] += '='; }
btoa(token[1]);
//console.log(require('jsonwebtoken').decode(token));
} catch(e) {
console.warn("[warning] could not decode token");
}
return common.reqLocalAsync({ url: '/api/config', method: 'POST', body: state.config, json: true }).then(function () {
//console.log("Pairing Enabled Locally");
return PromiseA.resolve();
}).catch(function (err) {
state._error = err;
console.error("Error while initializing config [offer]:");
console.error(err);
return PromiseA.reject(err);
});
}
, granted: function (/*_*/) { /*console.log("[grant] Pairing complete!");*/ return PromiseA.resolve(); }
, end: function () {
return common.reqLocalAsync({ url: '/api/enable', method: 'POST', body: [], json: true }).then(function () {
console.info("Success");
// workaround for https://github.com/nodejs/node/issues/21319
if (state._useTty) {
setTimeout(function () {
console.info("Some fun things to try first:\n");
console.info(" ~/telebit http ~/public");
console.info(" ~/telebit tcp 5050");
console.info(" ~/telebit ssh auto");
console.info();
console.info("Press any key to continue...");
console.info();
process.exit(0);
}, 0.5 * 1000);
return;
}
// end workaround
//parseCli(state);
});
}
});
};
}('undefined' === typeof module ? window : module.exports));

365
lib/admin/js/telebit.js Normal file
View File

@ -0,0 +1,365 @@
;(function (exports) {
'use strict';
var Keypairs = window.Keypairs;
var common = exports.TELEBIT = {};
common.debug = true;
/* global Promise */
var PromiseA;
if ('undefined' !== typeof Promise) {
PromiseA = Promise;
} else {
throw new Error("no Promise implementation defined");
}
/*globals AbortController*/
if ('undefined' !== typeof fetch) {
common._requestAsync = function (opts) {
// funnel requests through the local server
// (avoid CORS, for now)
var relayOpts = {
url: '/api/relay'
, method: 'POST'
, headers: {
'Content-Type': 'application/json'
, 'Accepts': 'application/json'
}
, body: JSON.stringify(opts)
};
var controller = new AbortController();
var tok = setTimeout(function () {
controller.abort();
}, 4000);
if (!relayOpts) {
relayOpts = {};
}
relayOpts.signal = controller.signal;
return window.fetch(relayOpts.url, relayOpts).then(function (resp) {
clearTimeout(tok);
return resp.json().then(function (json) {
if (json.error) {
return PromiseA.reject(new Error(json.error && json.error.message || JSON.stringify(json.error)));
}
return json;
});
});
};
common._reqLocalAsync = function (opts) {
if (!opts) { opts = {}; }
if (opts.json && true !== opts.json) {
opts.body = opts.json;
opts.json = true;
}
if (opts.json) {
if (!opts.headers) { opts.headers = {}; }
if (opts.body) {
opts.headers['Content-Type'] = 'application/json';
if ('string' !== typeof opts.body) {
opts.body = JSON.stringify(opts.body);
}
} else {
opts.headers.Accepts = 'application/json';
}
}
var controller = new AbortController();
var tok = setTimeout(function () {
controller.abort();
}, 4000);
opts.signal = controller.signal;
return window.fetch(opts.url, opts).then(function (resp) {
clearTimeout(tok);
return resp.json().then(function (json) {
var headers = {};
resp.headers.forEach(function (k, v) {
headers[k] = v;
});
return { statusCode: resp.status, headers: headers, body: json };
});
});
};
} else {
common._requestAsync = require('util').promisify(require('@root/request'));
common._reqLocalAsync = require('util').promisify(require('@root/request'));
}
common._sign = function (opts) {
var p;
if ('POST' === opts.method || opts.json) {
p = Keypairs.signJws({ jwk: opts.key || common._key, payload: opts.json || {} }).then(function (jws) {
opts.json = jws;
});
} else {
p = Keypairs.signJwt({ jwk: opts.key || common._key, claims: { iss: false, exp: '60s' } }).then(function (jwt) {
if (!opts.headers) { opts.headers = {}; }
opts.headers.Authorization = 'Bearer ' + jwt;
});
}
return p.then(function () {
return opts;
});
};
common.requestAsync = function (opts) {
return common._sign(opts).then(function (opts) {
return common._requestAsync(opts);
});
};
common.reqLocalAsync = function (opts) {
return common._sign(opts).then(function (opts) {
return common._reqLocalAsync(opts);
});
};
common.parseUrl = function (hostname) {
// add scheme, if missing
if (!/:\/\//.test(hostname)) {
hostname = 'https://' + hostname;
}
var location = new URL(hostname);
hostname = location.hostname + (location.port ? ':' + location.port : '');
hostname = location.protocol.replace(/https?/, 'https') + '//' + hostname + location.pathname;
return hostname;
};
common.parseHostname = function (hostname) {
var location = {};
try {
location = new URL(hostname);
} catch(e) {
// ignore
}
if (!location.protocol || /\./.test(location.protocol)) {
hostname = 'https://' + hostname;
location = new URL(hostname);
}
//hostname = location.hostname + (location.port ? ':' + location.port : '');
//hostname = location.protocol.replace(/https?/, 'https') + '//' + hostname + location.pathname;
return location.hostname;
};
common.apiDirectory = '_apis/telebit.cloud/index.json';
common.otp = function getOtp() {
return Math.round(Math.random() * 9999).toString().padStart(4, '0');
};
common.signToken = function (state) {
var JWT = require('./jwt.js');
var tokenData = {
domains: Object.keys(state.config.servernames || {}).filter(function (name) {
return /\./.test(name);
})
, ports: Object.keys(state.config.ports || {}).filter(function (port) {
port = parseInt(port, 10);
return port > 0 && port <= 65535;
})
, aud: state._relayUrl
, iss: Math.round(Date.now() / 1000)
};
return JWT.sign(tokenData, state.config.secret);
};
common.promiseTimeout = function (ms) {
var tok;
var p = new PromiseA(function (resolve) {
tok = setTimeout(function () {
resolve();
}, ms);
});
p.cancel = function () {
clearTimeout(tok);
};
return p;
};
common.api = {};
common.api.directory = function (state) {
console.log('[DEBUG] state:');
console.log(state);
state._relayUrl = common.parseUrl(state.relay);
if (!state._relays) { state._relays = {}; }
if (state._relays[state._relayUrl]) {
return PromiseA.resolve(state._relays[state._relayUrl]);
}
return common.requestAsync({ url: state._relayUrl + common.apiDirectory, json: true }).then(function (resp) {
var dir = resp.body;
state._relays[state._relayUrl] = dir;
return dir;
});
};
common.api._parseWss = function (state, dir) {
if (!dir || !dir.api_host) {
dir = { api_host: ':hostname', tunnel: { method: "wss", pathname: "" } };
}
state._relayHostname = common.parseHostname(state.relay);
return dir.tunnel.method + '://' + dir.api_host.replace(/:hostname/g, state._relayHostname) + dir.tunnel.pathname;
};
common.api.wss = function (state) {
return common.api.directory(state).then(function (dir) {
return common.api._parseWss(state, dir);
});
};
common.api.token = function (state, handlers) {
var firstReady = true;
function pollStatus(req) {
if (common.debug) { console.log('[debug] pollStatus called'); }
if (common.debug) { console.log(req); }
return common.requestAsync(req).then(function checkLocation(resp) {
var body = resp.body;
if (common.debug) { console.log('[debug] checkLocation'); }
if (common.debug) { console.log(body); }
// pending, try again
if ('pending' === body.status && resp.headers.location) {
if (common.debug) { console.log('[debug] pending'); }
return common.promiseTimeout(2 * 1000).then(function () {
return pollStatus({ url: resp.headers.location, json: true });
});
} else if ('ready' === body.status) {
if (common.debug) { console.log('[debug] ready'); }
if (firstReady) {
if (common.debug) { console.log('[debug] first ready'); }
firstReady = false;
// falls through on purpose
PromiseA.resolve(handlers.offer(body.access_token)).then(function () {
/*ignore*/
});
}
return common.promiseTimeout(2 * 1000).then(function () {
return pollStatus(req);
});
} else if ('complete' === body.status) {
if (common.debug) { console.log('[debug] complete'); }
return PromiseA.resolve(handlers.granted(null)).then(function () {
return PromiseA.resolve(handlers.end(null)).then(function () {});
});
} else {
if (common.debug) { console.log('[debug] bad status'); }
var err = new Error("Bad State:" + body.status);
err._request = req;
return PromiseA.reject(err);
}
}).catch(function (err) {
if (common.debug) { console.log('[debug] pollStatus error'); }
err._request = req;
err._hint = '[telebitd.js] pair request';
return PromiseA.resolve(handlers.error(err)).then(function () {});
});
}
// directory, requested, connect, tunnelUrl, offer, granted, end
function requestAuth(dir) {
if (common.debug) { console.log('[debug] after dir'); }
state.wss = common.api._parseWss(state, dir);
return PromiseA.resolve(handlers.tunnelUrl(state.wss)).then(function () {
if (common.debug) { console.log('[debug] after tunnelUrl'); }
if (state.config.secret /* && !state.config.token */) {
// TODO make token here in the browser
//state.config._token = common.signToken(state);
}
state.token = state.token || state.config.token || state.config._token;
if (state.token) {
if (common.debug) { console.log('[debug] token via token or secret'); }
// { token, pretoken }
return PromiseA.resolve(handlers.connect(state.token)).then(function () {
return PromiseA.resolve(handlers.end(null));
});
}
if (!dir.pair_request) {
if (common.debug) { console.log('[debug] no dir, connect'); }
return PromiseA.resolve(handlers.error(new Error("No token found or generated, and no pair_request api found.")));
}
// TODO sign token with own private key, including public key and thumbprint
// (much like ACME JOSE account)
// TODO handle agree
var otp = state._otp; // common.otp();
var authReq = {
subject: state.config.email
, subject_scheme: 'mailto'
// TODO create domains list earlier
, scope: (state.config._servernames || Object.keys(state.config.servernames || {}))
.concat(state.config._ports || Object.keys(state.config.ports || {})).join(',')
, otp: otp
// TODO make call to daemon for this info beforehand
/*
, hostname: os.hostname()
// Used for User-Agent
, os_type: os.type()
, os_platform: os.platform()
, os_release: os.release()
, os_arch: os.arch()
*/
};
var pairRequestUrl = new URL(dir.pair_request.pathname, 'https://' + dir.api_host.replace(/:hostname/g, state._relayHostname));
console.log('pairRequestUrl:', pairRequestUrl);
//console.log('pairRequestUrl:', JSON.stringify(pairRequestUrl.toJSON()));
var req = {
// WHATWG URL defines .toJSON() but, of course, it's not implemented
// because... why would we implement JavaScript objects in the DOM
// when we can have perfectly incompatible non-JS objects?
url: {
host: pairRequestUrl.host
, hostname: pairRequestUrl.hostname
, href: pairRequestUrl.href
, pathname: pairRequestUrl.pathname
// because why wouldn't node require 'path' on a json object and accept 'pathname' on a URL object...
// https://twitter.com/coolaj86/status/1053947919890403328
, path: pairRequestUrl.pathname
, port: pairRequestUrl.port || null
, protocol: pairRequestUrl.protocol
, search: pairRequestUrl.search || null
}
, method: dir.pair_request.method
, json: authReq
};
return common.requestAsync(req).then(function doFirst(resp) {
var body = resp.body;
if (common.debug) { console.log('[debug] first req'); }
if (!body.access_token && !body.jwt) {
return PromiseA.reject(new Error("something wrong with pre-authorization request"));
}
return PromiseA.resolve(handlers.requested(authReq, resp.headers.location)).then(function () {
return PromiseA.resolve(handlers.connect(body.access_token || body.jwt)).then(function () {
var err;
if (!resp.headers.location) {
err = new Error("bad authentication request response");
err._resp = resp.toJSON && resp.toJSON();
return PromiseA.resolve(handlers.error(err)).then(function () {});
}
return common.promiseTimeout(2 * 1000).then(function () {
return pollStatus({ url: resp.headers.location, json: true });
});
});
});
}).catch(function (err) {
if (common.debug) { console.log('[debug] gotoFirst error'); }
err._request = req;
err._hint = '[telebitd.js] pair request';
return PromiseA.resolve(handlers.error(err)).then(function () {});
});
});
}
if (state.pollUrl) {
return pollStatus({ url: state.pollUrl, json: true });
}
// backwards compat (TODO verify we can remove this)
var failoverDir = '{ "api_host": ":hostname", "tunnel": { "method": "wss", "pathname": "" } }';
return common.api.directory(state).then(function (dir) {
console.log('[debug] [directory]', dir);
if (!dir.api_host) { dir = JSON.parse(failoverDir); }
return dir;
}).catch(function (err) {
console.warn('[warn] [directory] fetch fail, using failover');
console.warn(err);
return JSON.parse(failoverDir);
}).then(function (dir) {
return PromiseA.resolve(handlers.directory(dir)).then(function () {
console.log('[debug] [directory]', dir);
return requestAuth(dir);
});
});
};
}('undefined' !== typeof module ? module.exports : window));

10947
lib/admin/js/vue.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,7 @@ var fs = require('fs');
var mkdirp = require('mkdirp');
var os = require('os');
var homedir = os.homedir();
var urequest = require('@coolaj86/urequest');
var urequest = require('@root/request');
common._NOTIFICATIONS = {
'newsletter': [ 'newsletter', 'communityMember' ]
@ -118,7 +118,7 @@ common.otp = function getOtp() {
return Math.round(Math.random() * 9999).toString().padStart(4, '0');
};
common.signToken = function (state) {
var jwt = require('jsonwebtoken');
var JWT = require('./jwt.js');
var tokenData = {
domains: Object.keys(state.config.servernames || {}).filter(function (name) {
return /\./.test(name);
@ -131,7 +131,7 @@ common.signToken = function (state) {
, iss: Math.round(Date.now() / 1000)
};
return jwt.sign(tokenData, state.config.secret);
return JWT.sign(tokenData, state.config.secret);
};
common.api = {};
common.api.directory = function (state, next) {

View File

@ -28,6 +28,7 @@ function TelebitRemote(state) {
EventEmitter.call(this);
var me = this;
var priv = {};
var path = require('path');
//var defaultHttpTimeout = (2 * 60);
//var activityTimeout = state.activityTimeout || (defaultHttpTimeout - 5) * 1000;
@ -39,8 +40,9 @@ function TelebitRemote(state) {
priv.tokens = [];
var auth;
if(!state.sortingHat) {
state.sortingHat = "./sorting-hat.js";
state.sortingHat = path.join(__dirname, '../sorting-hat.js');
}
state._connectionHandler = require(state.sortingHat);
if (state.token) {
if ('undefined' === state.token) {
throw new Error("passed string 'undefined' as token");
@ -349,7 +351,7 @@ function TelebitRemote(state) {
// TODO use readable streams instead
wstunneler._socket.pause();
require(state.sortingHat).assign(state, tun, function (err, conn) {
state._connectionHandler.assign(state, tun, function (err, conn) {
if (err) {
err.message = err.message.replace(/:tun_id/, tun._id);
packerHandlers._onConnectError(cid, tun, err);
@ -472,12 +474,12 @@ function TelebitRemote(state) {
priv.timeoutId = null;
var machine = Packer.create(packerHandlers);
console.info("[telebit:lib/remote.js] [connect] '" + (state.wss || state.relay) + "'");
console.info("[telebit:lib/daemon.js] [connect] '" + (state.wss || state.relay) + "'");
var tunnelUrl = (state.wss || state.relay).replace(/\/$/, '') + '/'; // + auth;
wstunneler = new WebSocket(tunnelUrl, { rejectUnauthorized: !state.insecure });
// XXXXXX
wstunneler.on('open', function () {
console.info("[telebit:lib/remote.js] [open] connected to '" + (state.wss || state.relay) + "'");
console.info("[telebit:lib/daemon.js] [open] connected to '" + (state.wss || state.relay) + "'");
me.emit('connect');
priv.refreshTimeout();
priv.timeoutId = setTimeout(priv.checkTimeout, activityTimeout);

120
lib/eggspress.js Normal file
View File

@ -0,0 +1,120 @@
'use strict';
function eggSend(obj) {
/*jslint validthis: true*/
var me = this;
if (!me.getHeader('content-type')) {
me.setHeader('Content-Type', 'application/json');
}
me.end(JSON.stringify(obj));
}
module.exports = function eggspress() {
//var patternsMap = {};
var allPatterns = [];
var app = function (req, res) {
var patterns = allPatterns.slice(0).reverse();
function next(err) {
if (err) {
req.end(err.message);
return;
}
var todo = patterns.pop();
if (!todo) {
console.log('[eggspress] Did not match any patterns', req.url);
require('finalhandler')(req, res)();
return;
}
// '', GET, POST, DELETE
if (todo[2] && req.method.toLowerCase() !== todo[2]) {
//console.log("[eggspress] HTTP method doesn't match", req.url);
next();
return;
}
var urlstr = (req.url.replace(/\/$/, '') + '/');
var match = urlstr.match(todo[0]);
if (!match) {
//console.log("[eggspress] pattern doesn't match", todo[0], req.url);
next();
return;
} else if ('string' === typeof todo[0] && 0 !== match.index) {
//console.log("[eggspress] string pattern is not the start", todo[0], req.url);
next();
return;
}
function fail(e) {
console.error("[eggspress] error", todo[2], todo[0], req.url);
console.error(e);
// TODO make a nice error message
res.end(e.message);
}
console.log("[eggspress] matched pattern", todo[0], req.url);
if ('function' === typeof todo[1]) {
// TODO this is prep-work
todo[1] = [todo[1]];
}
var fns = todo[1].slice(0);
req.params = match.slice(1);
function nextTodo(err) {
if (err) { fail(err); return; }
var fn = fns.shift();
if (!fn) { next(err); return; }
try {
var p = fn(req, res, nextTodo);
if (p && p.catch) {
p.catch(fail);
}
} catch(e) {
fail(e);
return;
}
}
nextTodo();
}
res.send = eggSend;
next();
};
app.use = function (pattern) {
var fns = Array.prototype.slice.call(arguments, 1);
return app._use('', pattern, fns);
};
[ 'HEAD', 'GET', 'POST', 'DELETE' ].forEach(function (method) {
app[method.toLowerCase()] = function (pattern) {
var fns = Array.prototype.slice.call(arguments, 1);
return app._use(method, pattern, fns);
};
});
app.post = function (pattern) {
var fns = Array.prototype.slice.call(arguments, 1);
return app._use('POST', pattern, fns);
};
app._use = function (method, pattern, fns) {
// always end in a slash, for now
if ('string' === typeof pattern) {
pattern = pattern.replace(/\/$/, '') + '/';
}
/*
if (!patternsMap[pattern]) {
patternsMap[pattern] = [];
}
patternsMap[pattern].push(fn);
patterns = Object.keys(patternsMap).sort(function (a, b) {
return b.length - a.length;
});
*/
allPatterns.push([pattern, fns, method.toLowerCase()]);
return app;
};
return app;
};

View File

@ -476,5 +476,13 @@ By using Telebit you agree to:
Enter your email to agree and login/create your account:
"
fail_relay_check = "===================
WARNING
===================
[{{status_code}}] '{{url}}'
This server does not describe a current telebit version (but it may still work).
"
[daemon]
version = "telebit daemon v{version}"

19
lib/jwt-test.js Normal file
View File

@ -0,0 +1,19 @@
'use strict';
var crypto = require('crypto');
var FAT = require('jsonwebtoken');
var JWT = require('./jwt.js');
var key = "justanothersecretsecret";
var keyid = crypto.createHash('sha256').update(key).digest('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
var tok1 = FAT.sign({ foo: "hello" }, key, { keyid: keyid });
var tok2 = JWT.sign({ foo: "hello" }, key);
if (tok1 !== tok2) {
console.error(JWT.decode(tok1));
console.error(JWT.decode(tok2));
throw new Error("our jwt doesn't match auth0/jsonwebtoken");
}
console.info('Pass');

43
lib/jwt.js Normal file
View File

@ -0,0 +1,43 @@
'use strict';
var crypto = require('crypto');
var JWT = module.exports;
JWT.decode = function (jwt) {
var parts;
try {
parts = jwt.split('.');
return {
header: JSON.parse(Buffer.from(parts[0], 'base64'))
, payload: JSON.parse(Buffer.from(parts[1], 'base64'))
, signature: parts[2] //Buffer.from(parts[2], 'base64')
};
} catch(e) {
throw new Error("JWT Parse Error: could not split, base64 decode, and JSON.parse token " + jwt);
}
};
JWT.verify = function (jwt) {
var decoded = JWT.decode(jwt);
throw new Error("not implemented yet");
};
function base64ToUrlSafe(str) {
return str
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '')
;
}
JWT.sign = function (claims, key) {
if (!claims.iat && false !== claims.iat) {
claims.iat = Math.round(Date.now()/1000);
}
var thumb = base64ToUrlSafe(crypto.createHash('sha256').update(key).digest('base64'));
var protect = base64ToUrlSafe(Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT', kid: thumb })).toString('base64'));
var payload = base64ToUrlSafe(Buffer.from(JSON.stringify(claims)).toString('base64'));
var signature = base64ToUrlSafe(crypto.createHmac('sha256', key).update(protect + '.' + payload).digest('base64'));
return protect + '.' + payload + '.' + signature;
};

39
lib/keystore-fallback.js Normal file
View File

@ -0,0 +1,39 @@
'use strict';
/*global Promise*/
var fs = require('fs').promises;
var path = require('path');
module.exports.create = function (opts) {
var keyext = '.key';
return {
getPassword: function (service, name) {
var f = path.join(opts.configDir, name + keyext);
return fs.readFile(f, 'utf8').catch(function (err) {
if ('ENOEXIST' === err.code) {
return;
}
});
}
, setPassword: function (service, name, key) {
var f = path.join(opts.configDir, name + keyext);
return fs.writeFile(f, key, 'utf8');
}
, deletePassword: function (service, name) {
var f = path.join(opts.configDir, name + keyext);
return fs.unlink(f);
}
, findCredentials: function (/*service*/) {
return fs.readdir(opts.configDir).then(function (nodes) {
return Promise.all(nodes.filter(function (node) {
return keyext === node.slice(-4);
}).map(function (node) {
return fs.readFile(path.join(opts.configDir, node), 'utf8').then(function (data) {
return { password: data };
});
}));
});
}
, insecure: true
};
};

29
lib/keystore-test.js Normal file
View File

@ -0,0 +1,29 @@
(function () {
'use strict';
var keystore = require('./keystore.js').create({
configDir: require('path').join(require('os').homedir(), '.config/telebit/')
, fallback: true
});
var name = "testy-mctestface-1";
return keystore.get(name).then(function (jwk) {
console.log("get1", typeof jwk, jwk);
if (!jwk || !jwk.kty) {
return require('keypairs').generate().then(function (jwk) {
var json = JSON.stringify(jwk.private);
return keystore.set(name, json).then(function () {
return keystore.all().then(function (vals) {
console.log("All", vals);
return keystore.get(name).then(function (val2) {
console.log("get2", val2);
});
});
}).catch(function (err) {
console.log('badness', err);
});
});
}
return jwk;
});
}());

52
lib/keystore.js Normal file
View File

@ -0,0 +1,52 @@
'use strict';
module.exports.create = function (opts) {
var service = opts.name || "Telebit";
var keytar;
try {
if (opts.fallback) {
throw new Error("forced fallback");
}
keytar = require('keytar');
// TODO test that long "passwords" (JWTs and JWKs) can be stored in all OSes
} catch(e) {
console.warn("Could not load native key management. Keys will be stored in plain text.");
keytar = require('./keystore-fallback.js').create(opts);
keytar.insecure = true;
}
return {
get: function (name) {
return keytar.getPassword(service, name).then(maybeParse);
}
, set: function (name, value) {
return keytar.setPassword(service, name, maybeStringify(value));
}
, delete: function (name) {
return keytar.deletePassword(service, name);
}
, all: function () {
return keytar.findCredentials(service).then(function (list) {
return list.map(function (el) {
el.password = maybeParse(el.password);
return el;
});
});
}
, insecure: keytar.insecure
};
};
function maybeParse(str) {
if (str && '{' === str[0]) {
return JSON.parse(str);
}
return str;
}
function maybeStringify(obj) {
if ('string' !== typeof obj && 'object' === typeof obj) {
return JSON.stringify(obj);
}
return obj;
}

196
lib/rc/index.js Normal file
View File

@ -0,0 +1,196 @@
'use strict';
var os = require('os');
var path = require('path');
var http = require('http');
var keypairs = require('keypairs');
var common = require('../cli-common.js');
/*
function packConfig(config) {
return Object.keys(config).map(function (key) {
var val = config[key];
if ('undefined' === val) {
throw new Error("'undefined' used as a string value");
}
if ('undefined' === typeof val) {
//console.warn('[DEBUG]', key, 'is present but undefined');
return;
}
if (val && 'object' === typeof val && !Array.isArray(val)) {
val = JSON.stringify(val);
}
return key + ':' + val; // converts arrays to strings with ,
});
}
*/
module.exports.create = function (state) {
common._init(
// make a default working dir and log dir
state._clientConfig.root || path.join(os.homedir(), '.local/share/telebit')
, (state._clientConfig.root && path.join(state._clientConfig.root, 'etc'))
|| path.resolve(common.DEFAULT_CONFIG_PATH, '..')
);
state._ipc = common.pipename(state._clientConfig, true);
function makeResponder(service, resp, fn) {
var body = '';
function finish() {
var err;
if (200 !== resp.statusCode) {
err = new Error(body || ('get ' + service + ' failed'));
err.statusCode = resp.statusCode;
err.code = "E_REQUEST";
}
if (body) {
try {
body = JSON.parse(body);
} catch(e) {
console.error('Error:', err);
// ignore
}
}
fn(err, body);
}
if (!resp.headers['content-length'] && !resp.headers['content-type']) {
finish();
return;
}
// TODO use readable
resp.on('data', function (chunk) {
body += chunk.toString();
});
resp.on('end', finish);
}
var RC = {};
RC.resolve = function (pathstr) {
// TODO use real hostname and return reqOpts rather than string?
return 'http://localhost:' + (RC.port({}).port||'1').toString() + '/' + pathstr.replace(/^\//, '');
};
RC.port = function (reqOpts) {
var fs = require('fs');
var portFile = path.join(path.dirname(state._ipc.path), 'telebit.port');
if (fs.existsSync(portFile)) {
reqOpts.host = 'localhost';
reqOpts.port = parseInt(fs.readFileSync(portFile, 'utf8').trim(), 10);
if (!state.ipc) {
state.ipc = {};
}
state.ipc.type = 'port';
state.ipc.path = path.dirname(state._ipc.path);
state.ipc.port = reqOpts.port;
} else {
reqOpts.socketPath = state._ipc.path;
}
return reqOpts;
};
RC.createRelauncher = function (replay, opts, cb) {
return function (err) {
/*global Promise*/
var p = new Promise(function (resolve, reject) {
// ENOENT - never started, cleanly exited last start, or creating socket at a different path
// ECONNREFUSED - leftover socket just needs to be restarted
if ('ENOENT' !== err.code && 'ECONNREFUSED' !== err.code) {
reject(err);
return;
}
// retried and failed again: quit
if (opts._taketwo) {
reject(err);
return;
}
require('../../usr/share/install-launcher.js').install({ env: process.env }, function (err) {
if (err) { reject(err); return; }
opts._taketwo = true;
setTimeout(function () {
if (replay.length <= 1) {
replay(opts).then(resolve).catch(reject);
return;
} else {
replay(opts, function (err, res) {
if (err) { reject(err); }
else { resolve(res); }
});
return;
}
}, 2500);
});
return;
});
if (cb) {
p.then(function () { cb(null); }).catch(function (err) { cb(err); });
}
return p;
};
};
RC.request = function request(opts, fn) {
if (!opts) { opts = {}; }
var service = opts.service || 'config';
/*
var args = opts.data;
if (args && 'control' === service) {
args = packConfig(args);
}
var json = JSON.stringify(opts.data);
*/
var url = '/rpc/' + service;
/*
if (json) {
url += ('?_body=' + encodeURIComponent(json));
}
*/
var method = opts.method || (opts.data && 'POST') || 'GET';
var reqOpts = {
method: method
, path: url
};
reqOpts = RC.port(reqOpts);
var req = http.request(reqOpts, function (resp) {
makeResponder(service, resp, fn);
});
var errHandler = RC.createRelauncher(RC.request, opts, fn);
req.on('error', errHandler);
// Simple GET
if ('POST' !== method || !opts.data) {
return keypairs.signJwt({
jwk: state.key
, claims: { iss: false, exp: Math.round(Date.now()/1000) + (15 * 60) }
//TODO , exp: '15m'
}).then(function (jwt) {
req.setHeader("Authorization", 'Bearer ' + jwt);
req.end();
});
}
return keypairs.signJws({
jwk: state.key
, protected: {
// alg will be filled out automatically
jwk: state.pub
, kid: false
, nonce: require('crypto').randomBytes(16).toString('hex') // TODO get from server
// TODO make localhost exceptional
, url: RC.resolve(reqOpts.path)
}
, payload: JSON.stringify(opts.data)
}).then(function (jws) {
req.setHeader("Content-Type", 'application/jose+json');
req.write(JSON.stringify(jws));
req.end();
});
};
return RC;
};

76
lib/ssh.js Normal file
View File

@ -0,0 +1,76 @@
'use strict';
/*global Promise*/
var PromiseA = Promise;
var crypto = require('crypto');
var util = require('util');
var readFile = util.promisify(require('fs').readFile);
var exec = require('child_process').exec;
function sshAllowsPassword(user) {
// SSH on Windows is a thing now (beta 2015, standard 2018)
// https://stackoverflow.com/questions/313111/is-there-a-dev-null-on-windows
var nullfile = '/dev/null';
if (/^win/i.test(process.platform)) {
nullfile = 'NUL';
}
var args = [
'ssh', '-v', '-n'
, '-o', 'Batchmode=yes'
, '-o', 'StrictHostKeyChecking=no'
, '-o', 'UserKnownHostsFile=' + nullfile
, user + '@localhost'
, '| true'
];
return new PromiseA(function (resolve) {
// not using promisify because all 3 arguments convey information
exec(args.join(' '), function (err, stdout, stderr) {
stdout = (stdout||'').toString('utf8');
stderr = (stderr||'').toString('utf8');
if (/\bpassword\b/.test(stdout) || /\bpassword\b/.test(stderr)) {
resolve('yes');
return;
}
if (/\bAuthentications\b/.test(stdout) || /\bAuthentications\b/.test(stderr)) {
resolve('no');
return;
}
resolve('maybe');
});
});
}
module.exports.checkSecurity = function () {
var conf = {};
var noRootPasswordRe = /(?:^|[\r\n]+)\s*PermitRootLogin\s+(prohibit-password|without-password|no)\s*/i;
var noPasswordRe = /(?:^|[\r\n]+)\s*PasswordAuthentication\s+(no)\s*/i;
var sshdConf = '/etc/ssh/sshd_config';
if (/^win/i.test(process.platform)) {
// TODO use %PROGRAMDATA%\ssh\sshd_config
sshdConf = 'C:\\ProgramData\\ssh\\sshd_config';
}
return readFile(sshdConf, null).then(function (sshd) {
sshd = sshd.toString('utf8');
var match;
match = sshd.match(noRootPasswordRe);
conf.permit_root_login = match ? match[1] : 'yes';
match = sshd.match(noPasswordRe);
conf.password_authentication = match ? match[1] : 'yes';
}).catch(function () {
// ignore error as that might not be the correct sshd_config location
}).then(function () {
var doesntExist = crypto.randomBytes(16).toString('hex');
return sshAllowsPassword(doesntExist).then(function (maybe) {
conf.requests_password = maybe;
});
}).then(function () {
return conf;
});
};
if (require.main === module) {
module.exports.checkSecurity().then(function (conf) {
console.log(conf);
return conf;
});
}

1147
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,8 @@
{
"name": "telebit",
"version": "0.20.8",
"version": "0.21.0-wip.1",
"description": "Break out of localhost. Connect to any device from anywhere over any tcp port or securely in a browser. A secure tunnel. A poor man's reverse VPN.",
"main": "lib/remote.js",
"main": "lib/daemon/index.js",
"files": [
"bin",
"lib",
@ -53,11 +53,12 @@
},
"homepage": "https://git.coolaj86.com/coolaj86/telebit.js#readme",
"dependencies": {
"@coolaj86/urequest": "^1.3.5",
"@root/request": "^1.3.10",
"finalhandler": "^1.1.1",
"greenlock": "^2.3.1",
"js-yaml": "^3.11.0",
"jsonwebtoken": "^7.1.9",
"greenlock": "^2.6.7",
"js-yaml": "^3.13.1",
"keyfetch": "^1.1.8",
"keypairs": "^1.2.14",
"mkdirp": "^0.5.1",
"proxy-packer": "^2.0.2",
"ps-list": "^5.0.0",
@ -72,6 +73,9 @@
"toml": "^0.4.1",
"ws": "^6.0.0"
},
"optionalDependencies": {
"keytar": "^4.6.0"
},
"trulyOptionalDependencies": {
"bluebird": "^3.5.1"
},

View File

@ -5,7 +5,7 @@ var pin = Math.round(Math.random() * 999999).toString().padStart(6, '0'); // '32
console.log('Pair Code:', pin);
var urequest = require('@coolaj86/urequest');
var urequest = require('@root/request');
var req = {
url: 'https://api.telebit.ppl.family/api/telebit.cloud/pair_request'
, method: 'POST'

View File

@ -2,7 +2,7 @@
var stateUrl = 'https://api.telebit.ppl.family/api/telebit.cloud/pair_state/bca27428719e9c67805359f1';
var urequest = require('@coolaj86/urequest');
var urequest = require('@root/request');
var req = {
url: stateUrl
, method: 'GET'

View File

@ -11,7 +11,7 @@ Launcher._killAll = function (fn) {
var psList = require('ps-list');
psList().then(function (procs) {
procs.forEach(function (proc) {
if ('node' === proc.name && /\btelebitd\b/i.test(proc.cmd)) {
if ('node' === proc.name && /\btelebit(d| daemon)\b/i.test(proc.cmd)) {
console.log(proc);
process.kill(proc.pid);
return true;
@ -45,37 +45,7 @@ Launcher._detect = function (things, fn) {
}
}
// could have used "command-exists" but I'm trying to stay low-dependency
// os.platform(), os.type()
if (!/^win/i.test(os.platform())) {
if (/^darwin/i.test(os.platform())) {
exec('command -v launchctl', things._execOpts, function (err, stdout, stderr) {
err = Launcher._getError(err, stderr);
fn(err, 'launchctl');
});
} else {
exec('command -v systemctl', things._execOpts, function (err, stdout, stderr) {
err = Launcher._getError(err, stderr);
fn(err, 'systemctl');
});
}
} else {
// https://stackoverflow.com/questions/17908789/how-to-add-an-item-to-registry-to-run-at-startup-without-uac
// wininit? regedit? SCM?
// REG ADD "HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" /V "My App" /t REG_SZ /F /D "C:\MyAppPath\MyApp.exe"
// https://www.microsoft.com/developerblog/2015/11/09/reading-and-writing-to-the-windows-registry-in-process-from-node-js/
// https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/reg-add
// https://social.msdn.microsoft.com/Forums/en-US/5b318f44-281e-4098-8dee-3ba8435fa391/add-registry-key-for-autostart-of-app-in-ice?forum=quebectools
// utils.elevate
// https://github.com/CatalystCode/windows-registry-node
exec('where reg.exe', things._execOpts, function (err, stdout, stderr) {
//console.log((stdout||'').trim());
if (stderr) {
console.error(stderr);
}
fn(err, 'reg.exe');
});
}
require('./which.js').launcher(things._execOpts, fn);
};
Launcher.install = function (things, fn) {
if (!fn) { fn = function (err) { if (err) { console.error(err); } }; }

63
usr/share/which.js Normal file
View File

@ -0,0 +1,63 @@
'use strict';
var os = require('os');
var exec = require('child_process').exec;
var which = module.exports;
which._getError = function getError(err, stderr) {
if (err) { return err; }
if (stderr) {
err = new Error(stderr);
err.code = 'EWHICH';
return err;
}
};
module.exports.which = function (cmd, execOpts, fn) {
return module.exports._which({
mac: cmd
, linux: cmd
, win: cmd
}, execOpts, fn);
};
module.exports.launcher = function (execOpts, fn) {
return module.exports._which({
mac: 'launchctl'
, linux: 'systemctl'
, win: 'reg.exe'
}, execOpts, fn);
};
module.exports._which = function (progs, execOpts, fn) {
// could have used "command-exists" but I'm trying to stay low-dependency
// os.platform(), os.type()
if (!/^win/i.test(os.platform())) {
if (/^darwin/i.test(os.platform())) {
exec('command -v ' + progs.mac, execOpts, function (err, stdout, stderr) {
err = which._getError(err, stderr);
fn(err, progs.mac);
});
} else {
exec('command -v ' + progs.linux, execOpts, function (err, stdout, stderr) {
err = which._getError(err, stderr);
fn(err, progs.linux);
});
}
} else {
// https://stackoverflow.com/questions/17908789/how-to-add-an-item-to-registry-to-run-at-startup-without-uac
// wininit? regedit? SCM?
// REG ADD "HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" /V "My App" /t REG_SZ /F /D "C:\MyAppPath\MyApp.exe"
// https://www.microsoft.com/developerblog/2015/11/09/reading-and-writing-to-the-windows-registry-in-process-from-node-js/
// https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/reg-add
// https://social.msdn.microsoft.com/Forums/en-US/5b318f44-281e-4098-8dee-3ba8435fa391/add-registry-key-for-autostart-of-app-in-ice?forum=quebectools
// utils.elevate
// https://github.com/CatalystCode/windows-registry-node
exec('where ' + progs.win, execOpts, function (err, stdout, stderr) {
//console.log((stdout||'').trim());
if (stderr) {
console.error(stderr);
}
fn(err, progs.win);
});
}
};