579 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			579 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| ;(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({
 | |
|     url: "/api/config"
 | |
|   , method: "GET"
 | |
|   }).then(function (resp) {
 | |
|     var json = resp.body;
 | |
|     appData.config = json;
 | |
|     return json;
 | |
|   });
 | |
| };
 | |
| api.status = function apiStatus() {
 | |
|   return Telebit.reqLocalAsync({ url: "/api/status", method: "GET" }).then(function (resp) {
 | |
|     var json = resp.body;
 | |
|     return json;
 | |
|   });
 | |
| };
 | |
| api.http = function apiHttp(o) {
 | |
|   var opts = {
 | |
|     url: "/api/http"
 | |
|   , method: "POST"
 | |
|   , headers: { 'Content-Type': 'application/json' }
 | |
|   , json: { name: o.name, handler: o.handler, indexes: o.indexes }
 | |
|   };
 | |
|   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 = {
 | |
|     url: "/api/ssh"
 | |
|   , method: "POST"
 | |
|   , headers: { 'Content-Type': 'application/json' }
 | |
|   , json: { port: port }
 | |
|   };
 | |
|   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 = {
 | |
|     url: "/api/enable"
 | |
|   , method: "POST"
 | |
|   //, headers: { 'Content-Type': 'application/json' }
 | |
|   };
 | |
|   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 = {
 | |
|     url: "/api/disable"
 | |
|   , method: "POST"
 | |
|   //, headers: { 'Content-Type': 'application/json' }
 | |
|   };
 | |
|   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");
 | |
|     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 run(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 getEmail() {
 | |
|   return Promise.resolve().then(function () {
 | |
|     var email = localStorage.getItem('email');
 | |
|     if (email) { return email; }
 | |
|     while (!email) {
 | |
|       email = window.prompt("Email address (device owner)?");
 | |
|     }
 | |
|     return email;
 | |
|   });
 | |
| }
 | |
| function requestAccount() {
 | |
|   return getKey().then(function (jwk) {
 | |
|     return getEmail().then(function(email) {
 | |
|       // 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;
 | |
| requestAccount().then(function (jwk) {
 | |
|   run(jwk);
 | |
|   setTimeout(function () {
 | |
|     document.body.hidden = false;
 | |
|   }, 50);
 | |
| });
 | |
| 
 | |
| }());
 |