Files
sshwifty-udp-telnet-http/ui/app.js
2023-01-05 09:46:33 +02:00

454 lines
13 KiB
JavaScript

// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2022 Ni Rui <ranqus@gmail.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import Vue from "vue";
import "./app.css";
import Auth from "./auth.vue";
import { Color as ControlColor } from "./commands/color.js";
import { Commands } from "./commands/commands.js";
import { Controls } from "./commands/controls.js";
import { Presets } from "./commands/presets.js";
import * as ssh from "./commands/ssh.js";
import * as telnet from "./commands/telnet.js";
import "./common.css";
import * as sshctl from "./control/ssh.js";
import * as telnetctl from "./control/telnet.js";
import * as cipher from "./crypto.js";
import Home from "./home.vue";
import "./landing.css";
import Loading from "./loading.vue";
import { Socket } from "./socket.js";
import * as stream from "./stream/common";
import * as xhr from "./xhr.js";
const backendQueryRetryDelay = 2000;
const maxTimeDiff = 30000;
const updateIndicatorMaxDisplayTime = 3000;
const mainTemplate = `
<home
v-if="page == 'app'"
:host-path="hostPath"
:query="query"
:connection="socket"
:controls="controls"
:commands="commands"
:preset-data="presetData.presets"
:restricted-to-presets="presetData.restricted"
:view-port="viewPort"
@navigate-to="changeURLHash"
@tab-opened="tabOpened"
@tab-closed="tabClosed"
@tab-updated="tabUpdated"
></home>
<auth
v-else-if="page == 'auth'"
:error="authErr"
@auth="submitAuth"
></auth>
<loading v-else :error="loadErr"></loading>
`.trim();
const socksInterface = "/sshwifty/socket";
const socksVerificationInterface = socksInterface + "/verify";
const socksKeyTimeTruncater = 100 * 1000;
function startApp(rootEl) {
const pageTitle = document.title;
let uiControlColor = new ControlColor();
function getCurrentKeyMixer() {
return Number(
Math.trunc(new Date().getTime() / socksKeyTimeTruncater)
).toString();
}
async function buildSocketKey(privateKey) {
return new Uint8Array(
await cipher.hmac512(
stream.buildBufferFromString(privateKey),
stream.buildBufferFromString(getCurrentKeyMixer())
)
).slice(0, 16);
}
new Vue({
el: rootEl,
components: {
loading: Loading,
auth: Auth,
home: Home,
},
data() {
return {
hostPath:
window.location.protocol +
"//" +
window.location.host +
window.location.pathname,
query:
window.location.hash.length > 0 &&
window.location.hash.indexOf("#") === 0
? window.location.hash.slice(1, window.location.hash.length)
: "",
page: "loading",
key: "",
presetData: {
presets: new Presets([]),
restricted: false,
},
authErr: "",
loadErr: "",
socket: null,
controls: new Controls([
new telnetctl.Telnet(uiControlColor),
new sshctl.SSH(uiControlColor),
]),
commands: new Commands([new telnet.Command(), new ssh.Command()]),
tabUpdateIndicator: null,
viewPort: {
dim: {
width: 0,
height: 0,
renew(width, height) {
this.width = width;
this.height = height;
},
},
},
viewPortUpdaters: {
width: 0,
height: 0,
dimResizer: null,
},
};
},
watch: {
loadErr() {
this.isErrored()
? document.body.classList.add("app-error")
: document.body.classList.remove("app-error");
},
authErr() {
this.isErrored()
? document.body.classList.add("app-error")
: document.body.classList.remove("app-error");
},
},
mounted() {
const self = this;
self.tryInitialAuth();
self.viewPortUpdaters.dimResizer = () => {
self.viewPortUpdaters.height = window.innerHeight;
self.viewPortUpdaters.width = window.innerWidth;
self.$nextTick(() => {
self.viewPort.dim.renew(
self.viewPortUpdaters.width,
self.viewPortUpdaters.height
);
});
};
window.addEventListener("resize", self.viewPortUpdaters.dimResizer);
},
beforeDestroy() {
window.removeEventListener("resize", self.viewPortUpdaters.dimResizer);
},
methods: {
changeTitleInfo(newTitleInfo) {
document.title = newTitleInfo + " " + pageTitle;
},
resetTitleInfo() {
document.title = pageTitle;
},
changeURLHash(newHash) {
window.location.hash = newHash;
},
isErrored() {
return this.authErr.length > 0 || this.loadErr.length > 0;
},
async getSocketAuthKey(privateKey) {
const enc = new TextEncoder(),
rTime = Number(Math.trunc(new Date().getTime() / 100000));
var finalKey = "";
if (privateKey.length <= 0) {
finalKey = "DEFAULT VERIFY KEY";
} else {
finalKey = privateKey;
}
return new Uint8Array(
await cipher.hmac512(enc.encode(finalKey), enc.encode(rTime))
).slice(0, 32);
},
buildBackendSocketURLs() {
let r = {
webSocket: "",
keepAlive: "",
};
switch (location.protocol) {
case "https:":
r.webSocket = "wss://";
break;
default:
r.webSocket = "ws://";
}
r.webSocket += location.host + socksInterface;
r.keepAlive = location.protocol + "//" + location.host + socksInterface;
return r;
},
buildSocket(key, dialTimeout, heartbeatInterval) {
return new Socket(
this.buildBackendSocketURLs(),
key,
dialTimeout * 1000,
heartbeatInterval * 1000
);
},
executeHomeApp(authResult, key) {
this.presetData = {
presets: new Presets(JSON.parse(authResult.data)),
restricted: authResult.onlyAllowPresetRemotes,
};
this.socket = this.buildSocket(
key,
authResult.timeout,
authResult.heartbeat
);
this.page = "app";
},
async doAuth(privateKey) {
let result = await this.requestAuth(privateKey);
if (result.key) {
this.key = result.key;
}
return result;
},
async requestAuth(privateKey) {
let authKey =
!privateKey || !this.key
? null
: await this.getSocketAuthKey(privateKey);
let h = await xhr.get(socksVerificationInterface, {
"X-Key": authKey
? btoa(String.fromCharCode.apply(null, authKey))
: "",
});
let serverDate = h.getResponseHeader("Date");
return {
result: h.status,
key: h.getResponseHeader("X-Key"),
timeout: h.getResponseHeader("X-Timeout"),
heartbeat: h.getResponseHeader("X-Heartbeat"),
date: serverDate ? new Date(serverDate) : null,
data: h.responseText,
onlyAllowPresetRemotes:
h.getResponseHeader("X-OnlyAllowPresetRemotes") === "yes",
};
},
async tryInitialAuth() {
try {
let result = await this.doAuth("");
if (result.date) {
let serverTime = result.date.getTime(),
clientTime = new Date().getTime(),
timeDiff = Math.abs(serverTime - clientTime);
// if (timeDiff > maxTimeDiff) {
// this.loadErr =
// "The time difference between this client " +
// "and the backend server is beyond operational limit.\r\n\r\n" +
// "Please try reload the page, and if the problem persisted, " +
// "consider to adjust your local time so both the client and " +
// "the server are running at same date time";
// return;
// }
}
let self = this;
switch (result.result) {
case 200:
this.executeHomeApp(result, {
async fetch() {
let result = await self.doAuth("");
if (result.result !== 200) {
throw new Error(
"Unable to fetch key from remote, unexpected " +
"error code: " +
result.result
);
}
return await buildSocketKey(atob(result.key) + "+");
},
});
break;
case 403:
this.page = "auth";
break;
case 0:
setTimeout(() => {
this.tryInitialAuth();
}, backendQueryRetryDelay);
break;
default:
alert("Unexpected backend query status: " + result.result);
}
} catch (e) {
this.loadErr = "Unable to initialize client application: " + e;
}
},
async submitAuth(passphrase) {
this.authErr = "";
try {
let result = await this.doAuth(passphrase);
let self = this;
switch (result.result) {
case 200:
this.executeHomeApp(result, {
async fetch() {
let result = await self.doAuth(passphrase);
if (result.result !== 200) {
throw new Error(
"Unable to fetch key from remote, unexpected " +
"error code: " +
result.result
);
}
return await buildSocketKey(
atob(result.key) + "+" + passphrase
);
},
});
break;
case 403:
this.authErr = "Authentication has failed. Wrong passphrase?";
break;
default:
this.authErr =
"Unexpected backend query status: " + result.result;
}
} catch (e) {
this.authErr = "Unable to authenticate: " + e;
}
},
updateTabTitleInfo(tabs, updated) {
if (tabs.length <= 0) {
this.resetTitleInfo();
return;
}
this.changeTitleInfo("(" + tabs.length + (updated ? "*" : "") + ")");
},
tabOpened(tabs) {
this.tabUpdated(tabs);
},
tabClosed(tabs) {
if (tabs.length > 0) {
this.updateTabTitleInfo(tabs, this.tabUpdateIndicator !== null);
return;
}
if (this.tabUpdateIndicator) {
clearTimeout(this.tabUpdateIndicator);
this.tabUpdateIndicator = null;
}
this.updateTabTitleInfo(tabs, false);
},
tabUpdated(tabs) {
if (this.tabUpdateIndicator) {
clearTimeout(this.tabUpdateIndicator);
this.tabUpdateIndicator = null;
}
this.updateTabTitleInfo(tabs, true);
this.tabUpdateIndicator = setTimeout(() => {
this.tabUpdateIndicator = null;
this.updateTabTitleInfo(tabs, false);
}, updateIndicatorMaxDisplayTime);
},
},
});
}
function initializeClient() {
let landingRoot = document.getElementById("landing");
if (!landingRoot) {
return;
}
if (process.env.NODE_ENV === "development") {
console.log("Currently in Development environment");
}
window.addEventListener("unhandledrejection", function (e) {
console.error("Error:", e);
});
window.addEventListener("error", function (e) {
console.error("Error:", e);
});
landingRoot.parentNode.removeChild(landingRoot);
let normalRoot = document.createElement("div");
normalRoot.setAttribute("id", "app");
normalRoot.innerHTML = mainTemplate;
document.body.insertBefore(normalRoot, document.body.firstChild);
startApp(normalRoot);
}
window.addEventListener("load", initializeClient);
document.addEventListener("load", initializeClient);
document.addEventListener("DOMContentLoaded", initializeClient);